diff --git a/ui/src/components/account/account-safety-warning-card.tsx b/ui/src/components/account/account-safety-warning-card.tsx index b412caae..441df970 100644 --- a/ui/src/components/account/account-safety-warning-card.tsx +++ b/ui/src/components/account/account-safety-warning-card.tsx @@ -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 @@ -123,6 +124,7 @@ export function AccountSafetyWarningCard({

{firstLine}

{secondLine}

+ {/* 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.

@@ -148,6 +150,7 @@ export function AccountSafetyWarningCard({ )} + {/* TODO i18n: missing key for "Applies to CLI and dashboard auth" */} Applies to CLI and dashboard auth @@ -155,6 +158,7 @@ export function AccountSafetyWarningCard({ {showAcknowledgement && onAcknowledgementTextChange && (
@@ -590,7 +600,8 @@ export function AddAccountDialog({
{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')}

)} diff --git a/ui/src/components/account/antigravity-responsibility-checklist.tsx b/ui/src/components/account/antigravity-responsibility-checklist.tsx index 8efd6eda..15241e47 100644 --- a/ui/src/components/account/antigravity-responsibility-checklist.tsx +++ b/ui/src/components/account/antigravity-responsibility-checklist.tsx @@ -78,6 +78,7 @@ export function AntigravityResponsibilityChecklist({ disabled={disabled} /> @@ -91,6 +92,7 @@ export function AntigravityResponsibilityChecklist({ disabled={disabled} /> @@ -106,6 +108,7 @@ export function AntigravityResponsibilityChecklist({ disabled={disabled} /> @@ -115,6 +118,7 @@ export function AntigravityResponsibilityChecklist({
+ {/* TODO i18n: missing key for step 4 */} Step 4: Type exact phrase to continue
- Read issue #509 + {/* TODO i18n: missing key */}Read issue #509
diff --git a/ui/src/components/account/flow-viz/account-card-stats.tsx b/ui/src/components/account/flow-viz/account-card-stats.tsx index 3b3a18f3..6ff2fcba 100644 --- a/ui/src/components/account/flow-viz/account-card-stats.tsx +++ b/ui/src/components/account/flow-viz/account-card-stats.tsx @@ -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
- Success Rate + {t('authMonitorLive.successRate')}
+ {/* TODO i18n: missing key for "Volume" */} Volume diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index debb9d8b..52291e11 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -173,7 +173,10 @@ export function AccountCard({
variant.audienceLabel ?? variant.detailLabel ?? 'Variant') + .map( + (variant) => + variant.audienceLabel ?? variant.detailLabel ?? t('accountSurfaceCard.variant') + ) .join(' • ')} > {groupedVariantSummaryLabel ? ( @@ -204,6 +207,7 @@ export function AccountCard({
{account.paused && ( + {/* TODO i18n: missing key for compact "Paused" */} Paused )} diff --git a/ui/src/components/account/shared/account-quota-panel.tsx b/ui/src/components/account/shared/account-quota-panel.tsx index fc52019c..09c23ffb 100644 --- a/ui/src/components/account/shared/account-quota-panel.tsx +++ b/ui/src/components/account/shared/account-quota-panel.tsx @@ -144,7 +144,9 @@ export function AccountQuotaPanel({ return (
- {mode === 'compact' ? t('accountCard.quotaLoading') : 'Loading quota...'} + + {mode === 'compact' ? t('accountCard.quotaLoading') : t('accountQuotaPanel.loadingQuota')} +
); } @@ -204,20 +206,24 @@ export function AccountQuotaPanel({ <> - Active · {formatRelativeTime(runtimeLastUsed)} + {/* TODO i18n: missing key for "Active" */}Active ·{' '} + {formatRelativeTime(runtimeLastUsed)} ) : runtimeLastUsed ? ( <> - Last used {formatRelativeTime(runtimeLastUsed)} + {/* TODO i18n: missing key for "Last used" */}Last used{' '} + {formatRelativeTime(runtimeLastUsed)} ) : ( <> - Not used yet + + {t('accountCardStats.notUsedYet')} + )}
diff --git a/ui/src/components/account/shared/account-surface-card.tsx b/ui/src/components/account/shared/account-surface-card.tsx index 783f3054..29391d05 100644 --- a/ui/src/components/account/shared/account-surface-card.tsx +++ b/ui/src/components/account/shared/account-surface-card.tsx @@ -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)} )} {paused && ( + {/* TODO i18n: missing key for compact "Paused" badge */} Paused )} @@ -205,6 +211,7 @@ export function AccountSurfaceCard({ {!isCompact && isDefault && ( + {/* TODO i18n: missing key for "Default" badge */} Default )} @@ -214,6 +221,7 @@ export function AccountSurfaceCard({ className="text-[10px] h-4 px-1.5 border-yellow-500 text-yellow-600" > + {/* TODO i18n: missing key for "Paused" badge */} Paused )} diff --git a/ui/src/components/analytics/cache-efficiency-card.tsx b/ui/src/components/analytics/cache-efficiency-card.tsx index c079c659..a9d2fe0a 100644 --- a/ui/src/components/analytics/cache-efficiency-card.tsx +++ b/ui/src/components/analytics/cache-efficiency-card.tsx @@ -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 + {/* TODO i18n: missing key for "Cache Efficiency" */} Cache Efficiency -

No cache data available

+

+ {t('analyticsCards.noCacheData')} +

); @@ -88,6 +93,7 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie + {/* TODO i18n: missing key for "Cache Efficiency" */} Cache Efficiency @@ -101,6 +107,7 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie

+ {/* TODO i18n: missing key for "Estimated Savings" */} Estimated Savings

@@ -115,7 +122,9 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie {metrics.cacheHitRate.toFixed(0)}% -

Hit Rate

+

+ {t('analyticsCards.hitRate')} +

{/* Cache Cost */} @@ -123,7 +132,9 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie ${metrics.cacheCost.toFixed(2)} -

Cache Cost

+

+ {t('analyticsCards.cacheCost')} +

@@ -137,6 +148,7 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie > Reads: {formatCompact(metrics.totalCacheReads)} Writes: {formatCompact(metrics.totalCacheWrites)} + {/* TODO i18n: missing keys for "Reads:" / "Writes:" */}
+ {/* TODO i18n: missing key for "Read" */} Read
+ {/* TODO i18n: missing key for "Write" */} Write
diff --git a/ui/src/components/analytics/cliproxy-stats-card.tsx b/ui/src/components/analytics/cliproxy-stats-card.tsx index 0b3003e7..01873e0d 100644 --- a/ui/src/components/analytics/cliproxy-stats-card.tsx +++ b/ui/src/components/analytics/cliproxy-stats-card.tsx @@ -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({ + {/* TODO i18n: missing key for "CLIProxy Stats" */} CLIProxy Stats @@ -62,16 +65,17 @@ export function CliproxyStatsCard({
+ {/* TODO i18n: missing key for "CLIProxy Stats" */} CLIProxy Stats - Offline + {t('cliproxyStatsOverview.offline')}

- Start a CLIProxy session (gemini, codex, agy) to collect stats. + {t('cliproxyStatsOverview.noActiveSessionHint')}

@@ -91,9 +95,11 @@ export function CliproxyStatsCard({
+ {/* TODO i18n: missing key for "CLIProxy Stats" */} CLIProxy Stats + {/* TODO i18n: missing key for "Error" */} Error
@@ -125,6 +131,7 @@ export function CliproxyStatsCard({
+ {/* TODO i18n: missing key for "CLIProxy Stats" */} CLIProxy Stats - Running + {t('cliproxyStatsOverview.running')}
@@ -176,6 +183,7 @@ export function CliproxyStatsCard({
{failedRequests > 0 ? `${failedRequests} failed` : 'All success'} + {/* TODO i18n: missing keys for "failed" / "All success" */}
@@ -187,7 +195,9 @@ export function CliproxyStatsCard({
{formatNumber(totalTokens)}
-
Total tokens
+
+ {t('cliproxyStatsOverview.totalTokens')} +
@@ -197,7 +207,7 @@ export function CliproxyStatsCard({
- Models Used + {t('cliproxyStatsOverview.modelsUsed')}
{models.map(([model, count]) => { diff --git a/ui/src/components/analytics/date-range-filter.tsx b/ui/src/components/analytics/date-range-filter.tsx index a644db6d..6cbecadd 100644 --- a/ui/src/components/analytics/date-range-filter.tsx +++ b/ui/src/components/analytics/date-range-filter.tsx @@ -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') ) ) : ( - Pick a date + {t('dateRangeFilter.pickADate')} )} diff --git a/ui/src/components/analytics/model-breakdown-chart.tsx b/ui/src/components/analytics/model-breakdown-chart.tsx index 60bca809..e39bbfa0 100644 --- a/ui/src/components/analytics/model-breakdown-chart.tsx +++ b/ui/src/components/analytics/model-breakdown-chart.tsx @@ -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 (
-

No model data available

+

{t('analyticsCards.noModelData')}

); } diff --git a/ui/src/components/analytics/model-details-content.tsx b/ui/src/components/analytics/model-details-content.tsx index 4aca6511..b360b3da 100644 --- a/ui/src/components/analytics/model-details-content.tsx +++ b/ui/src/components/analytics/model-details-content.tsx @@ -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) {

${model.cost.toFixed(2)}

-

Total Cost

+

+ {t('analyticsCards.totalCost')} +

{formatCompactNumber(model.tokens)}

-

Total Tokens

+

+ {t('analyticsCards.totalTokens')} +

{/* Token Breakdown */}
+ {/* TODO i18n: missing key for "Token Breakdown" */} Token Breakdown
+ {/* TODO i18n: missing keys for Input/Output/Cache Write/Cache Read labels */}
- Input/Output Ratio + {t('analyticsCards.inputOutputRatio')}

{ioRatioStatus.description} diff --git a/ui/src/components/analytics/session-stats-card.tsx b/ui/src/components/analytics/session-stats-card.tsx index 6d5c73a2..dbe6ec21 100644 --- a/ui/src/components/analytics/session-stats-card.tsx +++ b/ui/src/components/analytics/session-stats-card.tsx @@ -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 + {/* TODO i18n: missing key for "Session Stats" */} Session Stats -

No session data available

+

+ {t('analyticsCards.noSessionData')} +

); @@ -89,6 +94,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar + {/* TODO i18n: missing key for "Session Stats" */} Session Stats @@ -102,6 +108,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar {stats.totalSessions}

+ {/* TODO i18n: missing key for "Total Sessions" */} Total Sessions

@@ -115,6 +122,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar

+ {/* TODO i18n: missing key for "Avg Cost/Session" */} Avg Cost/Session

@@ -124,6 +132,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
+ {/* TODO i18n: missing key for "Recent Activity" */} Recent Activity
diff --git a/ui/src/components/analytics/token-breakdown-chart.tsx b/ui/src/components/analytics/token-breakdown-chart.tsx index 24817750..6e12df6e 100644 --- a/ui/src/components/analytics/token-breakdown-chart.tsx +++ b/ui/src/components/analytics/token-breakdown-chart.tsx @@ -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 (
-

No token data available

+

{t('analyticsCards.noTokenData')}

); } diff --git a/ui/src/components/analytics/usage-insights-card.tsx b/ui/src/components/analytics/usage-insights-card.tsx index 7c11461a..c5c6ec5d 100644 --- a/ui/src/components/analytics/usage-insights-card.tsx +++ b/ui/src/components/analytics/usage-insights-card.tsx @@ -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 (
-

All Systems Nominal

+

+ {t('healthCard.allSystemsNominal')} +

Your usage patterns are within normal ranges for the selected period.

@@ -104,13 +109,15 @@ export function UsageInsightsCard({
-

Usage Insights

+

{t('analyticsCards.usageInsights')}

- {summary.totalAnomalies} {summary.totalAnomalies === 1 ? 'Alert' : 'Alerts'} + {summary.totalAnomalies}{' '} + {/* TODO i18n: missing key for singular/plural "Alert"/"Alerts" */}{' '} + {summary.totalAnomalies === 1 ? 'Alert' : 'Alerts'}
diff --git a/ui/src/components/analytics/usage-trend-chart.tsx b/ui/src/components/analytics/usage-trend-chart.tsx index c3a47653..f7cd2d2f 100644 --- a/ui/src/components/analytics/usage-trend-chart.tsx +++ b/ui/src/components/analytics/usage-trend-chart.tsx @@ -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 (

+ {/* 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'}

diff --git a/ui/src/components/auth/user-menu.tsx b/ui/src/components/auth/user-menu.tsx index 4bc8cdac..d9fc5601 100644 --- a/ui/src/components/auth/user-menu.tsx +++ b/ui/src/components/auth/user-menu.tsx @@ -36,7 +36,7 @@ export function UserMenu() { - Sign Out + {/* TODO i18n: missing key for "Sign Out" */}Sign Out diff --git a/ui/src/components/cliproxy/ai-providers/family-rail.tsx b/ui/src/components/cliproxy/ai-providers/family-rail.tsx index 28e9432d..a8f08fcf 100644 --- a/ui/src/components/cliproxy/ai-providers/family-rail.tsx +++ b/ui/src/components/cliproxy/ai-providers/family-rail.tsx @@ -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', }; } diff --git a/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx b/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx index 941ac32b..05ea16f9 100644 --- a/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx +++ b/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx @@ -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'} ); @@ -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') { diff --git a/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx b/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx index d3dfc74c..77c10e52 100644 --- a/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx +++ b/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx @@ -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({
-
Required setup
+
+ {t('aiProvidersEntryDialog.requiredSetup')} +
Save the smallest working configuration first.
@@ -391,7 +395,9 @@ export function ProviderEntryDialog({ {supportsOpenAiCompat ? (
- +
-
Optional routing
+
+ {t('aiProvidersEntryDialog.optionalRouting')} +
Only fill these when the route needs more than the default behavior.
diff --git a/ui/src/components/cliproxy/cliproxy-dialog.tsx b/ui/src/components/cliproxy/cliproxy-dialog.tsx index 69f9a0e9..b221b8a6 100644 --- a/ui/src/components/cliproxy/cliproxy-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-dialog.tsx @@ -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) { - Create CLIProxy Variant + {t('providerEditor.createVariant')}
diff --git a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx index 32c0e229..9274e975 100644 --- a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx @@ -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(); diff --git a/ui/src/components/cliproxy/cliproxy-header.tsx b/ui/src/components/cliproxy/cliproxy-header.tsx index a2bcf5e5..8e64b4cb 100644 --- a/ui/src/components/cliproxy/cliproxy-header.tsx +++ b/ui/src/components/cliproxy/cliproxy-header.tsx @@ -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(null); @@ -157,7 +159,9 @@ export function CliproxyHeader({

{versionInfo?.backendLabel ?? 'CLIProxy'}

-

CCS-level account management

+

+ {t('cliproxyHeader.ccsLevelAccountManagement')} +

{/* 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')} {versionInfo && ( diff --git a/ui/src/components/cliproxy/cliproxy-stats-overview.tsx b/ui/src/components/cliproxy/cliproxy-stats-overview.tsx index e4668e80..9f31f3e1 100644 --- a/ui/src/components/cliproxy/cliproxy-stats-overview.tsx +++ b/ui/src/components/cliproxy/cliproxy-stats-overview.tsx @@ -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)

- Session Statistics + {t('cliproxyStatsOverview.sessionStatistics')}

- Real-time usage metrics from {backendLabel} + {t('cliproxyStatsOverview.realTimeMetrics', { backend: backendLabel })}

- Offline + {t('cliproxyStatsOverview.offline')}
@@ -88,13 +90,9 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
-

No Active Session

+

{t('cliproxyStatsOverview.noActiveSession')}

- Start a CLIProxy session using{' '} - ccs gemini,{' '} - ccs codex, - or ccs agy{' '} - to view real-time statistics. + {t('cliproxyStatsOverview.noActiveSessionHint')}

@@ -110,7 +108,7 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
-

Failed to Load Statistics

+

{t('cliproxyStatsOverview.failedLoadStats')}

{error.message}

@@ -147,10 +145,10 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)

- Session Statistics + {t('cliproxyStatsOverview.sessionStatistics')}

- Real-time usage metrics from {backendLabel} + {t('cliproxyStatsOverview.realTimeMetrics', { backend: backendLabel })}

- Running + {t('cliproxyStatsOverview.running')}
@@ -169,11 +167,15 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
-

Total Requests

+

+ {t('cliproxyStatsOverview.totalRequests')} +

{formatNumber(totalRequests)}

- {successRequests} success + + {t('cliproxyStatsOverview.successCount', { count: successRequests })} +
@@ -188,7 +190,9 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
-

Success Rate

+

+ {t('cliproxyStatsOverview.successRate')} +

{successRate}%

-

Total Tokens

+

+ {t('cliproxyStatsOverview.totalTokens')} +

{formatNumber(totalTokens)}

@@ -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), + })}

@@ -248,7 +256,9 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)
-

Models Used

+

+ {t('cliproxyStatsOverview.modelsUsed')} +

{models.length}

{models.length > 0 ? formatModelName(models[0][0]) : 'None'} @@ -268,7 +278,7 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps) - Model Usage Distribution + {t('cliproxyStatsOverview.modelUsageDistribution')} @@ -289,7 +299,7 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)

- {count} requests + {t('cliproxyStatsOverview.requestCount', { count })} {percentage}%
diff --git a/ui/src/components/cliproxy/cliproxy-table.tsx b/ui/src/components/cliproxy/cliproxy-table.tsx index f34ff317..d355f551 100644 --- a/ui/src/components/cliproxy/cliproxy-table.tsx +++ b/ui/src/components/cliproxy/cliproxy-table.tsx @@ -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(null); + const { t } = useTranslation(); const columns: ColumnDef[] = [ { accessorKey: 'name', - header: 'Name', + header: t('cliproxyTable.name'), cell: ({ row }) => {row.original.name}, }, { accessorKey: 'provider', - header: 'Provider', + header: t('cliproxyTable.provider'), cell: ({ row }) => { if (row.original.type === 'composite') { - return composite; + return {t('providerEditor.composite')}; } return getProviderDisplayName(row.original.provider); }, }, { accessorKey: 'account', - header: 'Account', + header: t('cliproxyTable.account'), cell: ({ row }) => { const account = row.original.account; if (!account) { - return default; + return ( + + {t('providerEditor.defaultLabel')} + + ); } return ( @@ -89,7 +95,7 @@ export function CliproxyTable({ data }: CliproxyTableProps) { }, { id: 'actions', - header: 'Actions', + header: t('cliproxyTable.actions'), cell: ({ row }) => (
@@ -102,6 +108,7 @@ export function CliproxyTable({ data }: CliproxyTableProps) { setEditingVariant(row.original)}> + {/* TODO i18n: missing key for "Edit" */} Edit deleteMutation.mutate(row.original.name)} > + {/* TODO i18n: missing key for "Delete" */} Delete @@ -128,7 +136,7 @@ export function CliproxyTable({ data }: CliproxyTableProps) { if (data.length === 0) { return (
-
No CLIProxy variants found.
+
{t('cliproxyHeader.noVariants')}
Create one to use OAuth-based providers with specific account configurations.
diff --git a/ui/src/components/cliproxy/cliproxy-tabs.tsx b/ui/src/components/cliproxy/cliproxy-tabs.tsx index f7dbf560..74a83f6e 100644 --- a/ui/src/components/cliproxy/cliproxy-tabs.tsx +++ b/ui/src/components/cliproxy/cliproxy-tabs.tsx @@ -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 ( - {TAB_CONFIG.map(({ value, label, icon: Icon }) => ( + {TAB_CONFIG.map(({ value, labelKey, icon: Icon }) => ( - {label} + {t(labelKey)} ))} diff --git a/ui/src/components/cliproxy/config/config-split-view.tsx b/ui/src/components/cliproxy/config/config-split-view.tsx index e2b195bf..162ac104 100644 --- a/ui/src/components/cliproxy/config/config-split-view.tsx +++ b/ui/src/components/cliproxy/config/config-split-view.tsx @@ -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('config.yaml'); const [showDiff, setShowDiff] = useState(false); @@ -82,7 +84,7 @@ export function ConfigSplitView() { {selectedFile} {isDirty && isEditingConfig && ( - Modified + {t('cliproxyConfig.modified')} )}
diff --git a/ui/src/components/cliproxy/config/diff-dialog.tsx b/ui/src/components/cliproxy/config/diff-dialog.tsx index 0ffe97db..b3ef04ac 100644 --- a/ui/src/components/cliproxy/config/diff-dialog.tsx +++ b/ui/src/components/cliproxy/config/diff-dialog.tsx @@ -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({ - Review Changes + {t('cliproxyConfig.reviewChanges')}
-
Original
-
Modified
+
{t('cliproxyConfig.original')}
+
{t('cliproxyConfig.modified')}
{renderDiff()}
@@ -78,10 +80,13 @@ export function DiffDialog({ diff --git a/ui/src/components/cliproxy/config/yaml-editor.tsx b/ui/src/components/cliproxy/config/yaml-editor.tsx index ff4ce276..af704ae5 100644 --- a/ui/src/components/cliproxy/config/yaml-editor.tsx +++ b/ui/src/components/cliproxy/config/yaml-editor.tsx @@ -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 (
@@ -116,7 +118,7 @@ export function EditorStatusBar({ )} - {isDirty && Unsaved changes} + {isDirty && {t('cliproxyConfig.unsavedChanges')}}
{cursorLine && cursorCol && ( diff --git a/ui/src/components/cliproxy/control-panel-embed.tsx b/ui/src/components/cliproxy/control-panel-embed.tsx index 2b0346df..1a5c6b1d 100644 --- a/ui/src/components/cliproxy/control-panel-embed.tsx +++ b/ui/src/components/cliproxy/control-panel-embed.tsx @@ -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(null); const [loadedFrameKey, setLoadedFrameKey] = useState(null); const [iframeRevision, setIframeRevision] = useState(0); @@ -280,7 +282,7 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
-

CLIProxy Control Panel

+

{t('cliproxyHeader.cliproxyControlPanel')}

-

CLIProxy Not Available

+

+ {t('cliproxyHeader.cliproxyNotAvailable')} +

{error}

Start a CLIProxy session with{' '} diff --git a/ui/src/components/cliproxy/extended-context-toggle.tsx b/ui/src/components/cliproxy/extended-context-toggle.tsx index c695e36b..ff9f0a3e 100644 --- a/ui/src/components/cliproxy/extended-context-toggle.tsx +++ b/ui/src/components/cliproxy/extended-context-toggle.tsx @@ -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({

- Extended Context + {t('extendedContext.extendedContext')} 1M tokens diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 59a401df..5da3a360 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -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 ( @@ -50,7 +56,7 @@ function renderProjectId(projectId: string | undefined, privacyMode: boolean | u
-

GCP Project ID (read-only)

+

{t('providerEditor.gcpProjectIdReadonly')}

@@ -63,13 +69,13 @@ function renderProjectId(projectId: string | undefined, privacyMode: boolean | u
- Project ID: N/A + {t('providerEditor.projectIdNA')}
-

Missing Project ID

-

This may cause errors. Remove the account and re-add it to fetch the project ID.

+

{t('providerEditor.missingProjectId')}

+

{t('providerEditor.missingProjectIdHint')}

@@ -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" /> diff --git a/ui/src/components/cliproxy/provider-editor/accounts-section.tsx b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx index cef6301e..e97a1318 100644 --- a/ui/src/components/cliproxy/provider-editor/accounts-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx @@ -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>(new Set()); @@ -140,6 +142,7 @@ export function AccountsSection({ /> )} + {/* TODO i18n: missing key for "Accounts" */} Accounts {accounts.length > 0 && ( @@ -191,8 +194,8 @@ export function AccountsSection({ ) : (
-

No accounts connected

-

Add an account to get started

+

{t('providerEditor.noAccountsConnected')}

+

{t('providerEditor.addAccountToStart')}

)} @@ -202,7 +205,7 @@ export function AccountsSection({
- Use incognito + {t('providerEditor.useIncognito')}
{isPausing ? : } + {/* TODO i18n: missing key for "Pause Selected" */} Pause Selected
diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index 644d3d9c..2022ce5c 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -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 ? (
- Loading settings... + {t('providerEditor.loadingSettings')}
) : (
@@ -312,7 +314,7 @@ export function ProviderEditor({
- Raw Configuration (JSON) + {t('rawEditorSection.rawConfig')} (JSON)
['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({

+ {/* TODO i18n: missing key for "Presets" */} Presets

-

Apply pre-configured model mappings

+

{t('providerEditor.presets')}

{presetGroups.map((group) => (
@@ -204,7 +207,7 @@ export function ModelConfigSection({ {/* Model Mapping */}
-

Model Mapping

+

{t('providerEditor.modelMapping')}

Configure which models to use for each tier

diff --git a/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx b/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx index 1333c499..dc251b61 100644 --- a/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx +++ b/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx @@ -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 (
@@ -67,12 +69,13 @@ export function ProviderEditorHeader({
{isRemoteMode ? (

+ {/* TODO i18n: missing key for remote traffic text */} Traffic auto-routed to remote server

) : ( data && (

- Last modified: {new Date(data.mtime).toLocaleString()} + {t('providerEditor.lastModified')}: {new Date(data.mtime).toLocaleString()}

) )} diff --git a/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx b/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx index d8af6ad2..2ea26cdf 100644 --- a/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx +++ b/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx @@ -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({

+ {/* TODO i18n: missing key for "Provider Information" */} Provider Information

- Provider + + {t('providerEditor.provider')} + {displayName}
{data && ( <>
- File Path + + {t('providerEditor.filePath')} +
{data.path} @@ -63,13 +70,17 @@ export function ProviderInfoTab({
- Last Modified + + {t('providerEditor.lastModified')} + {new Date(data.mtime).toLocaleString()}
)}
- Status + + {t('providerEditor.status')} + {authStatus.authenticated ? (
- Default Target + + {t('providerEditor.defaultTarget')} + {resolvedTarget}
@@ -93,7 +106,7 @@ export function ProviderInfoTab({ {/* Quick Usage */}
-

Quick Usage

+

{t('providerEditor.quickUsage')}

{isCodexProvider && ( diff --git a/ui/src/components/cliproxy/provider-editor/raw-editor-section.tsx b/ui/src/components/cliproxy/provider-editor/raw-editor-section.tsx index 904a4dcc..e987b1c1 100644 --- a/ui/src/components/cliproxy/provider-editor/raw-editor-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/raw-editor-section.tsx @@ -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={
- Loading editor... + {t('providerEditor.loadingEditor')}
} > diff --git a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts index 1166dfbd..8412ec44 100644 --- a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts +++ b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts @@ -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) => { diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index 737915db..75a2977e 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -379,12 +379,12 @@ export function FlexibleModelSelector({ {model.tier === 'paid' && } {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? ( - Shadowed + {t('providerModelSelector.shadowed')} ) : null} {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? ( - Prefix only + {t('providerModelSelector.prefixOnly')} ) : null} {isCodexProvider && } @@ -425,12 +425,12 @@ export function FlexibleModelSelector({ {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? ( - Shadowed + {t('providerModelSelector.shadowed')} ) : null} {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? ( - Prefix only + {t('providerModelSelector.prefixOnly')} ) : null} {isCodexProvider && } @@ -450,7 +450,7 @@ export function FlexibleModelSelector({
{value} - Current + {t('providerModelSelector.current')}
), @@ -458,7 +458,7 @@ export function FlexibleModelSelector({
{value} - Current + {t('providerModelSelector.current')}
), @@ -489,7 +489,11 @@ export function FlexibleModelSelector({ ? [ { key: 'current', - label: Current value, + label: ( + + {t('providerModelSelector.currentValue')} + + ), }, ] : []), @@ -531,8 +535,8 @@ export function FlexibleModelSelector({ >
{selectedRoutingHint.pinnedAvailable - ? 'Preferred pinned model:' - : 'Pinned route status:'}{' '} + ? t('providerModelSelector.preferredPinnedModel') + : t('providerModelSelector.pinnedRouteStatus')}{' '} {selectedRoutingHint.pinnedAvailable ? selectedRoutingHint.recommendedModelId @@ -544,7 +548,7 @@ export function FlexibleModelSelector({ ) : null} {value && !selectedRoutingHint && normalizeModelValue(value, routing) !== value ? (
- Pinned model is not currently advertised by the proxy: {value} + {t('providerModelSelector.pinnedModelNotAdvertised', { model: value })}
) : null}
diff --git a/ui/src/components/cliproxy/routing-guidance-card.tsx b/ui/src/components/cliproxy/routing-guidance-card.tsx index 73c85590..bffc9230 100644 --- a/ui/src/components/cliproxy/routing-guidance-card.tsx +++ b/ui/src/components/cliproxy/routing-guidance-card.tsx @@ -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(currentStrategy); const [detailsOpen, setDetailsOpen] = useState(false); @@ -114,7 +116,7 @@ export function RoutingGuidanceCard({
-
Routing strategy
+
{t('routingGuidance.routingStrategy')}
{currentStrategy} {state ? {sourceLabel} : null} {state ? {state.target} : null} @@ -164,6 +166,7 @@ export function RoutingGuidanceCard({ ) : ( )} + {/* TODO i18n: missing key for detail toggle */} {detailToggleLabel}
- Round robin spreads usage. + {t('routingGuidance.roundRobin')} - Fill first keeps backup accounts cold until they are needed. + {t('routingGuidance.fillFirst')}
{error ? ( diff --git a/ui/src/components/compatible-cli/codex-control-center-tab.tsx b/ui/src/components/compatible-cli/codex-control-center-tab.tsx index 727a6e8d..44d6d1a4 100644 --- a/ui/src/components/compatible-cli/codex-control-center-tab.tsx +++ b/ui/src/components/compatible-cli/codex-control-center-tab.tsx @@ -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 (
@@ -65,7 +68,7 @@ export function CodexControlCenterTab({

- Structured controls boundary + {t('codex.controlCenter')}

@@ -74,6 +77,7 @@ export function CodexControlCenterTab({
  • + {/* TODO i18n: missing key codex.writesUserLayer */} Writes exclusively to user-layer{' '} config.toml @@ -83,6 +87,7 @@ export function CodexControlCenterTab({
  • + {/* TODO i18n: missing key codex.noRepoTrustReflection */} Does not reflect repo trust layers or CLI overrides
  • @@ -95,9 +100,11 @@ export function CodexControlCenterTab({

    + {/* TODO i18n: missing key codex.formattingNote */} Formatting Note

    + {/* TODO i18n: missing key codex.formattingNoteDesc */} Saves normalize TOML formatting and strip comments. Switch to the raw editor if exact layout matters.

    @@ -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.' + ) } />
    diff --git a/ui/src/components/compatible-cli/codex-docs-tab.tsx b/ui/src/components/compatible-cli/codex-docs-tab.tsx index 496390c5..8c118e9f 100644 --- a/ui/src/components/compatible-cli/codex-docs-tab.tsx +++ b/ui/src/components/compatible-cli/codex-docs-tab.tsx @@ -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) { + {/* TODO i18n: missing key codex.ccsBridgeRecipe */} CCS bridge recipe

    + {/* TODO i18n: missing key codex.builtInLabel */} Built-in: Use ccsxp for the CCS provider shortcut.

    + {/* TODO i18n: missing key codex.nativeLabelRecipe */} Native: Configure the recipe below to use CLIProxy directly with{' '} codex.

    @@ -121,12 +127,15 @@ export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) {
    1. + {/* TODO i18n: missing key codex.saveCliproxyProvider */} Save the cliproxy provider in your user config.
    2. + {/* TODO i18n: missing key codex.setTopLevelModelProvider */} Set top-level model_provider to cliproxy.
    3. + {/* TODO i18n: missing key codex.exportCliproxyApiKey */} Export CLIPROXY_API_KEY before launching native Codex.
    @@ -137,6 +146,7 @@ export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) { + {/* TODO i18n: missing key codex.upstreamNotes */} Upstream notes @@ -150,7 +160,9 @@ export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) { )}
    -

    Codex docs

    +

    + {t('codex.codexDocs')} +

    {docsLinks.map((link) => (

    + {/* TODO i18n: missing key codex.providerBridgeReference */} Provider / bridge reference

    @@ -204,12 +217,14 @@ export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) { <>

    + {/* TODO i18n: missing key codex.providerValues */} Provider values: {docsReference.providerValues.join(', ')}

    )} {docsReference.settingsHierarchy.length > 0 && (

    + {/* TODO i18n: missing key codex.settingsHierarchy */} Settings hierarchy: {docsReference.settingsHierarchy.join(' -> ')}

    )} diff --git a/ui/src/components/compatible-cli/codex-features-card.tsx b/ui/src/components/compatible-cli/codex-features-card.tsx index 1d7a95c1..386ff6f9 100644 --- a/ui/src/components/compatible-cli/codex-features-card.tsx +++ b/ui/src/components/compatible-cli/codex-features-card.tsx @@ -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 ( } + // 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')} ) : null}

    + {/* TODO i18n: missing key codex.configOnlyFlags */} Existing config-only flags

    + {/* 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.

    @@ -92,17 +99,19 @@ export function CodexFeaturesCard({

    {name}

    + {/* TODO i18n: missing key codex.existing */} existing

    + {/* 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."}

    {current === null ? ( - Raw only + {t('codex.rawOnly')} ) : (
    (initialDraft); return ( @@ -92,8 +94,8 @@ function McpServerEditor({ - stdio - streamable-http + {t('codex.stdio')} + {t('codex.streamableHttp')} {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({
    @@ -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 ( } + // TODO i18n: missing key codex.mcpServersDesc description="Manage the safe MCP transport fields. Keep auth headers and bearer tokens in raw TOML." disabledReason={disabledReason} > 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({ - responses + {t('codex.responses')}

    + {/* TODO i18n: missing key codex.nativeCodexCliproxyHint */} If you want plain native codex to default to CLIProxy, save a provider named{' '} cliproxy with CLIPROXY_API_KEY here, then pick{' '} - cliproxy in the Default provider control above. + cliproxy in the {t('codex.defaultProvider')} control above.

    ); @@ -191,6 +203,7 @@ export function CodexModelProvidersCard({ onSave, onDelete, }: CodexModelProvidersCardProps) { + const { t } = useTranslation(); const [selectedName, setSelectedName] = useState('new'); const selectedEntry = useMemo( () => entries.find((entry) => entry.name === selectedName) ?? null, @@ -201,18 +214,21 @@ export function CodexModelProvidersCard({ return ( } + // 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} > + {/* TODO i18n: missing key codex.selectProfile */} - Create new profile + {t('codex.createNewProfile')} {entries.map((entry) => ( {entry.name} + {/* TODO i18n: missing key codex.activeSuffix */} {entry.name === activeProfile ? ' (active)' : ''} ))} diff --git a/ui/src/components/compatible-cli/codex-project-trust-card.tsx b/ui/src/components/compatible-cli/codex-project-trust-card.tsx index 7d265080..f712d180 100644 --- a/ui/src/components/compatible-cli/codex-project-trust-card.tsx +++ b/ui/src/components/compatible-cli/codex-project-trust-card.tsx @@ -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({ - trusted - untrusted + {t('codex.trusted')} + {t('codex.untrusted')}
    @@ -70,15 +73,20 @@ export function CodexProjectTrustCard({ saving = false, onSave, }: CodexProjectTrustCardProps) { + const { t } = useTranslation(); + return ( } + // TODO i18n: missing key codex.projectTrustDesc description="Trust current workspaces or remove stale trust entries without opening raw TOML." disabledReason={disabledReason} >

    + {/* TODO i18n: missing key codex.trustPathsHint */} Paths must be absolute or start with ~/. Relative paths are rejected so CCS does not trust the wrong folder.

    @@ -96,12 +104,13 @@ export function CodexProjectTrustCard({ onClick={() => onSave(workspacePath, 'trusted')} disabled={disabled || saving} > + {/* TODO i18n: missing key codex.trustCurrentWorkspace */} Trust current workspace
    {entries.length === 0 ? ( -

    No explicit project trust entries saved.

    +

    {t('codex.noProjectTrustEntries')}

    ) : ( entries.map((entry) => (
    + {/* TODO i18n: missing key codex.toggle */} Toggle
    @@ -521,9 +550,11 @@ export function CodexTopLevelControlsCard({ }: CodexTopLevelControlsCardProps) { return ( } + // 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} > diff --git a/ui/src/components/compatible-cli/droid-byok-reasoning-controls-card.tsx b/ui/src/components/compatible-cli/droid-byok-reasoning-controls-card.tsx index c65b9390..82768200 100644 --- a/ui/src/components/compatible-cli/droid-byok-reasoning-controls-card.tsx +++ b/ui/src/components/compatible-cli/droid-byok-reasoning-controls-card.tsx @@ -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 ( - BYOK Reasoning / Thinking + {t('droidSettings.reasoningControls')} customModels @@ -55,6 +58,7 @@ export function DroidByokReasoningControlsCard({ {models.length === 0 ? (

    + {/* TODO i18n: missing key droidSettings.noByokModels */} No BYOK custom models found in settings.json (`customModels` or `custom_models`).

    ) : ( @@ -75,7 +79,9 @@ export function DroidByokReasoningControlsCard({
    -

    Reasoning Effort

    +

    + {t('codex.reasoningEffortCapitalized')} +

    - Quick Settings + {t('droidSettings.quickControls')} settings.json @@ -140,6 +143,7 @@ export function DroidSettingsQuickControlsCard({
    {enumFieldConfig.map((field) => (
    + {/* TODO i18n: missing keys for droidSettings enum/boolean/number field labels */}

    {field.label}

    (
    + {/* TODO i18n: missing keys for droidSettings boolean field labels */}

    {field.label}

    onNewEnvKeyChange(e.target.value.toUpperCase())} className="font-mono text-sm h-8 w-2/5" onKeyDown={(e) => e.key === 'Enter' && newEnvKey.trim() && onAddEnvVar()} /> onNewEnvValueChange(e.target.value)} className="font-mono text-sm h-8 flex-1" diff --git a/ui/src/components/profiles/editor/header-section.tsx b/ui/src/components/profiles/editor/header-section.tsx index 0ed87e02..098391a4 100644 --- a/ui/src/components/profiles/editor/header-section.tsx +++ b/ui/src/components/profiles/editor/header-section.tsx @@ -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({
    {data && (

    - Last modified: {new Date(data.mtime).toLocaleString()} + {t('profileEditor.lastModified')}: {new Date(data.mtime).toLocaleString()}

    )}
    - Default target: + {t('profileEditor.defaultTarget')}: {isTargetSaving && } @@ -104,12 +106,12 @@ export function HeaderSection({ {isSaving ? ( <> - Saving... + {t('profileEditor.saving')} ) : ( <> - Save + {t('settingsAuth.save')} )} diff --git a/ui/src/components/profiles/editor/image-analysis-status-section.tsx b/ui/src/components/profiles/editor/image-analysis-status-section.tsx index c1100487..f5fca3c2 100644 --- a/ui/src/components/profiles/editor/image-analysis-status-section.tsx +++ b/ui/src/components/profiles/editor/image-analysis-status-section.tsx @@ -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 = { - 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, + 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, + 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, + 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 (
    @@ -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({
    -

    Image

    +

    {t('imageAnalysisStatus.sectionTitle')}

    {headerBadge.label}

    - {getPreviewLabel(source, previewState)} · {getHeaderLabel(effectiveStatus, target)} + {getPreviewLabel(t, source, previewState)} ·{' '} + {getHeaderLabel(t, effectiveStatus, target)}

    @@ -180,7 +207,7 @@ export function ImageAnalysisStatusSection({ @@ -190,7 +217,9 @@ export function ImageAnalysisStatusSection({
    -
    Use native image reading
    +
    + {t('imageAnalysisStatus.useNativeImageReading')} +
    {capabilityLabel && ( {capabilityLabel} @@ -198,7 +227,7 @@ export function ImageAnalysisStatusSection({ )}

    - {getToggleSummary(effectiveStatus, target)} + {getToggleSummary(t, effectiveStatus, target)}

    @@ -206,7 +235,7 @@ export function ImageAnalysisStatusSection({ checked={nativeReadChecked} onCheckedChange={onToggleNativeRead} disabled={!onToggleNativeRead} - aria-label="Use native image reading" + aria-label={t('imageAnalysisStatus.useNativeImageReading')} />
    diff --git a/ui/src/components/profiles/editor/index.tsx b/ui/src/components/profiles/editor/index.tsx index a7daa66a..12006fe8 100644 --- a/ui/src/components/profiles/editor/index.tsx +++ b/ui/src/components/profiles/editor/index.tsx @@ -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>({}); const [conflictDialog, setConflictDialog] = useState(false); const [rawJsonEdits, setRawJsonEdits] = useState(null); @@ -299,15 +301,15 @@ export function ProfileEditor({ {isLoading ? (
    - Loading settings... + {t('settingsDialog.loadingSettings')}
    ) : isError ? (
    -

    Failed to load settings.

    +

    {t('settingsPage.failedLoad')}

    @@ -332,7 +334,7 @@ export function ProfileEditor({
    - Raw Configuration (JSON) + {t('rawEditorSection.rawConfig')}
    handleConflictResolve(true)} onCancel={() => handleConflictResolve(false)} diff --git a/ui/src/components/profiles/editor/raw-editor-section.tsx b/ui/src/components/profiles/editor/raw-editor-section.tsx index 924e187b..79cf6ea1 100644 --- a/ui/src/components/profiles/editor/raw-editor-section.tsx +++ b/ui/src/components/profiles/editor/raw-editor-section.tsx @@ -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={
    - Loading editor... + + {t('profileEditorSections.loadingEditor')} +
    } > @@ -59,7 +63,7 @@ export function RawEditorSection({ {!isRawJsonValid && rawJsonEdits !== null && (
    - Invalid JSON syntax + {t('profileEditor.invalidJson')}
    )} {isRawJsonValid && hasMissingFields && ( @@ -67,13 +71,13 @@ export function RawEditorSection({
    - Missing required fields: + {t('profileEditor.missingFields')}: {' '} {missingRequiredFields.join(', ')}

    - These fields will use default values at runtime. + {t('profileEditor.missingFieldsHint')}

    diff --git a/ui/src/components/profiles/editor/use-profile-editor.ts b/ui/src/components/profiles/editor/use-profile-editor.ts index 07202c58..462eaa5e 100644 --- a/ui/src/components/profiles/editor/use-profile-editor.ts +++ b/ui/src/components/profiles/editor/use-profile-editor.ts @@ -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) => { diff --git a/ui/src/components/profiles/openrouter-badge.tsx b/ui/src/components/profiles/openrouter-badge.tsx index 6f11e3b8..02ad445d 100644 --- a/ui/src/components/profiles/openrouter-badge.tsx +++ b/ui/src/components/profiles/openrouter-badge.tsx @@ -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} -

    Access 349+ models via OpenRouter

    +

    {t('openrouterBadge.integration')}

    ); diff --git a/ui/src/components/profiles/openrouter-banner.tsx b/ui/src/components/profiles/openrouter-banner.tsx index ac213f5b..6ecdd029 100644 --- a/ui/src/components/profiles/openrouter-banner.tsx +++ b/ui/src/components/profiles/openrouter-banner.tsx @@ -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) {
    -

    NEW: OpenRouter Integration

    +

    + {t('openrouterBadge.new')}: {t('openrouterBadge.integration')} +

    - Browse {isLoading ? '300+' : `${modelCount}+`} models from OpenAI, Anthropic, Google, - Meta and more. + {t('openrouterBanner.accessModels', { + count: isLoading ? 300 : modelCount, + })}

    @@ -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')} )} (null); @@ -105,7 +107,7 @@ export function OpenRouterModelPicker({ setSearch(e.target.value)} - placeholder={placeholder} + placeholder={placeholder ?? t('openrouterModelPicker.searchModels')} className="pl-9" />
    @@ -180,7 +182,7 @@ export function OpenRouterModelPicker({
    - Newest Models + {t('openrouterModelPicker.newestModels')}
    {newestModels.map((model) => ( diff --git a/ui/src/components/profiles/openrouter-promo-card.tsx b/ui/src/components/profiles/openrouter-promo-card.tsx index ef3119d3..461a5779 100644 --- a/ui/src/components/profiles/openrouter-promo-card.tsx +++ b/ui/src/components/profiles/openrouter-promo-card.tsx @@ -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 (
    @@ -21,9 +23,11 @@ export function OpenRouterPromoCard({ onCreateClick }: OpenRouterPromoCardProps)
    -

    OpenRouter

    +

    + {t('openrouterPromoCard.title')} +

    - {isLoading ? '300+' : `${modelCount}+`} models available + {t('openrouterPromoCard.description')}

    diff --git a/ui/src/components/profiles/profile-card.tsx b/ui/src/components/profiles/profile-card.tsx index ff18790c..50832636 100644 --- a/ui/src/components/profiles/profile-card.tsx +++ b/ui/src/components/profiles/profile-card.tsx @@ -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 OpenRouter - OpenRouter profile + {t('profileCard.openRouter')} )} {profile.isActive && ( diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx index 1ae37568..1cb81e9b 100644 --- a/ui/src/components/profiles/profile-create-dialog.tsx +++ b/ui/src/components/profiles/profile-create-dialog.tsx @@ -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({ - Create API Profile + {t('profileCreateDialog.createProfile')} - - Choose a provider preset or configure a custom API endpoint. - + {t('profileCreateDialog.chooseProviderHint')}
    {LOCAL_RUNTIME_PRESETS.map((preset) => ( @@ -386,13 +384,13 @@ export function ProfileCreateDialog({
    - Basic Information + {t('profileCreateDialog.basicInformation')} {hasBasicErrors && ( )} - Model Configuration + {t('profileCreateDialog.modelConfiguration')} {hasModelErrors && ( )} @@ -405,19 +403,19 @@ export function ProfileCreateDialog({ {/* Profile Name */}
    {errors.name ? (

    {errors.name.message}

    ) : (

    - Used in CLI:{' '} + {t('profileCreateDialog.usedInCli')}{' '} ccs my-api "prompt"

    )} @@ -425,11 +423,11 @@ export function ProfileCreateDialog({ {/* Base URL - always editable, pre-filled from preset */}
    - + {errors.baseUrl ? (

    {errors.baseUrl.message}

    @@ -441,12 +439,12 @@ export function ProfileCreateDialog({ ) : currentPreset ? (

    {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 })}

    ) : (

    - The endpoint that accepts OpenAI-compatible and Anthropic requests + {t('profileCreateDialog.endpointHint')}

    )}
    @@ -454,12 +452,14 @@ export function ProfileCreateDialog({ {/* API Key - optional for presets that don't require it */}
    @@ -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({

    {errors.apiKey.message}

    ) : currentPreset?.requiresApiKey === false ? (

    - Only needed if your local endpoint has authentication enabled + {t('profileCreateDialog.apiKeyOptionalHint')}

    ) : ( currentPreset?.apiKeyHint && ( @@ -499,7 +500,7 @@ export function ProfileCreateDialog({
    - +

    @@ -553,10 +554,9 @@ export function ProfileCreateDialog({

    -

    Model Mapping

    +

    {t('profileCreateDialog.modelMapping')}

    - Map Claude Code tiers (Opus/Sonnet/Haiku) to models supported by your - provider. + {t('profileCreateDialog.modelMappingDesc')}

    @@ -564,11 +564,11 @@ export function ProfileCreateDialog({ {/* OpenRouter Model Picker */} {isOpenRouter && (
    - + 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 ? (

    {modelSearch - ? `No models found for "${modelSearch}"` - : 'Loading models...'} + ? t('profileCreateDialog.noModelsFound', { query: modelSearch }) + : t('profileCreateDialog.loadingModels')}

    ) : (
    {!modelSearch && (
    - Newest Models + {t('openrouterModelPicker.newestModels')}
    )} {filteredModels.map((model) => ( @@ -609,7 +609,7 @@ export function ProfileCreateDialog({
    -
    Add new account
    -
    Authenticate with a different account
    +
    + {t('setupWizard.accountStep.addNewAccount')} +
    +
    + {t('setupWizard.accountStep.addNewAccountDesc')} +
    diff --git a/ui/src/components/setup/wizard/steps/auth-step.tsx b/ui/src/components/setup/wizard/steps/auth-step.tsx index 6b200c83..de2b17f4 100644 --- a/ui/src/components/setup/wizard/steps/auth-step.tsx +++ b/ui/src/components/setup/wizard/steps/auth-step.tsx @@ -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 */}

    - 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, + })}

    {isPending && ( -

    - Complete the OAuth flow in your browser... -

    +

    {t('setupWizard.authStep.completeOAuth')}

    )}
    @@ -60,7 +61,9 @@ export function AuthStep({
    - Or use terminal + + {t('setupWizard.authStep.orUseTerminal')} +
    @@ -69,7 +72,7 @@ export function AuthStep({
    - Run this command in your terminal: + {t('setupWizard.authStep.runCommandHint')}
    @@ -85,11 +88,13 @@ export function AuthStep({
    diff --git a/ui/src/components/setup/wizard/steps/success-step.tsx b/ui/src/components/setup/wizard/steps/success-step.tsx index 56bf23e9..34edc541 100644 --- a/ui/src/components/setup/wizard/steps/success-step.tsx +++ b/ui/src/components/setup/wizard/steps/success-step.tsx @@ -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 (
    @@ -16,19 +18,19 @@ export function SuccessStep({ variantName, onClose }: SuccessStepProps) {
    -
    Variant Created!
    -
    Your custom variant is ready to use
    +
    {t('setupWizard.successStep.title')}
    +
    {t('setupWizard.successStep.subtitle')}
    -
    Usage:
    +
    {t('setupWizard.successStep.usage')}
    ccs {variantName} "your prompt here"
    ); diff --git a/ui/src/components/setup/wizard/steps/variant-step.tsx b/ui/src/components/setup/wizard/steps/variant-step.tsx index b3723cc1..311fe496 100644 --- a/ui/src/components/setup/wizard/steps/variant-step.tsx +++ b/ui/src/components/setup/wizard/steps/variant-step.tsx @@ -127,9 +127,7 @@ export function VariantStep({ placeholder={t('setupVariant.modelPlaceholder')} /> {deniedCustomModel && ( -

    - Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated. -

    +

    {t('providerEditor.agyDenylist')}

    )}
    ); } diff --git a/ui/src/components/shared/claudekit-badge.tsx b/ui/src/components/shared/claudekit-badge.tsx index a8c4308b..87254ccd 100644 --- a/ui/src/components/shared/claudekit-badge.tsx +++ b/ui/src/components/shared/claudekit-badge.tsx @@ -6,10 +6,13 @@ */ import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; const CLAUDEKIT_URL = 'https://claudekit.cc?ref=HMNKXOHN'; export function ClaudeKitBadge() { + const { t } = useTranslation(); + return (
    - ClaudeKit + {t('claudekitBadge.alt')} - Powered by + {t('claudekitBadge.poweredBy')} - ClaudeKit + {t('claudekitBadge.claudekit')} diff --git a/ui/src/components/shared/code-editor.tsx b/ui/src/components/shared/code-editor.tsx index c98857a4..311e0371 100644 --- a/ui/src/components/shared/code-editor.tsx +++ b/ui/src/components/shared/code-editor.tsx @@ -17,6 +17,7 @@ import { cn } from '@/lib/utils'; import { isSensitiveKey } from '@/lib/sensitive-keys'; import { AlertCircle, CheckCircle2, Eye, EyeOff } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { useTranslation } from 'react-i18next'; interface CodeEditorProps { value: string; @@ -99,6 +100,7 @@ export function CodeEditor({ heightMode = 'content', }: CodeEditorProps) { const { isDark } = useTheme(); + const { t } = useTranslation(); const [isFocused, setIsFocused] = useState(false); const [isMasked, setIsMasked] = useState(true); const isFillParent = heightMode === 'fill-parent'; @@ -238,7 +240,7 @@ export function CodeEditor({ size="icon" className="h-6 w-6 bg-background/50 hover:bg-background border shadow-sm rounded-full" onClick={() => setIsMasked(!isMasked)} - title={isMasked ? 'Reveal sensitive values' : 'Mask sensitive values'} + title={isMasked ? t('codeEditor.revealSensitive') : t('codeEditor.maskSensitive')} > {isMasked ? : } @@ -250,7 +252,7 @@ export function CodeEditor({ {validation.valid ? ( - Valid {language.toUpperCase()} + {t('codeEditor.valid', { language: language.toUpperCase() })} ) : ( @@ -259,7 +261,9 @@ export function CodeEditor({ {validation.line && ` (line ${validation.line})`} )} - {readonly && (Read-only)} + {readonly && ( + {t('codeEditor.readOnly')} + )}
    ); diff --git a/ui/src/components/shared/command-builder.tsx b/ui/src/components/shared/command-builder.tsx index 8e33dae3..fc172b1e 100644 --- a/ui/src/components/shared/command-builder.tsx +++ b/ui/src/components/shared/command-builder.tsx @@ -1,10 +1,11 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { ScrollArea } from '@/components/ui/scroll-area'; import { CopyIcon, PlayIcon, TerminalIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; interface Command { id: string; @@ -13,62 +14,64 @@ interface Command { category: string; } -const commonCommands: Command[] = [ - { - id: '1', - command: 'ccs config', - description: 'Open configuration interface', - category: 'Config', - }, - { - id: '2', - command: 'ccs profile create --name my-profile', - description: 'Create a new profile', - category: 'Profile', - }, - { - id: '3', - command: 'ccs profile switch --name my-profile', - description: 'Switch to a profile', - category: 'Profile', - }, - { - id: '4', - command: 'ccs doctor', - description: 'Check system health', - category: 'Diagnostics', - }, - { - id: '5', - command: 'ccs cliproxy list', - description: 'List available CLIProxy providers', - category: 'CLIProxy', - }, - { - id: '6', - command: 'ccs cliproxy add --provider gemini --token YOUR_TOKEN', - description: 'Add CLIProxy provider', - category: 'CLIProxy', - }, -]; - export function CommandBuilder() { + const { t } = useTranslation(); const [command, setCommand] = useState(''); - const [filteredCommands, setFilteredCommands] = useState(commonCommands); - const handleCommandChange = (value: string) => { - setCommand(value); - const filtered = commonCommands.filter( + const commonCommands = useMemo( + () => [ + { + id: '1', + command: 'ccs config', + description: t('commandBuilder.cmdConfig'), + category: 'Config', + }, + { + id: '2', + command: 'ccs profile create --name my-profile', + description: t('commandBuilder.cmdCreateProfile'), + category: 'Profile', + }, + { + id: '3', + command: 'ccs profile switch --name my-profile', + description: t('commandBuilder.cmdSwitchProfile'), + category: 'Profile', + }, + { + id: '4', + command: 'ccs doctor', + description: t('commandBuilder.cmdDoctor'), + category: 'Diagnostics', + }, + { + id: '5', + command: 'ccs cliproxy list', + description: t('commandBuilder.cmdListProviders'), + category: 'CLIProxy', + }, + { + id: '6', + command: 'ccs cliproxy add --provider gemini --token YOUR_TOKEN', + description: t('commandBuilder.cmdAddProvider'), + category: 'CLIProxy', + }, + ], + [t] + ); + + // Derive filtered commands from command input and translated commonCommands + const filteredCommands = useMemo(() => { + if (!command) return commonCommands; + return commonCommands.filter( (cmd) => - cmd.command.toLowerCase().includes(value.toLowerCase()) || - cmd.description.toLowerCase().includes(value.toLowerCase()) + cmd.command.toLowerCase().includes(command.toLowerCase()) || + cmd.description.toLowerCase().includes(command.toLowerCase()) ); - setFilteredCommands(filtered); - }; + }, [commonCommands, command]); const handleCommandSelect = (cmd: string) => { setCommand(cmd); - setFilteredCommands(commonCommands); }; const handleCopy = () => { @@ -86,25 +89,25 @@ export function CommandBuilder() { - Command Builder + {t('commandBuilder.title')}
    handleCommandChange(e.target.value)} + onChange={(e) => setCommand(e.target.value)} className="font-mono" />
    diff --git a/ui/src/components/shared/confirm-dialog.tsx b/ui/src/components/shared/confirm-dialog.tsx index c680a278..a3831fba 100644 --- a/ui/src/components/shared/confirm-dialog.tsx +++ b/ui/src/components/shared/confirm-dialog.tsx @@ -8,6 +8,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; +import { useTranslation } from 'react-i18next'; interface ConfirmDialogProps { open: boolean; @@ -25,9 +26,11 @@ export function ConfirmDialog({ onCancel, title, description, - confirmText = 'Confirm', + confirmText, variant = 'default', }: ConfirmDialogProps) { + const { t } = useTranslation(); + return ( !isOpen && onCancel()}> @@ -36,12 +39,12 @@ export function ConfirmDialog({ {description} - Cancel + {t('confirmDialog.cancel')} - {confirmText} + {confirmText ?? t('confirmDialog.confirm')} diff --git a/ui/src/components/shared/connection-indicator.tsx b/ui/src/components/shared/connection-indicator.tsx index 3ce36825..b24de6a8 100644 --- a/ui/src/components/shared/connection-indicator.tsx +++ b/ui/src/components/shared/connection-indicator.tsx @@ -6,22 +6,31 @@ import { Wifi, WifiOff, RefreshCw } from 'lucide-react'; import { useWebSocket } from '@/hooks/use-websocket'; +import { useTranslation } from 'react-i18next'; export function ConnectionIndicator() { const { status, isReconnecting } = useWebSocket(); + const { t } = useTranslation(); const statusConfig = { - connected: { icon: Wifi, color: 'text-green-600', label: 'Connected', animate: false }, + connected: { + icon: Wifi, + color: 'text-green-600', + label: t('connectionIndicator.connected'), + animate: false, + }, connecting: { icon: RefreshCw, color: 'text-yellow-500', - label: 'Connecting...', + label: t('connectionIndicator.connecting'), animate: true, }, disconnected: { icon: isReconnecting ? RefreshCw : WifiOff, color: isReconnecting ? 'text-amber-500' : 'text-red-500', - label: isReconnecting ? 'Reconnecting...' : 'Disconnected', + label: isReconnecting + ? t('connectionIndicator.reconnecting') + : t('connectionIndicator.disconnected'), animate: isReconnecting, }, }; diff --git a/ui/src/components/shared/device-code-dialog.tsx b/ui/src/components/shared/device-code-dialog.tsx index 9183be6a..6389065e 100644 --- a/ui/src/components/shared/device-code-dialog.tsx +++ b/ui/src/components/shared/device-code-dialog.tsx @@ -88,8 +88,8 @@ export function DeviceCodeDialog({ const instructions = getDeviceCodeProviderInstruction(provider); const openActionLabel = providerDisplay === 'Unknown provider' - ? 'Open verification page' - : `Open ${providerDisplay.split(' ')[0]}`; + ? i18n.t('deviceCodeDialog.openVerificationPage') + : i18n.t('deviceCodeDialog.openProviderPage', { provider: providerDisplay.split(' ')[0] }); // Format remaining time const formatTime = (seconds: number): string => { @@ -106,16 +106,20 @@ export function DeviceCodeDialog({ - Authorize {providerDisplay} + {i18n.t('deviceCodeDialog.authorize', { provider: providerDisplay })} - Enter the code below at the authorization page. + {i18n.t('deviceCodeDialog.enterCodeAtPage')} {timeRemaining !== null && timeRemaining > 0 && ( - (Expires in {formatTime(timeRemaining)}) + {i18n.t('deviceCodeDialog.expiresIn', { time: formatTime(timeRemaining) })} + + )} + {isExpired && ( + + {i18n.t('deviceCodeDialog.codeExpired')} )} - {isExpired && (Code expired)} @@ -132,7 +136,11 @@ export function DeviceCodeDialog({ size="icon" className="absolute top-2 right-2" onClick={handleCopyCode} - aria-label={hasCopied ? 'Code copied' : 'Copy verification code'} + aria-label={ + hasCopied + ? i18n.t('deviceCodeDialog.codeCopiedAria') + : i18n.t('deviceCodeDialog.copyCodeAria') + } > {hasCopied ? ( @@ -157,12 +165,12 @@ export function DeviceCodeDialog({ {hasCopied ? ( <> - Copied! + {i18n.t('deviceCodeDialog.copied')} ) : ( <> - Copy Code + {i18n.t('deviceCodeDialog.copyCode')} )} @@ -171,7 +179,7 @@ export function DeviceCodeDialog({ {/* Waiting indicator */}
    - Waiting for authorization... + {i18n.t('deviceCodeDialog.waitingForAuth')}
    diff --git a/ui/src/components/shared/docs-link.tsx b/ui/src/components/shared/docs-link.tsx index 72e16014..727437f9 100644 --- a/ui/src/components/shared/docs-link.tsx +++ b/ui/src/components/shared/docs-link.tsx @@ -6,12 +6,15 @@ import { BookOpen } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { useTranslation } from 'react-i18next'; const DOCS_URL = 'https://docs.ccs.kaitran.ca'; export function DocsLink() { + const { t } = useTranslation(); + return ( - diff --git a/ui/src/components/shared/localhost-disclaimer.tsx b/ui/src/components/shared/localhost-disclaimer.tsx index c6efc7b4..fb5ca593 100644 --- a/ui/src/components/shared/localhost-disclaimer.tsx +++ b/ui/src/components/shared/localhost-disclaimer.tsx @@ -1,10 +1,12 @@ import { Shield, X } from 'lucide-react'; import { useState } from 'react'; import { useAuth } from '@/contexts/auth-context'; +import { useTranslation } from 'react-i18next'; export function LocalhostDisclaimer() { const [dismissed, setDismissed] = useState(false); const { authEnabled, authConfigured, isLocalAccess, loading } = useAuth(); + const { t } = useTranslation(); const isRemoteReadonly = !isLocalAccess && !authEnabled; @@ -21,30 +23,25 @@ export function LocalhostDisclaimer() { {authConfigured ? ( <> - Remote dashboard access is read-only because dashboard auth is currently disabled on the - host. Re-enable dashboard auth on the host to unlock remote changes. + {t('localhostDisclaimer.remoteReadonlyAuthDisabledLong')} - Remote dashboard is read-only until dashboard auth is re-enabled on the host. + {t('localhostDisclaimer.remoteReadonlyAuthDisabledShort')} ) : ( <> - Remote dashboard access is read-only until you run ccs config auth setup on the host. - - - Remote dashboard is read-only until host auth is configured. + {t('localhostDisclaimer.remoteReadonlySetupLong')} + {t('localhostDisclaimer.remoteReadonlySetupShort')} )} ) : ( <> - - This dashboard runs locally. All data stays on your machine. - - Local dashboard - data stays on your device. + {t('localhostDisclaimer.localLong')} + {t('localhostDisclaimer.localShort')} ); @@ -59,7 +56,7 @@ export function LocalhostDisclaimer() { diff --git a/ui/src/components/shared/privacy-toggle.tsx b/ui/src/components/shared/privacy-toggle.tsx index eb70362d..7a45ae0c 100644 --- a/ui/src/components/shared/privacy-toggle.tsx +++ b/ui/src/components/shared/privacy-toggle.tsx @@ -8,9 +8,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { usePrivacy } from '@/contexts/privacy-context'; import { Eye, EyeOff } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; export function PrivacyToggle() { const { privacyMode, togglePrivacyMode } = usePrivacy(); + const { t } = useTranslation(); return ( @@ -28,11 +30,7 @@ export function PrivacyToggle() { -

    - {privacyMode - ? 'Privacy mode ON - Click to show data' - : 'Privacy mode OFF - Click to hide data'} -

    +

    {privacyMode ? t('privacyToggle.modeOn') : t('privacyToggle.modeOff')}

    ); diff --git a/ui/src/components/shared/project-selection-dialog.tsx b/ui/src/components/shared/project-selection-dialog.tsx index b80b00b9..da46bb51 100644 --- a/ui/src/components/shared/project-selection-dialog.tsx +++ b/ui/src/components/shared/project-selection-dialog.tsx @@ -16,6 +16,7 @@ import { } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Loader2, FolderOpen, Check, Circle, CheckCircle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; interface GCloudProject { id: string; @@ -50,6 +51,7 @@ export function ProjectSelectionDialog({ const [selectedId, setSelectedId] = useState(defaultProjectId); const [isSubmitting, setIsSubmitting] = useState(false); const [countdown, setCountdown] = useState(timeoutSeconds); + const { t } = useTranslation(); // Countdown timer for auto-selection useEffect(() => { @@ -115,13 +117,13 @@ export function ProjectSelectionDialog({ - Select Google Cloud Project + {t('projectSelectionDialog.title')} - Choose which project to use for {providerDisplay} authentication. + {t('projectSelectionDialog.description', { provider: providerDisplay })} {countdown > 0 && ( - (Auto-selecting default in {countdown}s) + {t('projectSelectionDialog.autoSelectCountdown', { count: countdown })} )} @@ -149,7 +151,9 @@ export function ProjectSelectionDialog({
    {project.id}
    {project.id === defaultProjectId && ( - Default + + {t('projectSelectionDialog.default')} + )}
    ))} @@ -169,9 +173,9 @@ export function ProjectSelectionDialog({ )}
    -
    All Projects
    +
    {t('projectSelectionDialog.allProjects')}
    - Onboard all {projects.length} listed projects + {t('projectSelectionDialog.allProjectsDescription', { count: projects.length })}
    @@ -180,18 +184,18 @@ export function ProjectSelectionDialog({
    diff --git a/ui/src/components/shared/quick-commands.tsx b/ui/src/components/shared/quick-commands.tsx index 6ab4d6f8..a8ee6e62 100644 --- a/ui/src/components/shared/quick-commands.tsx +++ b/ui/src/components/shared/quick-commands.tsx @@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button'; import { Copy, Check, Terminal } from 'lucide-react'; import { useState } from 'react'; import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; interface CommandSnippet { label: string; @@ -39,6 +40,7 @@ interface QuickCommandsProps { export function QuickCommands({ snippets = defaultSnippets }: QuickCommandsProps) { const [copiedIndex, setCopiedIndex] = useState(null); + const { t } = useTranslation(); const copyToClipboard = async (text: string, index: number) => { await navigator.clipboard.writeText(text); @@ -51,7 +53,7 @@ export function QuickCommands({ snippets = defaultSnippets }: QuickCommandsProps - Quick Commands + {t('quickCommands.title')} diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index b653dde7..0dba6fb7 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -22,6 +22,7 @@ import { type UnifiedQuotaResult, } from '@/lib/utils'; import type { ProviderEntitlementEvidence } from '@/lib/api-client'; +import { useTranslation } from 'react-i18next'; interface QuotaTooltipContentProps { quota: UnifiedQuotaResult | null | undefined; @@ -57,22 +58,26 @@ function formatAbsoluteResetTime(resetTime: string | null): string | null { } } -function getClaudeWindowDisplayLabel(rateLimitType: string, fallback: string): string { +function getClaudeWindowDisplayLabel( + rateLimitType: string, + fallback: string, + t: (key: string) => string +): string { switch (rateLimitType) { case 'five_hour': - return '5h usage limit'; + return t('quotaTooltip.fiveHourLimit'); case 'seven_day': - return 'Weekly usage limit'; + return t('quotaTooltip.weeklyLimit'); case 'seven_day_opus': - return 'Weekly usage (Opus)'; + return t('quotaTooltip.weeklyOpus'); case 'seven_day_sonnet': - return 'Weekly usage (Sonnet)'; + return t('quotaTooltip.weeklySonnet'); case 'seven_day_oauth_apps': - return 'Weekly usage (OAuth apps)'; + return t('quotaTooltip.weeklyOAuthApps'); case 'seven_day_cowork': - return 'Weekly usage (Cowork)'; + return t('quotaTooltip.weeklyCowork'); case 'overage': - return 'Extra usage'; + return t('quotaTooltip.extraUsage'); default: return fallback; } @@ -118,38 +123,42 @@ function formatGeminiBucketModels(modelIds: string[] | undefined): string | null function formatGeminiRemainingAmount( remainingAmount: number | null | undefined, - tokenType: string | null | undefined + tokenType: string | null | undefined, + t: (key: string, options?: Record) => string ): string | null { if (remainingAmount === null || remainingAmount === undefined) return null; const formattedAmount = remainingAmount.toLocaleString(); switch (tokenType?.trim().toLowerCase()) { case 'requests': - return `${formattedAmount} requests remaining`; + return t('quotaTooltip.requestsRemaining', { count: formattedAmount }); case 'input': - return `${formattedAmount} input tokens remaining`; + return t('quotaTooltip.inputTokensRemaining', { count: formattedAmount }); case 'output': - return `${formattedAmount} output tokens remaining`; + return t('quotaTooltip.outputTokensRemaining', { count: formattedAmount }); default: - return `${formattedAmount} remaining`; + return t('quotaTooltip.amountRemaining', { count: formattedAmount }); } } -function renderEntitlementRows(entitlement: ProviderEntitlementEvidence | undefined) { +function renderEntitlementRows( + entitlement: ProviderEntitlementEvidence | undefined, + t: (key: string) => string +) { if (!entitlement) return null; const rows: Array<{ label: string; value: string | null }> = []; if (entitlement.rawTierLabel) { - rows.push({ label: 'Tier', value: entitlement.rawTierLabel }); + rows.push({ label: t('quotaTooltip.tier'), value: entitlement.rawTierLabel }); } else if (entitlement.normalizedTier !== 'unknown') { - rows.push({ label: 'Tier', value: entitlement.normalizedTier }); + rows.push({ label: t('quotaTooltip.tier'), value: entitlement.normalizedTier }); } if (entitlement.rawTierId) { - rows.push({ label: 'Tier ID', value: entitlement.rawTierId }); + rows.push({ label: t('quotaTooltip.tierId'), value: entitlement.rawTierId }); } if (entitlement.accessState !== 'entitled' || entitlement.capacityState !== 'available') { rows.push({ - label: 'State', + label: t('quotaTooltip.state'), value: `${entitlement.accessState.replaceAll('_', ' ')} / ${entitlement.capacityState.replaceAll('_', ' ')}`, }); } @@ -169,8 +178,10 @@ function renderEntitlementRows(entitlement: ProviderEntitlementEvidence | undefi * Uses type guards for proper TypeScript narrowing */ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentProps) { + const { t } = useTranslation(); + if (!quota) { - return

    Loading quota...

    ; + return

    {t('quotaTooltip.loadingQuota')}

    ; } if (!quota.success) { @@ -186,7 +197,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro

    - {failureInfo?.label || quota.error || 'Failed to load quota'} + {failureInfo?.label || quota.error || t('quotaTooltip.failedLoadQuota')}

    {failureInfo?.summary || quota.error} @@ -219,8 +230,8 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro return (

    - {renderEntitlementRows(quota.entitlement)} -

    Model Quotas:

    + {renderEntitlementRows(quota.entitlement, t)} +

    {t('quotaTooltip.modelQuotas')}

    {tierOrder.map((tier, idx) => { const models = groups.get(tier); if (!models || models.length === 0) return null; @@ -263,8 +274,12 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro return (
    -

    Rate Limits:

    - {quota.planType &&

    Plan: {quota.planType}

    } +

    {t('quotaTooltip.rateLimits')}

    + {quota.planType && ( +

    + {t('quotaTooltip.plan', { plan: quota.planType })} +

    + )} {orderedWindows.map((w, index) => (
    -

    Rate Limits:

    +

    {t('quotaTooltip.rateLimits')}

    {orderedWindows.map((window, index) => (
    - {getClaudeWindowDisplayLabel(window.rateLimitType, window.label)} + {getClaudeWindowDisplayLabel(window.rateLimitType, window.label, t)} {window.remainingPercent}%
    @@ -369,24 +384,24 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro return (
    - {renderEntitlementRows(quota.entitlement)} + {renderEntitlementRows(quota.entitlement, t)} {!hasEntitlementTier && quota.tierLabel && (
    - Tier + {t('quotaTooltip.tier')} {quota.tierLabel}
    )} {quota.creditBalance !== null && quota.creditBalance !== undefined && (
    - Credits + {t('quotaTooltip.credits')} {quota.creditBalance.toLocaleString()}
    )}
    -

    Model quotas:

    +

    {t('quotaTooltip.modelQuotasLower')}

    {sharedTokenType && (

    - All buckets report {sharedTokenType} + {t('quotaTooltip.allBucketsReport', { tokenType: sharedTokenType })}

    )}
    @@ -395,7 +410,8 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro const bucketModels = formatGeminiBucketModels(bucket.modelIds); const remainingAmountLabel = formatGeminiRemainingAmount( bucket.remainingAmount, - bucket.tokenType + bucket.tokenType, + t ); return ( @@ -437,17 +453,22 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro // GitHub Copilot (ghcp) provider tooltip if (isGhcpQuotaResult(quota)) { const snapshotRows = [ - { label: 'Premium Interactions', snapshot: quota.snapshots.premiumInteractions }, - { label: 'Chat', snapshot: quota.snapshots.chat }, - { label: 'Completions', snapshot: quota.snapshots.completions }, + { + label: t('quotaTooltip.premiumInteractions'), + snapshot: quota.snapshots.premiumInteractions, + }, + { label: t('quotaTooltip.chat'), snapshot: quota.snapshots.chat }, + { label: t('quotaTooltip.completions'), snapshot: quota.snapshots.completions }, ]; const effectiveResetTime = quota.quotaResetDate ?? resetTime; const planLabel = formatPlanLabel(quota.planType); return (
    -

    Quota Snapshots:

    - {planLabel &&

    Plan: {planLabel}

    } +

    {t('quotaTooltip.quotaSnapshots')}

    + {planLabel && ( +

    {t('quotaTooltip.plan', { plan: planLabel })}

    + )} {snapshotRows.map(({ label, snapshot }) => { const isLow = snapshot.percentRemaining < 20; return ( @@ -456,13 +477,16 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro {label} {snapshot.unlimited - ? 'Unlimited' + ? t('quotaTooltip.unlimited') : `${formatQuotaPercent(snapshot.percentRemaining)}%`}
    {!snapshot.unlimited && (
    - {snapshot.remaining}/{snapshot.entitlement} remaining + {t('quotaTooltip.remaining', { + remaining: snapshot.remaining, + entitlement: snapshot.entitlement, + })}
    )}
    @@ -480,13 +504,15 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro * Reset time indicator shown at bottom of tooltip */ function ResetTimeIndicator({ resetTime }: { resetTime: string | null }) { + const { t } = useTranslation(); + if (!resetTime) return null; return (
    - Resets {formatResetTime(resetTime)} + {t('quotaTooltip.resets', { time: formatResetTime(resetTime) })}
    ); @@ -501,6 +527,8 @@ function CodexResetIndicators({ weeklyResetTime: string | null; fallbackResetTime: string | null; }) { + const { t } = useTranslation(); + const hasSpecificReset = !!fiveHourResetTime || !!weeklyResetTime; if (!hasSpecificReset && !fallbackResetTime) return null; @@ -510,7 +538,7 @@ function CodexResetIndicators({
    - 5h resets {formatResetTime(fiveHourResetTime)} + {t('quotaTooltip.fiveHourResets', { time: formatResetTime(fiveHourResetTime) })}
    )} @@ -518,7 +546,7 @@ function CodexResetIndicators({
    - Weekly resets {formatResetTime(weeklyResetTime)} + {t('quotaTooltip.weeklyResets', { time: formatResetTime(weeklyResetTime) })}
    )} diff --git a/ui/src/components/shared/settings-dialog.tsx b/ui/src/components/shared/settings-dialog.tsx index dac1321d..3f79d35d 100644 --- a/ui/src/components/shared/settings-dialog.tsx +++ b/ui/src/components/shared/settings-dialog.tsx @@ -207,16 +207,16 @@ function SettingsDialogContent({ return ( <> - Edit Profile: {profileName} - - Configure environment variables and settings for this profile. - + {i18n.t('settingsDialog.editProfile', { name: profileName })} + {i18n.t('settingsDialog.description')} {isLoading ? (
    - Loading settings... + + {i18n.t('settingsDialog.loadingSettings')} +
    ) : (
    @@ -230,20 +230,20 @@ function SettingsDialogContent({ value="env" className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2" > - Environment + {i18n.t('settingsDialog.envTab')} - Raw JSON + {i18n.t('settingsDialog.rawJsonTab')} - General + {i18n.t('settingsDialog.generalTab')} @@ -272,8 +272,8 @@ function SettingsDialogContent({
    ) : (
    -

    No environment variables configured.

    -

    Add variables in your settings.json file.

    +

    {i18n.t('settingsDialog.noEnvVars')}

    +

    {i18n.t('settingsDialog.noEnvVarsHint')}

    )} @@ -284,7 +284,9 @@ function SettingsDialogContent({ fallback={
    - Loading editor... + + {i18n.t('settingsDialog.loadingEditor')} +
    } > @@ -301,20 +303,26 @@ function SettingsDialogContent({ - Profile Information - Details about this configuration file. + + {i18n.t('settingsDialog.profileInfo')} + + {i18n.t('settingsDialog.profileInfoDesc')} {data && ( <>
    - Path + + {i18n.t('settingsDialog.path')} + {data.path}
    - Last Modified + + {i18n.t('settingsDialog.lastModified')} + {new Date(data.mtime).toLocaleString()}
    @@ -326,7 +334,7 @@ function SettingsDialogContent({
    @@ -348,9 +357,9 @@ function SettingsDialogContent({ handleConflictResolve(true)} onCancel={() => handleConflictResolve(false)} diff --git a/ui/src/components/shared/sponsor-button.tsx b/ui/src/components/shared/sponsor-button.tsx index 382d9afb..ac4a453e 100644 --- a/ui/src/components/shared/sponsor-button.tsx +++ b/ui/src/components/shared/sponsor-button.tsx @@ -7,10 +7,13 @@ import { Heart } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; const SPONSOR_URL = 'https://github.com/sponsors/kaitranntt'; export function SponsorButton() { + const { t } = useTranslation(); + return ( - Sponsor + {t('sponsorButton.sponsor')} ); diff --git a/ui/src/components/shared/value-metrics.tsx b/ui/src/components/shared/value-metrics.tsx index 9975a7b9..e7ffed46 100644 --- a/ui/src/components/shared/value-metrics.tsx +++ b/ui/src/components/shared/value-metrics.tsx @@ -1,6 +1,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { TrendingUpIcon, TrendingDownIcon, DollarSignIcon, ZapIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; interface MetricCardProps { title: string; @@ -39,37 +40,39 @@ function MetricCard({ title, value, change, changeLabel, icon, trend }: MetricCa } export function ValueMetrics() { + const { t } = useTranslation(); + // Mock data for demonstration const metrics = [ { - title: 'API Cost Saved', + title: t('valueMetrics.apiCostSaved'), value: '$127.50', change: 23, - changeLabel: 'vs last month', + changeLabel: t('valueMetrics.vsLastMonth'), icon: , trend: 'up' as const, }, { - title: 'Tokens Saved', + title: t('valueMetrics.tokensSaved'), value: '2.4M', change: 18, - changeLabel: 'through caching', + changeLabel: t('valueMetrics.throughCaching'), icon: , trend: 'up' as const, }, { - title: 'Queries Faster', + title: t('valueMetrics.queriesFaster'), value: '43%', change: 12, - changeLabel: 'average speedup', + changeLabel: t('valueMetrics.averageSpeedup'), icon: , trend: 'up' as const, }, { - title: 'Errors Reduced', + title: t('valueMetrics.errorsReduced'), value: '-67%', change: 67, - changeLabel: 'with retry logic', + changeLabel: t('valueMetrics.withRetryLogic'), icon: , trend: 'down' as const, }, @@ -77,7 +80,7 @@ export function ValueMetrics() { return (
    -

    Performance Metrics

    +

    {t('valueMetrics.performanceMetrics')}

    {metrics.map((metric, index) => ( @@ -86,25 +89,29 @@ export function ValueMetrics() { - Monthly Summary + {t('valueMetrics.monthlySummary')}
    $342.10
    -
    Total Saved
    +
    {t('valueMetrics.totalSaved')}
    8.7M
    -
    Tokens Processed
    +
    + {t('valueMetrics.tokensProcessed')} +
    1,247
    -
    Queries Handled
    +
    + {t('valueMetrics.queriesHandled')} +
    99.8%
    -
    Uptime
    +
    {t('valueMetrics.uptime')}
    diff --git a/ui/src/components/updates/support-entry-card.tsx b/ui/src/components/updates/support-entry-card.tsx index f3cc70b7..d9ab873e 100644 --- a/ui/src/components/updates/support-entry-card.tsx +++ b/ui/src/components/updates/support-entry-card.tsx @@ -24,9 +24,9 @@ const SCOPE_STYLES: Record = { export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) { const { t } = useTranslation(); const pillarLabels: { key: keyof CliSupportEntry['pillars']; label: string }[] = [ - { key: 'baseUrl', label: 'Base URL' }, - { key: 'auth', label: 'Auth' }, - { key: 'model', label: 'Model' }, + { key: 'baseUrl', label: t('profileDialog.baseUrl') }, + { key: 'auth', label: t('copilotPage.auth') }, + { key: 'model', label: t('cliproxyTable.model') }, ]; return ( diff --git a/ui/src/components/updates/updates-spotlight.tsx b/ui/src/components/updates/updates-spotlight.tsx index e9bea171..b53ff587 100644 --- a/ui/src/components/updates/updates-spotlight.tsx +++ b/ui/src/components/updates/updates-spotlight.tsx @@ -3,6 +3,7 @@ import { BellRing, ExternalLink } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { formatCatalogDate, getLatestSupportNotice } from '@/lib/support-updates-catalog'; import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; export function UpdatesSpotlight({ className, @@ -12,6 +13,7 @@ export function UpdatesSpotlight({ compact?: boolean; }) { const latest = getLatestSupportNotice(); + const { t } = useTranslation(); if (!latest) { return null; } @@ -35,7 +37,7 @@ export function UpdatesSpotlight({ to="/updates" className="inline-flex items-center gap-1 font-medium text-blue-700 hover:underline dark:text-blue-300" > - Open Updates Center + {t('updatesSpotlight.openUpdatesCenter')} diff --git a/ui/src/hooks/use-accounts.ts b/ui/src/hooks/use-accounts.ts index 83193022..d1461b67 100644 --- a/ui/src/hooks/use-accounts.ts +++ b/ui/src/hooks/use-accounts.ts @@ -12,6 +12,7 @@ import { type SharedGroupSummary, } from '@/lib/account-continuity'; import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; export interface AuthAccountsView { accounts: AuthAccountRow[]; @@ -70,12 +71,13 @@ export function useAccounts() { export function useSetDefaultAccount() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: (name: string) => api.accounts.setDefault(name), onSuccess: (_data, name) => { queryClient.invalidateQueries({ queryKey: ['accounts'] }); - toast.success(`Default account set to "${name}"`); + toast.success(t('toasts.defaultAccountSet', { name })); }, onError: (error: Error) => { toast.error(error.message); @@ -85,12 +87,13 @@ export function useSetDefaultAccount() { export function useResetDefaultAccount() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: () => api.accounts.resetDefault(), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['accounts'] }); - toast.success('Default account reset to CCS'); + toast.success(t('toasts.defaultAccountReset')); }, onError: (error: Error) => { toast.error(error.message); @@ -100,12 +103,13 @@ export function useResetDefaultAccount() { export function useDeleteAccount() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: (name: string) => api.accounts.delete(name), onSuccess: (_data, name) => { queryClient.invalidateQueries({ queryKey: ['accounts'] }); - toast.success(`Account "${name}" deleted`); + toast.success(t('toasts.accountDeleted', { name })); }, onError: (error: Error) => { toast.error(error.message); @@ -115,6 +119,7 @@ export function useDeleteAccount() { export function useUpdateAccountContext() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: ({ @@ -136,7 +141,7 @@ export function useUpdateAccountContext() { ? `shared (${(vars.context_group || 'default').trim().toLowerCase().replace(/\s+/g, '-')}, deeper continuity)` : `shared (${(vars.context_group || 'default').trim().toLowerCase().replace(/\s+/g, '-')}, standard)` : 'isolated'; - toast.success(`Updated "${vars.name}" context to ${contextSummary}`); + toast.success(t('toasts.contextUpdated', { name: vars.name, summary: contextSummary })); }, onError: (error: Error) => { toast.error(error.message); @@ -146,6 +151,7 @@ export function useUpdateAccountContext() { export function useConfirmLegacyAccountPolicies() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: async (accounts: Account[]) => { @@ -174,6 +180,7 @@ export function useConfirmLegacyAccountPolicies() { onSuccess: ({ updatedCount, failedCount }) => { queryClient.invalidateQueries({ queryKey: ['accounts'] }); if (failedCount > 0 && updatedCount > 0) { + // TODO i18n: missing key for partial legacy confirmation with failures toast.error( `Confirmed ${updatedCount} legacy account${updatedCount > 1 ? 's' : ''}, but ${failedCount} update${failedCount > 1 ? 's' : ''} failed. Refreshed account state.` ); @@ -181,6 +188,7 @@ export function useConfirmLegacyAccountPolicies() { } if (failedCount > 0) { + // TODO i18n: missing key for all legacy confirmations failed toast.error( `Failed to confirm ${failedCount} legacy account${failedCount > 1 ? 's' : ''}. Refreshed account state.` ); @@ -188,13 +196,11 @@ export function useConfirmLegacyAccountPolicies() { } if (updatedCount > 0) { - toast.success( - `Confirmed explicit sync mode for ${updatedCount} legacy account${updatedCount > 1 ? 's' : ''}` - ); + toast.success(t('toasts.legacyConfirmSuccess', { count: updatedCount })); return; } - toast.info('No legacy accounts need confirmation'); + toast.info(t('toasts.noLegacyAccounts')); }, onError: (error: Error) => { queryClient.invalidateQueries({ queryKey: ['accounts'] }); diff --git a/ui/src/hooks/use-claude-extension.ts b/ui/src/hooks/use-claude-extension.ts index 058e2e0b..d2df822c 100644 --- a/ui/src/hooks/use-claude-extension.ts +++ b/ui/src/hooks/use-claude-extension.ts @@ -1,5 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; import { withApiBase } from '@/lib/api-client'; export interface ClaudeExtensionProfileOption { @@ -149,6 +150,7 @@ export function useCreateClaudeExtensionBinding() { }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: bindingsQueryKey }); + // TODO i18n: missing key for 'Binding created' toast.success('Binding created'); }, onError: (error: Error) => toast.error(error.message), @@ -157,6 +159,7 @@ export function useCreateClaudeExtensionBinding() { export function useUpdateClaudeExtensionBinding() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: ({ id, binding }: { id: string; binding: ClaudeExtensionBindingInput }) => @@ -172,7 +175,7 @@ export function useUpdateClaudeExtensionBinding() { queryClient.invalidateQueries({ queryKey: ['claude-extension-binding-status', variables.id], }); - toast.success('Binding saved'); + toast.success(t('claudeExtensionPage.bindingSaved')); }, onError: (error: Error) => toast.error(error.message), }); @@ -180,6 +183,7 @@ export function useUpdateClaudeExtensionBinding() { export function useDeleteClaudeExtensionBinding() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: (id: string) => @@ -188,7 +192,7 @@ export function useDeleteClaudeExtensionBinding() { }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: bindingsQueryKey }); - toast.success('Binding deleted'); + toast.success(t('claudeExtensionPage.bindingDeleted')); }, onError: (error: Error) => toast.error(error.message), }); @@ -216,9 +220,11 @@ function useClaudeExtensionActionMutation(action: 'apply' | 'reset', successMess } export function useApplyClaudeExtensionBinding() { - return useClaudeExtensionActionMutation('apply', 'Binding applied'); + const { t } = useTranslation(); + return useClaudeExtensionActionMutation('apply', t('claudeExtensionPage.bindingApplied')); } export function useResetClaudeExtensionBinding() { - return useClaudeExtensionActionMutation('reset', 'Managed values removed'); + const { t } = useTranslation(); + return useClaudeExtensionActionMutation('reset', t('claudeExtensionPage.managedValuesRemoved')); } diff --git a/ui/src/hooks/use-cliproxy-ai-providers.ts b/ui/src/hooks/use-cliproxy-ai-providers.ts index 8493493c..6824859b 100644 --- a/ui/src/hooks/use-cliproxy-ai-providers.ts +++ b/ui/src/hooks/use-cliproxy-ai-providers.ts @@ -17,6 +17,7 @@ export function useCliproxyAiProviders() { export function useCreateCliproxyAiProviderEntry() { const queryClient = useQueryClient(); + // TODO i18n: missing key for 'Provider entry created' return useMutation({ mutationFn: ({ @@ -38,6 +39,7 @@ export function useCreateCliproxyAiProviderEntry() { export function useUpdateCliproxyAiProviderEntry() { const queryClient = useQueryClient(); + // TODO i18n: missing key for 'Provider entry updated' return useMutation({ mutationFn: ({ @@ -61,6 +63,7 @@ export function useUpdateCliproxyAiProviderEntry() { export function useDeleteCliproxyAiProviderEntry() { const queryClient = useQueryClient(); + // TODO i18n: missing key for 'Provider entry removed' return useMutation({ mutationFn: ({ family, entryId }: { family: AiProviderFamilyId; entryId: string }) => diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index a49d0c25..3f41a1e3 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -6,6 +6,7 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; import { api } from '@/lib/api-client'; import { isValidProvider, isDeviceCodeProvider } from '@/lib/provider-config'; @@ -77,6 +78,7 @@ const INITIAL_STATE: AuthFlowState = { }; export function useCliproxyAuthFlow() { + const { t } = useTranslation(); const [state, setState] = useState(INITIAL_STATE); const stateRef = useRef(INITIAL_STATE); @@ -129,6 +131,7 @@ export function useCliproxyAuthFlow() { setState((prev) => ({ ...prev, isAuthenticating: false, + // TODO i18n: missing key for 'Authentication timed out. Please try again.' error: 'Authentication timed out. Please try again.', })); } @@ -158,6 +161,7 @@ export function useCliproxyAuthFlow() { const hasAccount = typeof data.account === 'object' && data.account !== null; if (!hasAccount) { stopPolling(); + // TODO i18n: missing key for 'Authenticated account could not be registered' const errorMsg = 'Authenticated account could not be registered'; toast.error(errorMsg); setState((prev) => ({ @@ -173,7 +177,7 @@ export function useCliproxyAuthFlow() { queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] }); invalidateCliproxyRoutingData(queryClient); - toast.success(`${provider} authentication successful`); + toast.success(t('toasts.providerAuthSuccess', { provider })); openedAuthUrlRef.current = false; setState(INITIAL_STATE); } else if (data.status === 'auth_url') { @@ -194,7 +198,7 @@ export function useCliproxyAuthFlow() { data.user_code && data.verification_url ? `Open ${data.verification_url} and enter code: ${data.user_code}` : 'Switch to Device Code method and try again.'; - toast.error('Provider returned Device Code flow in callback mode'); + toast.error(t('toasts.providerDeviceCodeInCallback')); setState((prev) => ({ ...prev, isAuthenticating: false, @@ -222,6 +226,7 @@ export function useCliproxyAuthFlow() { } stopPolling(); + // TODO i18n: missing key for 'Lost contact with the auth status endpoint' const message = error instanceof Error && error.message.trim().length > 0 ? error.message @@ -234,12 +239,13 @@ export function useCliproxyAuthFlow() { })); } }, - [isActiveAttempt, queryClient, stopPolling] + [isActiveAttempt, queryClient, stopPolling, t] ); const startAuth = useCallback( async (provider: string, options?: StartAuthOptions) => { if (!isValidProvider(provider)) { + // TODO i18n: missing key for 'Unknown provider: {{provider}}' setState({ ...INITIAL_STATE, error: `Unknown provider: ${provider}`, @@ -313,6 +319,7 @@ export function useCliproxyAuthFlow() { openedAuthUrlRef.current = false; setState(INITIAL_STATE); } else { + // TODO i18n: missing key for 'Authenticated account could not be registered' (start endpoint) const errorMsg = typeof data.error === 'string' ? data.error @@ -363,6 +370,7 @@ export function useCliproxyAuthFlow() { const success = data.success === true; if (!response.ok || !success) { + // TODO i18n: missing key for 'Failed to start OAuth' const errorMsg = typeof data.error === 'string' ? data.error : 'Failed to start OAuth'; throw new Error(errorMsg); } @@ -475,9 +483,10 @@ export function useCliproxyAuthFlow() { queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] }); invalidateCliproxyRoutingData(queryClient); - toast.success(`${currentProvider} authentication successful`); + toast.success(t('toasts.providerAuthSuccess', { provider: currentProvider })); setState(INITIAL_STATE); } else { + // TODO i18n: missing key for 'Callback submission failed' const errorMsg = typeof data.error === 'string' ? data.error @@ -490,12 +499,13 @@ export function useCliproxyAuthFlow() { if (!isActiveAttempt(attemptId)) { return; } + // TODO i18n: missing key for 'Failed to submit callback' const message = error instanceof Error ? error.message : 'Failed to submit callback'; toast.error(message); setState((prev) => ({ ...prev, isSubmittingCallback: false, error: message })); } }, - [isActiveAttempt, state.provider, queryClient, stopPolling] + [isActiveAttempt, state.provider, queryClient, stopPolling, t] ); return useMemo( diff --git a/ui/src/hooks/use-cliproxy-config.ts b/ui/src/hooks/use-cliproxy-config.ts index 23795ced..6dc25288 100644 --- a/ui/src/hooks/use-cliproxy-config.ts +++ b/ui/src/hooks/use-cliproxy-config.ts @@ -11,6 +11,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { parse as parseYaml } from 'yaml'; import { api } from '@/lib/api-client'; import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; interface ValidationResult { valid: boolean; @@ -70,6 +71,7 @@ function validateYaml(code: string): ValidationResult { export function useCliproxyConfig() { const queryClient = useQueryClient(); + const { t } = useTranslation(); // Fetch config.yaml - server state const configQuery = useQuery({ @@ -121,21 +123,21 @@ export function useCliproxyConfig() { dispatch({ type: 'SAVE_SUCCESS', content: variables }); queryClient.invalidateQueries({ queryKey: ['cliproxy-config-yaml'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy'] }); - toast.success('Configuration saved successfully'); + toast.success(t('toasts.configSaved')); }, onError: (error: Error) => { - toast.error(`Failed to save: ${error.message}`); + toast.error(t('toasts.configSaveFailed', { error: error.message })); }, }); // Save handler const saveContent = useCallback(() => { if (!validation.valid) { - toast.error('Cannot save invalid YAML'); + toast.error(t('toasts.invalidYaml')); return; } saveMutation.mutate(content); - }, [content, validation.valid, saveMutation]); + }, [content, validation.valid, saveMutation, t]); return { // State diff --git a/ui/src/hooks/use-cliproxy-sync.ts b/ui/src/hooks/use-cliproxy-sync.ts index e468c7b5..7baa5510 100644 --- a/ui/src/hooks/use-cliproxy-sync.ts +++ b/ui/src/hooks/use-cliproxy-sync.ts @@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; /** Sync status response */ export interface SyncStatus { @@ -151,6 +152,7 @@ export function useSyncPreview() { */ export function useExecuteSync() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: executeSync, @@ -161,15 +163,16 @@ export function useExecuteSync() { // Show success toast with synced count if (data.syncedCount === 0) { - toast.info('No profiles to sync'); + toast.info(t('toasts.noProfilesToSync')); } else { + // TODO i18n: missing key for 'Synced {{count}} profile(s) to CLIProxy' toast.success( `Synced ${data.syncedCount} profile${data.syncedCount === 1 ? '' : 's'} to CLIProxy` ); } }, onError: (error: Error) => { - toast.error(`Sync failed: ${error.message}`); + toast.error(t('toasts.syncFailed', { error: error.message })); }, }); } diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index 46d48a4e..2574833d 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -13,6 +13,7 @@ import { type RoutingStrategy, } from '@/lib/api-client'; import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; function invalidateCliproxyRoutingQueries(queryClient: ReturnType): void { queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] }); @@ -57,12 +58,15 @@ export function useCliproxyRoutingStrategy() { export function useUpdateCliproxyRoutingStrategy() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: (strategy: RoutingStrategy) => api.cliproxy.updateRoutingStrategy(strategy), onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['cliproxy-routing'] }); - toast.success(result.message || `Routing strategy set to ${result.strategy}`); + toast.success( + result.message || t('toasts.routingStrategySet', { strategy: result.strategy }) + ); }, onError: (error: Error) => { toast.error(error.message); @@ -72,12 +76,13 @@ export function useUpdateCliproxyRoutingStrategy() { export function useCreateVariant() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: (data: CreateVariant) => api.cliproxy.create(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['cliproxy'] }); - toast.success('Variant created successfully'); + toast.success(t('toasts.variantCreated')); }, onError: (error: Error) => { toast.error(error.message); @@ -87,13 +92,14 @@ export function useCreateVariant() { export function useUpdateVariant() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: ({ name, data }: { name: string; data: UpdateVariant }) => api.cliproxy.update(name, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['cliproxy'] }); - toast.success('Variant updated successfully'); + toast.success(t('toasts.variantUpdated')); }, onError: (error: Error) => { toast.error(error.message); @@ -103,12 +109,13 @@ export function useUpdateVariant() { export function useDeleteVariant() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: (name: string) => api.cliproxy.delete(name), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['cliproxy'] }); - toast.success('Variant deleted successfully'); + toast.success(t('toasts.variantDeleted')); }, onError: (error: Error) => { toast.error(error.message); @@ -134,13 +141,14 @@ export function useProviderAccounts(provider: string) { export function useSetDefaultAccount() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) => api.cliproxy.accounts.setDefault(provider, accountId), onSuccess: () => { invalidateCliproxyAccountQueries(queryClient); - toast.success('Default account updated'); + toast.success(t('toasts.defaultAccountUpdated')); }, onError: (error: Error) => { toast.error(error.message); @@ -150,13 +158,14 @@ export function useSetDefaultAccount() { export function useRemoveAccount() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) => api.cliproxy.accounts.remove(provider, accountId), onSuccess: () => { invalidateCliproxyAccountQueries(queryClient); - toast.success('Account removed'); + toast.success(t('toasts.accountRemoved')); }, onError: (error: Error) => { toast.error(error.message); @@ -166,6 +175,7 @@ export function useRemoveAccount() { export function usePauseAccount() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) => @@ -173,7 +183,7 @@ export function usePauseAccount() { onSuccess: () => { invalidateCliproxyAccountQueries(queryClient); queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); - toast.success('Account paused'); + toast.success(t('toasts.accountPaused')); }, onError: (error: Error) => { toast.error(error.message); @@ -183,6 +193,7 @@ export function usePauseAccount() { export function useResumeAccount() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) => @@ -190,7 +201,7 @@ export function useResumeAccount() { onSuccess: () => { invalidateCliproxyAccountQueries(queryClient); queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); - toast.success('Account resumed'); + toast.success(t('toasts.accountResumed')); }, onError: (error: Error) => { toast.error(error.message); @@ -208,6 +219,7 @@ export function useSoloAccount() { invalidateCliproxyAccountQueries(queryClient); queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); const pausedCount = data.paused.length; + // TODO i18n: missing key for 'Solo mode: paused {{count}} other account(s)' toast.success( `Solo mode: paused ${pausedCount} other account${pausedCount !== 1 ? 's' : ''}` ); @@ -227,10 +239,12 @@ export function useBulkPauseAccounts() { onSuccess: (data) => { invalidateCliproxyAccountQueries(queryClient); queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); + // TODO i18n: missing key for 'Paused {{count}} account(s)' toast.success( `Paused ${data.succeeded.length} account${data.succeeded.length !== 1 ? 's' : ''}` ); if (data.failed.length > 0) { + // TODO i18n: missing key for '{{count}} account(s) failed to pause' toast.warning( `${data.failed.length} account${data.failed.length !== 1 ? 's' : ''} failed to pause` ); @@ -251,10 +265,12 @@ export function useBulkResumeAccounts() { onSuccess: (data) => { invalidateCliproxyAccountQueries(queryClient); queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); + // TODO i18n: missing key for 'Resumed {{count}} account(s)' toast.success( `Resumed ${data.succeeded.length} account${data.succeeded.length !== 1 ? 's' : ''}` ); if (data.failed.length > 0) { + // TODO i18n: missing key for '{{count}} account(s) failed to resume' toast.warning( `${data.failed.length} account${data.failed.length !== 1 ? 's' : ''} failed to resume` ); @@ -269,13 +285,14 @@ export function useBulkResumeAccounts() { // OAuth flow hook export function useStartAuth() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: ({ provider, nickname }: { provider: string; nickname?: string }) => api.cliproxy.auth.start(provider, nickname), onSuccess: (_data, variables) => { invalidateCliproxyAccountQueries(queryClient); - toast.success(`Account added for ${variables.provider}`); + toast.success(t('toasts.accountAdded', { provider: variables.provider })); }, onError: (error: Error) => { toast.error(error.message); @@ -296,15 +313,16 @@ export function useCancelAuth() { // Kiro IDE import hook (alternative auth path when OAuth callback fails) export function useKiroImport() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: () => api.cliproxy.auth.kiroImport(), onSuccess: (data) => { invalidateCliproxyAccountQueries(queryClient); if (data.account) { - toast.success(`Imported Kiro account: ${data.account.email || data.account.id}`); + toast.success(t('toasts.kiroImported', { name: data.account.email || data.account.id })); } else { - toast.success('Kiro token imported'); + toast.success(t('toasts.kiroTokenImported')); } }, onError: (error: Error) => { @@ -331,13 +349,14 @@ export function useCliproxyModels() { export function useUpdateModel() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: ({ provider, model }: { provider: string; model: string }) => api.cliproxy.updateModel(provider, model), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] }); - toast.success('Model updated'); + toast.success(t('toasts.modelUpdated')); }, onError: (error: Error) => { toast.error(error.message); @@ -357,13 +376,14 @@ export function usePresets(profile: string) { export function useCreatePreset() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: ({ profile, data }: { profile: string; data: CreatePreset }) => api.presets.create(profile, data), onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ['presets', variables.profile] }); - toast.success(`Preset "${variables.data.name}" saved`); + toast.success(t('toasts.presetSaved', { name: variables.data.name })); }, onError: (error: Error) => { toast.error(error.message); @@ -373,13 +393,14 @@ export function useCreatePreset() { export function useDeletePreset() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: ({ profile, name }: { profile: string; name: string }) => api.presets.delete(profile, name), onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ['presets', variables.profile] }); - toast.success('Preset deleted'); + toast.success(t('toasts.presetDeleted')); }, onError: (error: Error) => { toast.error(error.message); @@ -399,17 +420,18 @@ export function useProxyStatus() { export function useStartProxy() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: () => api.cliproxy.proxyStart(), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['proxy-status'] }); if (data.alreadyRunning) { - toast.info('CLIProxy was already running'); + toast.info(t('toasts.cliproxyAlreadyRunning')); } else if (data.started) { - toast.success('CLIProxy started successfully'); + toast.success(t('toasts.cliproxyStarted')); } else { - toast.error(data.error || 'Failed to start CLIProxy'); + toast.error(data.error || t('toasts.cliproxyStartFailed')); } }, onError: (error: Error) => { @@ -420,17 +442,19 @@ export function useStartProxy() { export function useStopProxy() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: () => api.cliproxy.proxyStop(), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['proxy-status'] }); if (data.stopped) { + // TODO i18n: missing key for 'CLIProxy stopped ({{count}} session(s) disconnected)' toast.success( `CLIProxy stopped${data.sessionCount ? ` (${data.sessionCount} session(s) disconnected)` : ''}` ); } else { - toast.error(data.error || 'Failed to stop CLIProxy'); + toast.error(data.error || t('toasts.cliproxyStopFailed')); } }, onError: (error: Error) => { @@ -472,10 +496,11 @@ export function useUpdateBackend() { queryClient.invalidateQueries({ queryKey: ['cliproxy-server-config'], refetchType: 'all' }); queryClient.invalidateQueries({ queryKey: ['proxy-status'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); + // TODO i18n: missing key for 'Backend updated' toast.success('Backend updated'); }, onError: (error: Error) => { - // Handle 409 conflict (proxy running) + // TODO i18n: missing key for 'Stop the proxy first to change backend' if (error.message.includes('Proxy is running')) { toast.error('Stop the proxy first to change backend'); } else { @@ -511,8 +536,10 @@ export function useInstallVersion() { queryClient.invalidateQueries({ queryKey: ['cliproxy-update-check'] }); queryClient.invalidateQueries({ queryKey: ['proxy-status'] }); if (data.success) { + // TODO i18n: missing key for 'Installed v{{version}}' toast.success(data.message || `Installed v${data.version}`); } else { + // TODO i18n: missing key for 'Installation failed' toast.error(data.error || 'Installation failed'); } }, @@ -530,8 +557,10 @@ export function useRestartProxy() { onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['proxy-status'] }); if (data.success) { + // TODO i18n: missing key for 'Proxy restarted on port {{port}}' toast.success(`Proxy restarted on port ${data.port}`); } else { + // TODO i18n: missing key for 'Restart failed' toast.error(data.error || 'Restart failed'); } }, diff --git a/ui/src/hooks/use-device-code.ts b/ui/src/hooks/use-device-code.ts index 81c18a44..2011b6aa 100644 --- a/ui/src/hooks/use-device-code.ts +++ b/ui/src/hooks/use-device-code.ts @@ -8,6 +8,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; import { getDeviceCodeProviderDisplayName } from '@/lib/provider-config'; export interface DeviceCodePrompt { @@ -33,6 +34,7 @@ function coerceProvider(value: unknown): string { } export function useDeviceCode() { + const { t } = useTranslation(); const [state, setState] = useState({ isOpen: false, prompt: null, @@ -48,7 +50,7 @@ export function useDeviceCode() { console.log('[DeviceCode] Received prompt:', data.sessionId); const provider = coerceProvider(data.provider); const displayName = getDeviceCodeProviderDisplayName(provider); - toast.info(`${displayName} authorization required`); + toast.info(t('toasts.authRequired', { provider: displayName })); setState({ isOpen: true, @@ -66,7 +68,7 @@ export function useDeviceCode() { setState((prev) => { if (prev.prompt && prev.prompt.sessionId === data.sessionId) { const displayName = getDeviceCodeProviderDisplayName(prev.prompt.provider); - toast.success(`${displayName} authentication successful!`); + toast.success(t('toasts.authSuccess', { provider: displayName })); return { isOpen: false, prompt: null, error: null }; } return prev; @@ -76,7 +78,7 @@ export function useDeviceCode() { setState((prev) => { if (prev.prompt && prev.prompt.sessionId === data.sessionId) { const displayName = getDeviceCodeProviderDisplayName(prev.prompt.provider); - toast.error(`${displayName} authentication failed`); + toast.error(t('toasts.authFailed', { provider: displayName })); return { isOpen: false, prompt: null, error: data.error as string }; } return prev; @@ -85,7 +87,7 @@ export function useDeviceCode() { console.log('[DeviceCode] Code expired:', data.sessionId); setState((prev) => { if (prev.prompt?.sessionId === data.sessionId) { - toast.error('Device code expired. Please try again.'); + toast.error(t('toasts.deviceCodeExpired')); return { isOpen: false, prompt: null, error: 'Device code expired' }; } return prev; @@ -99,7 +101,7 @@ export function useDeviceCode() { return () => { window.removeEventListener('ws-message', handleMessage as EventListener); }; - }, []); + }, [t]); const handleClose = useCallback(() => { setState({ isOpen: false, prompt: null, error: null }); @@ -115,12 +117,12 @@ export function useDeviceCode() { if (state.prompt?.userCode) { try { await navigator.clipboard.writeText(state.prompt.userCode); - toast.success('Code copied to clipboard'); + toast.success(t('toasts.codeCopied')); } catch { - toast.error('Failed to copy code'); + toast.error(t('toasts.failedCopy')); } } - }, [state.prompt]); + }, [state.prompt, t]); return useMemo( () => ({ diff --git a/ui/src/hooks/use-logs.ts b/ui/src/hooks/use-logs.ts index 2f63f4fa..356ebb3a 100644 --- a/ui/src/hooks/use-logs.ts +++ b/ui/src/hooks/use-logs.ts @@ -1,6 +1,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useDeferredValue, useMemo, useState } from 'react'; import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; import { api, type LogsEntry, @@ -100,6 +101,7 @@ export function useLogsWorkspace() { export function useUpdateLogsConfig() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: (payload: UpdateLogsConfigPayload) => api.logs.updateConfig(payload), @@ -109,10 +111,10 @@ export function useUpdateLogsConfig() { queryClient.invalidateQueries({ queryKey: SOURCES_QUERY_KEY }), queryClient.invalidateQueries({ queryKey: ['logs', 'entries'] }), ]); - toast.success('Logging configuration saved.'); + toast.success(t('toasts.loggingConfigSaved')); }, onError: (error: Error) => { - toast.error(error.message || 'Failed to save logging configuration.'); + toast.error(error.message || t('toasts.loggingConfigSaveFailed')); }, }); } diff --git a/ui/src/hooks/use-profiles.ts b/ui/src/hooks/use-profiles.ts index 5e46dcd3..971bab53 100644 --- a/ui/src/hooks/use-profiles.ts +++ b/ui/src/hooks/use-profiles.ts @@ -13,6 +13,7 @@ import { type ImportProfileRequest, } from '@/lib/api-client'; import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; export function useProfiles() { return useQuery({ @@ -23,12 +24,13 @@ export function useProfiles() { export function useCreateProfile() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: (data: CreateProfile) => api.profiles.create(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['profiles'] }); - toast.success('Profile created successfully'); + toast.success(t('toasts.profileCreated')); }, onError: (error: Error) => { toast.error(error.message); @@ -38,13 +40,14 @@ export function useCreateProfile() { export function useUpdateProfile() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: ({ name, data }: { name: string; data: UpdateProfile }) => api.profiles.update(name, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['profiles'] }); - toast.success('Profile updated successfully'); + toast.success(t('toasts.profileUpdated')); }, onError: (error: Error) => { toast.error(error.message); @@ -54,12 +57,13 @@ export function useUpdateProfile() { export function useDeleteProfile() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: (name: string) => api.profiles.delete(name), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['profiles'] }); - toast.success('Profile deleted successfully'); + toast.success(t('toasts.profileDeleted')); }, onError: (error: Error) => { toast.error(error.message); @@ -75,25 +79,27 @@ export function useDiscoverProfileOrphans() { export function useRegisterProfileOrphans() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: (data: RegisterProfileOrphansRequest) => api.profiles.registerOrphans(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['profiles'] }); - toast.success('Orphan profiles registration complete'); + toast.success(t('toasts.orphanProfilesComplete')); }, }); } export function useCopyProfile() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: ({ name, data }: { name: string; data: CopyProfileRequest }) => api.profiles.copy(name, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['profiles'] }); - toast.success('Profile copied successfully'); + toast.success(t('toasts.profileCopied')); }, }); } @@ -107,12 +113,13 @@ export function useExportProfile() { export function useImportProfile() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: (data: ImportProfileRequest) => api.profiles.import(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['profiles'] }); - toast.success('Profile imported successfully'); + toast.success(t('toasts.profileImported')); }, }); } diff --git a/ui/src/hooks/use-unified-config.ts b/ui/src/hooks/use-unified-config.ts index e0d1d021..871e1033 100644 --- a/ui/src/hooks/use-unified-config.ts +++ b/ui/src/hooks/use-unified-config.ts @@ -6,6 +6,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from '@/lib/api-client'; import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; /** * Get current config format and migration status @@ -33,13 +34,14 @@ export function useUnifiedConfig() { */ export function useUpdateConfig() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: (config: Record) => api.config.update(config), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['unified-config'] }); queryClient.invalidateQueries({ queryKey: ['config-format'] }); - toast.success('Configuration updated successfully'); + toast.success(t('toasts.unifiedConfigUpdated')); }, onError: (error: Error) => { toast.error(error.message); @@ -52,20 +54,21 @@ export function useUpdateConfig() { */ export function useMigration() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: (dryRun: boolean) => api.config.migrate(dryRun), onSuccess: (result, dryRun) => { if (dryRun) { - toast.info('Migration preview completed'); + toast.info(t('toasts.migrationPreviewComplete')); } else if (result.success) { queryClient.invalidateQueries({ queryKey: ['config-format'] }); queryClient.invalidateQueries({ queryKey: ['unified-config'] }); queryClient.invalidateQueries({ queryKey: ['profiles'] }); queryClient.invalidateQueries({ queryKey: ['accounts'] }); - toast.success('Migration completed successfully'); + toast.success(t('toasts.migrationComplete')); } else { - toast.error(result.error ?? 'Migration failed'); + toast.error(result.error ?? t('toasts.migrationFailed')); } }, onError: (error: Error) => { @@ -79,6 +82,7 @@ export function useMigration() { */ export function useRollback() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: (backupPath: string) => api.config.rollback(backupPath), @@ -88,9 +92,9 @@ export function useRollback() { queryClient.invalidateQueries({ queryKey: ['unified-config'] }); queryClient.invalidateQueries({ queryKey: ['profiles'] }); queryClient.invalidateQueries({ queryKey: ['accounts'] }); - toast.success('Rollback completed successfully'); + toast.success(t('toasts.rollbackComplete')); } else { - toast.error('Rollback failed'); + toast.error(t('toasts.rollbackFailed')); } }, onError: (error: Error) => { diff --git a/ui/src/hooks/use-websocket.ts b/ui/src/hooks/use-websocket.ts index aab8a6e1..aa2ced99 100644 --- a/ui/src/hooks/use-websocket.ts +++ b/ui/src/hooks/use-websocket.ts @@ -7,6 +7,7 @@ import { useEffect, useState, useCallback, useRef, useMemo } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; interface WSMessage { type: string; @@ -17,6 +18,7 @@ interface WSMessage { type ConnectionStatus = 'connecting' | 'connected' | 'disconnected'; export function useWebSocket() { + const { t } = useTranslation(); const [status, setStatus] = useState('disconnected'); const [isReconnecting, setIsReconnecting] = useState(false); const wsRef = useRef(null); @@ -36,17 +38,17 @@ export function useWebSocket() { case 'config-changed': queryClient.invalidateQueries({ queryKey: ['profiles'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy'] }); - toast.info('Configuration updated externally'); + toast.info(t('toasts.configUpdatedExternally')); break; case 'settings-changed': queryClient.invalidateQueries({ queryKey: ['profiles'] }); - toast.info('Settings file updated'); + toast.info(t('toasts.settingsFileUpdated')); break; case 'profiles-changed': queryClient.invalidateQueries({ queryKey: ['accounts'] }); - toast.info('Accounts updated'); + toast.info(t('toasts.accountsUpdated')); break; case 'proxy-status-changed': @@ -61,7 +63,7 @@ export function useWebSocket() { console.log(`[WS] Unknown message: ${message.type}`); } }, - [queryClient] + [queryClient, t] ); const connect = useCallback(() => { diff --git a/ui/src/lib/account-identity.ts b/ui/src/lib/account-identity.ts index eac2beda..b001fd02 100644 --- a/ui/src/lib/account-identity.ts +++ b/ui/src/lib/account-identity.ts @@ -32,13 +32,13 @@ function formatVariantPart(part: string): string { switch (normalized) { case 'team': - return 'Team'; + return 'Team'; // TODO i18n: missing key for account variant team case 'free': - return 'Free'; + return 'Free'; // TODO i18n: missing key for account variant free case 'plus': - return 'Plus'; + return 'Plus'; // TODO i18n: missing key for account variant plus case 'pro': - return 'Pro'; + return 'Pro'; // TODO i18n: missing key for account variant pro default: return /^[a-f0-9]{8}$/i.test(normalized) ? normalized @@ -98,14 +98,14 @@ function formatWorkspaceLabel(parts: string[]): { const workspaceId = parts.find((part) => /^[a-f0-9]{8}$/i.test(part)); if (workspaceId) { return { - detailLabel: `Workspace ${workspaceId.toLowerCase()}`, + detailLabel: `Workspace ${workspaceId.toLowerCase()}`, // TODO i18n: missing key for workspace label compactDetailLabel: workspaceId.toLowerCase(), }; } const extraLabel = parts.map(formatVariantPart).filter(Boolean).join(' · '); return { - detailLabel: extraLabel || 'Team', + detailLabel: extraLabel || 'Team', // TODO i18n: missing key for team fallback compactDetailLabel: extraLabel || 'Team', }; } @@ -155,7 +155,7 @@ export function getAccountIdentityPresentation( const suffix = parts[parts.length - 1]?.toLowerCase(); if (suffix && BUSINESS_PLAN_PARTS.has(suffix)) { const workspace = formatWorkspaceLabel(parts.slice(0, -1)); - const inlineLabel = ['Business', workspace.detailLabel].filter(Boolean).join(' · '); + const inlineLabel = ['Business', workspace.detailLabel].filter(Boolean).join(' · '); // TODO i18n: missing keys for Business/Personal audience labels return { email: resolvedEmail, audience: 'business', @@ -171,7 +171,7 @@ export function getAccountIdentityPresentation( .filter(Boolean) .join(' · '); const detailLabel = detailParts || formatVariantPart(suffix); - const inlineLabel = ['Personal', detailLabel].filter(Boolean).join(' · '); + const inlineLabel = ['Personal', detailLabel].filter(Boolean).join(' · '); // TODO i18n: missing key for Personal return { email: resolvedEmail, audience: 'personal', diff --git a/ui/src/lib/codex-config.ts b/ui/src/lib/codex-config.ts index cfae6619..35f57b14 100644 --- a/ui/src/lib/codex-config.ts +++ b/ui/src/lib/codex-config.ts @@ -61,41 +61,41 @@ wire_api = "responses"`; export const KNOWN_CODEX_FEATURES: CodexFeatureCatalogEntry[] = [ { name: 'multi_agent', - label: 'Multi-agent', - description: 'Enable subagent collaboration tools.', + label: 'Multi-agent', // TODO i18n: missing key for codex feature + description: 'Enable subagent collaboration tools.', // TODO i18n: missing key }, { name: 'unified_exec', - label: 'Unified exec', - description: 'Use the PTY-backed unified exec tool.', + label: 'Unified exec', // TODO i18n: missing key for codex feature + description: 'Use the PTY-backed unified exec tool.', // TODO i18n: missing key }, { name: 'shell_snapshot', - label: 'Shell snapshot', - description: 'Reuse shell environment snapshots.', + label: 'Shell snapshot', // TODO i18n: missing key for codex feature + description: 'Reuse shell environment snapshots.', // TODO i18n: missing key }, { name: 'apply_patch_freeform', - label: 'Apply patch', - description: 'Enable freeform apply_patch edits.', + label: 'Apply patch', // TODO i18n: missing key for codex feature + description: 'Enable freeform apply_patch edits.', // TODO i18n: missing key }, - { name: 'js_repl', label: 'JS REPL', description: 'Enable the Node-backed JavaScript REPL.' }, + { name: 'js_repl', label: 'JS REPL', description: 'Enable the Node-backed JavaScript REPL.' }, // TODO i18n: missing keys { name: 'runtime_metrics', - label: 'Runtime metrics', - description: 'Collect Codex runtime metrics.', + label: 'Runtime metrics', // TODO i18n: missing key for codex feature + description: 'Collect Codex runtime metrics.', // TODO i18n: missing key }, { name: 'prevent_idle_sleep', - label: 'Prevent idle sleep', - description: 'Keep the machine awake while active.', + label: 'Prevent idle sleep', // TODO i18n: missing key for codex feature + description: 'Keep the machine awake while active.', // TODO i18n: missing key }, - { name: 'fast_mode', label: 'Fast mode', description: 'Allow the fast service tier path.' }, - { name: 'apps', label: 'Apps', description: 'Enable ChatGPT Apps and connectors support.' }, + { name: 'fast_mode', label: 'Fast mode', description: 'Allow the fast service tier path.' }, // TODO i18n: missing keys + { name: 'apps', label: 'Apps', description: 'Enable ChatGPT Apps and connectors support.' }, // TODO i18n: missing keys { name: 'smart_approvals', - label: 'Smart approvals', - description: 'Route eligible approvals through the guardian flow.', + label: 'Smart approvals', // TODO i18n: missing key for codex feature + description: 'Route eligible approvals through the guardian flow.', // TODO i18n: missing key }, ]; diff --git a/ui/src/lib/codex-effort.ts b/ui/src/lib/codex-effort.ts index ff5e8bfb..9f3b26f2 100644 --- a/ui/src/lib/codex-effort.ts +++ b/ui/src/lib/codex-effort.ts @@ -10,12 +10,16 @@ export function parseCodexEffort(modelId: string | undefined): CodexEffort | und } export function getCodexEffortDisplay( - modelId: string | undefined + modelId: string | undefined, + effortLabels?: { pinned: (effort: string) => string; auto: string } ): { label: string; explicit: boolean } | null { if (!modelId) return null; const effort = parseCodexEffort(modelId); if (effort) { - return { label: `Pinned ${effort}`, explicit: true }; + return { + label: effortLabels?.pinned(effort) ?? `Pinned ${effort}`, + explicit: true, + }; } - return { label: 'Auto effort', explicit: false }; + return { label: effortLabels?.auto ?? 'Auto effort', explicit: false }; } diff --git a/ui/src/lib/droid-byok-custom-models.ts b/ui/src/lib/droid-byok-custom-models.ts index c454dcdb..30f7e628 100644 --- a/ui/src/lib/droid-byok-custom-models.ts +++ b/ui/src/lib/droid-byok-custom-models.ts @@ -267,7 +267,7 @@ export function extractDroidByokModels(settings: Record): Droid const displayName = asNonEmptyString(entry.displayName) ?? asNonEmptyString(entry.model_display_name) ?? - 'Unnamed model'; + 'Unnamed model'; // TODO i18n: missing key for unnamed model const model = asNonEmptyString(entry.model) ?? ''; const provider = asNonEmptyString(entry.provider) ?? 'unknown'; const providerKind = normalizeProviderKind(provider); diff --git a/ui/src/lib/error-log-parser.ts b/ui/src/lib/error-log-parser.ts index 66b4207d..ff61c6c0 100644 --- a/ui/src/lib/error-log-parser.ts +++ b/ui/src/lib/error-log-parser.ts @@ -398,8 +398,6 @@ function getStatusText(code: number): string { */ export function formatRelativeTime(modifiedSeconds: number, locale?: string): string { const formatLocale = getFormattingLocale(locale); - const isZh = formatLocale === 'zh-CN'; - const isVi = formatLocale === 'vi'; const now = Date.now(); const modified = modifiedSeconds * 1000; // Convert to milliseconds const diff = now - modified; @@ -410,21 +408,33 @@ export function formatRelativeTime(modifiedSeconds: number, locale?: string): st const days = Math.floor(hours / 24); if (seconds < 60) { + // TODO i18n: missing key for relative time "just now" + const isZh = formatLocale === 'zh-CN'; + const isVi = formatLocale === 'vi'; if (isZh) return '刚刚'; if (isVi) return 'vừa xong'; return 'just now'; } if (minutes < 60) { + // TODO i18n: missing key for relative time minutes + const isZh = formatLocale === 'zh-CN'; + const isVi = formatLocale === 'vi'; if (isZh) return `${minutes} 分钟前`; if (isVi) return `${minutes} phút trước`; return `${minutes}m ago`; } if (hours < 24) { + // TODO i18n: missing key for relative time hours + const isZh = formatLocale === 'zh-CN'; + const isVi = formatLocale === 'vi'; if (isZh) return `${hours} 小时前`; if (isVi) return `${hours} giờ trước`; return `${hours}h ago`; } if (days < 7) { + // TODO i18n: missing key for relative time days + const isZh = formatLocale === 'zh-CN'; + const isVi = formatLocale === 'vi'; if (isZh) return `${days} 天前`; if (isVi) return `${days} ngày trước`; return `${days}d ago`; @@ -458,6 +468,7 @@ export function getStatusColor(code: number): string { * Get error type label */ export function getErrorTypeLabel(type: ParsedErrorLog['errorType'], locale?: string): string { + // TODO i18n: missing keys for error type labels (rate_limit, auth, not_found, server, timeout, unknown) const formatLocale = getFormattingLocale(locale); if (formatLocale === 'zh-CN') { const labels: Record = { @@ -520,6 +531,7 @@ export function formatQuotaResetTimestamp( const formatLocale = getFormattingLocale(locale); const isZh = formatLocale === 'zh-CN'; const isVi = formatLocale === 'vi'; + // TODO i18n: missing keys for quota reset timestamp formatting try { const resetDate = new Date(timestamp); const now = new Date(); diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 67e00c03..99c7133d 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -171,6 +171,7 @@ const resources = { cancel: 'Cancel', savePreset: 'Save Preset', applyPreset: 'Apply Preset', + deletePreset: 'Delete Preset', }, componentModelSelector: { selectModel: 'Select model', @@ -212,6 +213,14 @@ const resources = { recommended: 'Recommended', allModelsCount: 'All Models ({{count}})', noModelsAvailable: 'No models available', + shadowed: 'Shadowed', + prefixOnly: 'Prefix only', + current: 'Current', + currentValue: 'Current value', + preferredPinnedModel: 'Preferred pinned model:', + pinnedRouteStatus: 'Pinned route status:', + pinnedModelNotAdvertised: + 'Pinned model is not currently advertised by the proxy: {{model}}', }, createAuthProfileDialog: { title: 'Create New Account', @@ -849,6 +858,8 @@ const resources = { }, settingsTabs: { web: 'Web', + image: 'Image', + channels: 'Channels', env: 'Env', think: 'Think', proxy: 'Proxy', @@ -1426,6 +1437,886 @@ const resources = { retryContent: 'Retry content', noMarkdown: 'No markdown content available.', }, + + // ======================================== + // Domain 1: Navigation / Layout / Shared + // ======================================== + heroSection: { + title: 'CCS Config', + subtitle: 'Claude Code Switch Dashboard', + }, + hubFooter: { + logs: 'Logs', + settings: 'Settings', + github: 'GitHub', + copyright: '© {{year}} kaitranntt', + }, + themeToggle: { + srLabel: 'Toggle theme', + }, + ccsLogo: { + alt: 'CCS Logo', + text: 'CCS Config', + }, + claudekitBadge: { + title: 'Powered by ClaudeKit Framework', + alt: 'ClaudeKit', + poweredBy: 'Powered by', + claudekit: 'ClaudeKit', + }, + codeEditor: { + revealSensitive: 'Reveal sensitive values', + maskSensitive: 'Mask sensitive values', + valid: 'Valid {{language}}', + readOnly: '(Read-only)', + }, + commandBuilder: { + title: 'Command Builder', + searchPlaceholder: 'Type or select a command...', + copy: 'Copy', + run: 'Run', + cmdConfig: 'Open configuration interface', + cmdCreateProfile: 'Create a new profile', + cmdSwitchProfile: 'Switch to a profile', + cmdDoctor: 'Check system health', + cmdListProviders: 'List available CLIProxy providers', + cmdAddProvider: 'Add CLIProxy provider', + }, + confirmDialog: { + confirm: 'Confirm', + cancel: 'Cancel', + }, + connectionIndicator: { + connected: 'Connected', + connecting: 'Connecting...', + disconnected: 'Disconnected', + reconnecting: 'Reconnecting...', + }, + docsLink: { + title: 'View documentation', + }, + githubLink: { + title: 'Report an issue on GitHub', + }, + globalEnvIndicator: { + injectedCount_one: '{{count}} global env var will be injected at runtime', + injectedCount_other: '{{count}} global env vars will be injected at runtime', + overriddenCount: '({{count}} overridden by profile)', + skippedLabel: 'Skipped (profile already defines):', + configureInSettings: 'Configure in Settings', + }, + localhostDisclaimer: { + remoteReadonlyAuthDisabledLong: + 'Remote dashboard access is read-only because dashboard auth is currently disabled on the host. Re-enable dashboard auth on the host to unlock remote changes.', + remoteReadonlyAuthDisabledShort: + 'Remote dashboard is read-only until dashboard auth is re-enabled on the host.', + remoteReadonlySetupLong: + 'Remote dashboard access is read-only until you run ccs config auth setup on the host.', + remoteReadonlySetupShort: 'Remote dashboard is read-only until host auth is configured.', + localLong: 'This dashboard runs locally. All data stays on your machine.', + localShort: 'Local dashboard - data stays on your device.', + dismiss: 'Dismiss disclaimer', + }, + privacyToggle: { + modeOn: 'Privacy mode ON - Click to show data', + modeOff: 'Privacy mode OFF - Click to hide data', + }, + projectSelectionDialog: { + title: 'Select Google Cloud Project', + description: 'Choose which project to use for {{provider}} authentication.', + autoSelectCountdown: '(Auto-selecting default in {{count}}s)', + default: 'Default', + allProjects: 'All Projects', + allProjectsDescription: 'Onboard all {{count}} listed projects', + useDefault: 'Use Default', + selecting: 'Selecting...', + confirmSelection: 'Confirm Selection', + codeCopied: 'Code copied', + copyVerificationCode: 'Copy verification code', + }, + quickCommands: { + title: 'Quick Commands', + startDefault: 'Start Default', + startDefaultDesc: 'Launch Claude with default profile', + glmProfile: 'GLM Profile', + glmProfileDesc: 'Switch to GLM model', + healthCheck: 'Health Check', + healthCheckDesc: 'Run system diagnostics', + delegateTask: 'Delegate Task', + delegateTaskDesc: 'Delegate to GLM profile', + }, + quotaTooltip: { + loadingQuota: 'Loading quota...', + failedLoadQuota: 'Failed to load quota', + modelQuotas: 'Model Quotas:', + rateLimits: 'Rate Limits:', + plan: 'Plan: {{plan}}', + quotaSnapshots: 'Quota Snapshots:', + unlimited: 'Unlimited', + remaining: '{{remaining}}/{{entitlement}} remaining', + tier: 'Tier', + tierId: 'Tier ID', + state: 'State', + credits: 'Credits', + modelQuotasLower: 'Model quotas:', + allBucketsReport: 'All buckets report {{tokenType}}', + requestsRemaining: '{{count}} requests remaining', + inputTokensRemaining: '{{count}} input tokens remaining', + outputTokensRemaining: '{{count}} output tokens remaining', + amountRemaining: '{{count}} remaining', + fiveHourLimit: '5h usage limit', + weeklyLimit: 'Weekly usage limit', + weeklyOpus: 'Weekly usage (Opus)', + weeklySonnet: 'Weekly usage (Sonnet)', + weeklyOAuthApps: 'Weekly usage (OAuth apps)', + weeklyCowork: 'Weekly usage (Cowork)', + extraUsage: 'Extra usage', + premiumInteractions: 'Premium Interactions', + chat: 'Chat', + completions: 'Completions', + resets: 'Resets {{time}}', + fiveHourResets: '5h resets {{time}}', + weeklyResets: 'Weekly resets {{time}}', + }, + sponsorButton: { + title: 'Sponsor this project on GitHub', + sponsor: 'Sponsor', + }, + valueMetrics: { + apiCostSaved: 'API Cost Saved', + tokensSaved: 'Tokens Saved', + queriesFaster: 'Queries Faster', + errorsReduced: 'Errors Reduced', + vsLastMonth: 'vs last month', + throughCaching: 'through caching', + averageSpeedup: 'average speedup', + withRetryLogic: 'with retry logic', + performanceMetrics: 'Performance Metrics', + monthlySummary: 'Monthly Summary', + totalSaved: 'Total Saved', + tokensProcessed: 'Tokens Processed', + queriesHandled: 'Queries Handled', + uptime: 'Uptime', + }, + updatesSpotlight: { + openUpdatesCenter: 'Open Updates Center', + }, + deviceCodeDialog: { + authorize: 'Authorize {{provider}}', + enterCodeAtPage: 'Enter the code below at the authorization page.', + expiresIn: '(Expires in {{time}})', + codeExpired: '(Code expired)', + copied: 'Copied!', + copyCode: 'Copy Code', + waitingForAuth: 'Waiting for authorization...', + openVerificationPage: 'Open verification page', + openProviderPage: 'Open {{provider}}', + copyCodeAria: 'Copy verification code', + codeCopiedAria: 'Code copied', + }, + settingsDialog: { + editProfile: 'Edit Profile: {{name}}', + description: 'Configure environment variables and settings for this profile.', + loadingSettings: 'Loading settings...', + envTab: 'Environment', + rawJsonTab: 'Raw JSON', + generalTab: 'General', + noEnvVars: 'No environment variables configured.', + noEnvVarsHint: 'Add variables in your settings.json file.', + loadingEditor: 'Loading editor...', + profileInfo: 'Profile Information', + profileInfoDesc: 'Details about this configuration file.', + path: 'Path', + lastModified: 'Last Modified', + cancel: 'Cancel', + saving: 'Saving...', + saveChanges: 'Save Changes', + conflictTitle: 'File Modified Externally', + conflictDesc: + 'This settings file was modified by another process. Overwrite with your changes or discard?', + overwrite: 'Overwrite', + }, + + // ======================================== + // Domain 2: Accounts / Auth + // ======================================== + setupWizard: { + title: 'Quick Setup Wizard', + stepProviderDesc: 'Select a provider to get started', + stepAuthDesc: 'Authenticate with your provider', + stepAccountDesc: 'Select which account to use', + stepVariantDesc: 'Create your custom variant', + stepSuccessDesc: 'Setup complete!', + authStep: { + authenticateWith: 'Authenticate with {{provider}} to add an account', + authenticating: 'Authenticating...', + authenticateInBrowser: 'Authenticate in Browser', + completeOAuth: 'Complete the OAuth flow in your browser...', + orUseTerminal: 'Or use terminal', + runCommandHint: 'Run this command in your terminal:', + back: 'Back', + checking: 'Checking...', + refreshStatus: 'Refresh Status', + }, + accountStep: { + selectAccount: 'Select an account ({{count}})', + defaultAccount: 'Default account', + or: 'Or', + addNewAccount: 'Add new account', + addNewAccountDesc: 'Authenticate with a different account', + back: 'Back', + }, + variantStep: { + back: 'Back', + skip: 'Skip', + }, + successStep: { + title: 'Variant Created!', + subtitle: 'Your custom variant is ready to use', + usage: 'Usage:', + done: 'Done', + }, + }, + accountSurfaceCard: { + business: 'Biz', + personal: 'Pers', + variant: 'Variant', + }, + accountCardStats: { + notUsedYet: 'Not used yet', + }, + accountQuotaPanel: { + weekly: 'Weekly', + loadingQuota: 'Loading quota...', + }, + userMenu: { + signedInAs: 'Signed in as {{username}}', + }, + authMonitorLive: { + live: 'LIVE', + accountMonitor: 'Account Monitor', + updated: 'Updated {{time}}', + updatedNow: 'Updated now', + requestsLabel: 'req', + stats: 'Stats', + successRate: 'Success Rate', + missingProjectId: 'Missing Project ID', + noActivity: 'no activity', + }, + providerCard: { + missingProjectIdAria: 'Missing Project ID', + }, + loginPage: { + showPassword: 'Show password', + hidePassword: 'Hide password', + }, + + // ======================================== + // Domain 3: CLIProxy / Provider Editor + // ======================================== + cliproxyStatsOverview: { + sessionStatistics: 'Session Statistics', + realTimeMetrics: 'Real-time usage metrics from {{backend}}', + offline: 'Offline', + running: 'Running', + noActiveSession: 'No Active Session', + noActiveSessionHint: + 'Start a CLIProxy session using ccs gemini, ccs codex, or ccs agy to view real-time statistics.', + failedLoadStats: 'Failed to Load Statistics', + totalRequests: 'Total Requests', + successCount: '{{count}} success', + successRate: 'Success Rate', + totalTokens: 'Total Tokens', + estimatedCost: '~${{cost}} estimated', + modelsUsed: 'Models Used', + modelUsageDistribution: 'Model Usage Distribution', + requestCount: '{{count}} requests', + }, + cliproxyTable: { + name: 'Name', + provider: 'Provider', + model: 'Model', + account: 'Account', + status: 'Status', + default: 'Default', + actions: 'Actions', + }, + cliproxyTabs: { + overview: 'Overview', + variants: 'Variants', + aiProviders: 'AI Providers', + controlPanel: 'Control Panel', + }, + cliproxyHeader: { + ccsLevelAccountManagement: 'CCS-level account management', + cliproxyNotAvailable: 'CLIProxy Not Available', + cliproxyControlPanel: 'CLIProxy Control Panel', + noVariants: 'No CLIProxy variants found.', + addAccountToStart: 'Add an account to get started', + }, + routingGuidance: { + roundRobin: 'Round robin spreads usage.', + fillFirst: 'Fill first keeps backup accounts cold until they are needed.', + routingStrategy: 'Routing strategy', + optionalRouting: 'Optional routing', + }, + extendedContext: { + extendedContext: 'Extended Context', + }, + cliproxyConfig: { + unsavedChanges: 'Unsaved changes', + original: 'Original', + modified: 'Modified', + reviewChanges: 'Review Changes', + loadingEditor: 'Loading editor...', + }, + providerEditor: { + provider: 'Provider', + filePath: 'File Path', + lastModified: 'Last Modified', + defaultTarget: 'Default Target', + quickUsage: 'Quick Usage', + modelMapping: 'Model Mapping', + status: 'Status', + loadingSettings: 'Loading settings...', + loadingEditor: 'Loading editor...', + noAccountsConnected: 'No accounts connected', + addAccountToStart: 'Add an account to get started', + gcpProjectIdReadonly: 'GCP Project ID (read-only)', + projectIdNA: 'Project ID: N/A', + missingProjectId: 'Missing Project ID', + missingProjectIdHint: + 'This may cause errors. Remove the account and re-add it to fetch the project ID.', + useIncognito: 'Use incognito', + aliases: 'Aliases', + current: 'Current', + currentValue: 'Current value', + composite: 'composite', + defaultLabel: 'default', + requiredSetup: 'Required setup', + connectorName: 'Connector Name', + proxyUrl: 'Proxy URL', + proxyUrlSet: 'Proxy URL set', + excludedModels: 'Excluded Models', + headers: 'Headers', + secret: 'Secret', + prefix: 'Prefix', + modelMappings: 'Model Mappings', + baseUri: 'Base URL', + apiKeys: 'API Keys', + presets: 'Apply pre-configured model mappings', + createVariant: 'Create CLIProxy Variant', + agyDenylist: 'Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.', + }, + providerEditorAccountItem: { + modelsUsed: 'Models Used', + }, + bulkActionBar: { + applyPreset: 'Apply preset', + }, + modelConfigSection: { + defaultModel: 'Default Model', + }, + rawEditorSection: { + rawConfig: 'Raw Configuration', + }, + providerEditorHeader: { + connectorName: 'Connector Name', + }, + aiProvidersFamilyRail: { + current: 'Current', + }, + aiProvidersEntryCard: { + apiKeys: 'API Keys', + }, + aiProvidersEntryDialog: { + connectorName: 'Connector Name', + baseUri: 'Base URL', + proxyUrl: 'Proxy URL', + secret: 'Secret', + prefix: 'Prefix', + excludedModels: 'Excluded Models', + headers: 'Headers', + modelMappings: 'Model Mappings', + requiredSetup: 'Required setup', + optionalRouting: 'Optional routing', + }, + + // ======================================== + // Domain 4: Compatible CLI Tabs + // ======================================== + codex: { + controlCenter: 'Control Center', + overview: 'Overview', + docs: 'Docs', + nativeCodexRuntime: 'Native Codex Runtime', + ccsCodexProvider: 'CCS Codex provider / bridge', + codexDocs: 'Codex docs', + supportedFlows: 'Supported flows', + twoSupportedPaths: 'Two supported paths:', + nativeLabel: 'Native:', + nativeDesc: 'Codex is a first-class, runtime-only target in CCS v1.', + ccsBridge: 'CCS Bridge', + apiProfilesDefault: 'API profiles continue to default to Claude or Droid.', + recommendedSetupFlow: 'Recommended setup flow', + fastestPath: 'Fastest path', + officialChannels: 'Official Channels', + codexCli: 'Codex CLI', + openNativeCodex: 'Open native Codex', + runBuiltInCodex: 'Run built-in Codex on Codex', + runBuiltInCodexExplicit: 'Run built-in Codex on Codex (explicit)', + openCodexDashboard: 'Open Codex dashboard', + status: 'Status', + profiles: 'Profiles', + createNewProfile: 'Create new profile', + createNewProvider: 'Create new provider', + createNewMcpServer: 'Create new MCP server', + defaultProvider: 'Default provider', + useDefault: 'Use default', + useGlobalProvider: 'Use global provider', + useProviderDefault: 'Use provider default', + quickFillWarning: 'Quick-fill only. Review before saving.', + thisFileUpstreamOwned: 'This file is upstream-owned by Codex CLI.', + notes: 'Notes', + approvalPolicy: 'Approval policy', + sandboxMode: 'Sandbox mode', + reasoningEffort: 'Reasoning effort', + useGlobalEffort: 'Use global effort', + reasoningEffortCapitalized: 'Reasoning Effort', + thinkingBudgetTokens: 'Thinking Budget Tokens', + modelContextWindow: 'Model context window', + autoCompactTokenLimit: 'Auto-compact token limit', + toolOutputTokenLimit: 'Tool output token limit', + webSearch: 'Web search', + personality: 'Personality', + model: 'Model', + rawOnly: 'Raw only', + trusted: 'trusted', + untrusted: 'untrusted', + noProjectTrustEntries: 'No explicit project trust entries saved.', + codexNativeRecipe: 'Saved native Codex recipe', + gptContextCap: 'GPT-5.4 context cap', + usageLimitCost: 'Usage-limit cost above 272K', + longContextOverride: 'Long context override', + counts2x: 'Counts 2x', + normalUsageWindow: 'Normal usage window', + useCodexDefault: 'Use Codex default', + stdio: 'stdio', + streamableHttp: 'streamable-http', + responses: 'responses', + defaultTargetCli: 'Default Target CLI', + executionChain: 'Execution chain', + targetPath: 'Current target path', + userConfig: 'User config', + configYaml: 'config.yaml', + flow: 'Flow', + docsTab: 'Docs', + }, + droidSettings: { + quickControls: 'Quick Controls', + reasoningControls: 'Reasoning Controls', + thinkingBudget: 'Thinking Budget', + anthropicOnly: 'Anthropic models only', + byokCustomModels: 'BYOK Custom Models', + }, + rawJsonSettingsEditor: { + title: 'Raw Settings Editor', + }, + copilotConfigForm: { + copilotConfiguration: 'Copilot Configuration', + deprecatedModels: 'Deprecated Copilot models detected', + failedLoadStatus: 'Failed to load status', + useWithClaudeCode: 'Use your GitHub Copilot subscription with Claude Code', + githubCopilotControls: 'GitHub Copilot controls prompt/context limits upstream.', + provider: 'Provider', + filePath: 'File Path', + status: 'Status', + enabled: 'Enabled', + disabled: 'Disabled', + loadingEditor: 'Loading editor...', + modelMapping: 'Model Mapping', + quickUsage: 'Quick Usage', + noPremiumUsage: 'No premium usage count', + }, + copilotPresets: { + gpt5Codex: 'GPT-5.3 Codex', + claude46: 'Claude 4.6', + gemini3: 'Gemini 3', + }, + + // ======================================== + // Domain 5: Logs / Monitoring / Health / Analytics + // ======================================== + healthCard: { + allSystemsNominal: 'All Systems Nominal', + machineChecks: 'Machine checks', + }, + analyticsCards: { + cacheCost: 'Cache Cost', + hitRate: 'Hit Rate', + inputOutputRatio: 'Input/Output Ratio', + noCacheData: 'No cache data available', + noModelData: 'No model data available', + noSessionData: 'No session data available', + noTokenData: 'No token data available', + totalCost: 'Total Cost', + totalTokens: 'Total Tokens', + usageInsights: 'Usage Insights', + }, + dateRangeFilter: { + pickADate: 'Pick a date', + }, + logsConfig: { + level: 'Lvl', + message: 'Message', + source: 'Source', + time: 'Time', + proc: 'Proc', + open: 'Open', + run: 'Run', + refreshEntries: 'Refresh Entries', + }, + logsDetailPanel: { + details: 'Details', + }, + logsFilters: { + filters: 'Filters', + }, + logsOverviewCards: { + overview: 'Overview', + }, + logsPageSkeleton: { + loadingLogs: 'Loading logs...', + }, + monitoringErrorLogs: { + logContent: 'Log Content', + }, + analyticsPages: { + chartsGrid: 'Charts', + costByModel: 'Cost by Model', + }, + + // ======================================== + // Domain 6: Hooks / Toasts / Error Transport + // ======================================== + toasts: { + profileCreated: 'Profile created successfully', + profileUpdated: 'Profile updated successfully', + profileDeleted: 'Profile deleted successfully', + orphanProfilesComplete: 'Orphan profiles registration complete', + profileCopied: 'Profile copied successfully', + profileImported: 'Profile imported successfully', + authRequired: '{{provider}} authorization required', + authSuccess: '{{provider}} authentication successful!', + authFailed: '{{provider}} authentication failed', + deviceCodeExpired: 'Device code expired. Please try again.', + codeCopied: 'Code copied to clipboard', + failedCopy: 'Failed to copy code', + configSaved: 'Configuration saved successfully', + configSaveFailed: 'Failed to save: {{error}}', + invalidYaml: 'Cannot save invalid YAML', + configUpdatedExternally: 'Configuration updated externally', + settingsFileUpdated: 'Settings file updated', + accountsUpdated: 'Accounts updated', + noProfilesToSync: 'No profiles to sync', + syncFailed: 'Sync failed: {{error}}', + providerAuthSuccess: '{{provider}} authentication successful', + providerDeviceCodeInCallback: 'Provider returned Device Code flow in callback mode', + loggingConfigSaved: 'Logging configuration saved.', + loggingConfigSaveFailed: 'Failed to save logging configuration.', + unifiedConfigUpdated: 'Configuration updated successfully', + migrationPreviewComplete: 'Migration preview completed', + migrationComplete: 'Migration completed successfully', + migrationFailed: 'Migration failed', + rollbackComplete: 'Rollback completed successfully', + rollbackFailed: 'Rollback failed', + defaultAccountSet: 'Default account set to "{{name}}"', + defaultAccountReset: 'Default account reset to CCS', + accountDeleted: 'Account "{{name}}" deleted', + contextUpdated: 'Updated "{{name}}" context to {{summary}}', + legacyConfirmError: + 'Account "{{name}}" needs explicit confirmation. Use Edit History Sync on this account.', + legacyConfirmFailed: 'Legacy account "{{name}}" failed confirmation: {{error}}', + legacyConfirmSuccess_one: '{{count}} legacy account confirmed', + legacyConfirmSuccess_other: '{{count}} legacy accounts confirmed', + noLegacyAccounts: 'No legacy accounts need confirmation', + routingStrategySet: 'Routing strategy set to {{strategy}}', + variantCreated: 'Variant created successfully', + variantUpdated: 'Variant updated successfully', + variantDeleted: 'Variant deleted successfully', + defaultAccountUpdated: 'Default account updated', + accountRemoved: 'Account removed', + accountPaused: 'Account paused', + accountResumed: 'Account resumed', + accountAdded: 'Account added for {{provider}}', + kiroImported: 'Imported Kiro account: {{name}}', + kiroTokenImported: 'Kiro token imported', + modelUpdated: 'Model updated', + presetSaved: 'Preset "{{name}}" saved', + presetDeleted: 'Preset deleted', + cliproxyAlreadyRunning: 'CLIProxy was already running', + cliproxyStarted: 'CLIProxy started successfully', + cliproxyStartFailed: 'Failed to start CLIProxy', + cliproxyStopped: 'CLIProxy stopped', + cliproxyStopFailed: 'Failed to stop CLIProxy', + presetApplied: 'Applied "{{name}}" preset', + presetAppliedCustom: 'Applied custom preset', + settingsSavedWithAdjustments: 'Settings saved with model adjustments', + settingsSaved: 'Settings saved', + failedSaveSettings: 'Failed to save settings', + codexRefreshFailed: 'Failed to refresh Codex snapshot. Raw edits were kept.', + codexRefreshError: 'Failed to refresh Codex snapshot.', + codexFixToml: 'Fix TOML before saving.', + codexSaved: 'Saved Codex config.toml.', + codexChangedExternally: 'config.toml changed externally. Refresh and retry.', + codexSaveFailed: 'Failed to save Codex config.toml.', + codexUpdateFailed: 'Failed to update Codex config.', + noOrphanProfiles: 'No orphan profile settings found', + profilesRegistered: 'Registered {{count}} profile(s){{skipped}}', + destinationEmpty: 'Destination profile name cannot be empty', + profileExportDownloaded: 'Profile export downloaded', + profileImportFailed: 'Failed to import profile bundle', + }, + + // ======================================== + // Domain 7: Profiles / Settings / Pages + // ======================================== + profileEditorSections: { + imageAnalysis: 'Image Analysis', + loadingImageSettings: 'Loading image settings...', + skipPermissionPrompts: 'Skip permission prompts on launch', + useNativeImageReading: 'Use native image reading', + skipTransformer: 'Skip transformer', + friendlyUi: 'Friendly UI', + info: 'Info', + }, + imageAnalysisStatus: { + sectionTitle: 'Image', + openSettings: 'Open Settings', + useNativeImageReading: 'Use native image reading', + refreshingPreview: 'Refreshing preview', + savedStatus: 'Saved status', + livePreview: 'Live preview', + disabledGlobally: 'Disabled globally', + targetBypassesHook: '{{target}} bypasses the hook', + nativeImageReading: 'Native image reading', + setupNeeded: 'Setup needed', + needsAuth: 'Needs auth', + needsProxy: 'Needs proxy', + nativeFallback: 'Native fallback', + transformerReady: 'Transformer ready', + badgeDisabled: 'Disabled', + badgeBypassed: 'Bypassed', + badgeNative: 'Native', + badgeSetup: 'Setup', + badgeAuth: 'Auth', + badgeProxy: 'Proxy', + badgeReady: 'Ready', + capabilityVerified: 'Verified', + capabilityUnknown: 'Unknown', + toggleSummaryNativeCapable: + '{{model}} looks image-ready. CCS will bypass the transformer here.', + toggleSummaryNativeModel: 'CCS will prefer native reading for {{model}}.', + toggleSummaryNativeDefault: 'CCS will prefer native image reading for this profile.', + toggleSummaryNativeFileAccess: 'This profile currently stays on native file access.', + toggleSummaryInactiveTarget: + 'Saved Claude-side image routing is inactive while {{target}} is selected.', + toggleSummaryTransformerRoute: 'Transformer route: {{backend}}{{modelSuffix}}.', + noteDisabledGlobally: 'Image is disabled globally in CCS settings.', + noteTargetBypassesHook: 'Current target {{target}} bypasses the Claude Read hook.', + notePersistHook: 'Persist the profile hook before transformer routing can run here.', + targetLabel: { + claude: 'Claude Code', + droid: 'Factory Droid', + codex: 'Codex CLI', + }, + }, + openrouterBadge: { + new: 'NEW', + integration: 'OpenRouter Integration', + }, + openrouterBanner: { + accessModels: 'Access {{count}}+ models via OpenRouter', + add: 'Add', + }, + openrouterModelPicker: { + searchModels: 'Search Models', + newestModels: 'Newest Models', + }, + openrouterPromoCard: { + title: 'OpenRouter', + description: 'Access hundreds of models from one API endpoint.', + }, + profileCard: { + profile: 'Profile', + openRouter: 'OpenRouter profile', + claudeCode: 'Claude Code', + claudeCodeDefault: 'Claude Code (default)', + factoryDroid: 'Factory Droid', + codexCli: 'Codex CLI', + ccsProfile: 'CCS profile', + }, + profileDeck: { + profiles: 'Profiles', + failedToLoad: 'Failed to load profiles: {{message}}', + noProfiles: 'No profiles configured. Create your first profile to get started.', + }, + profilesTable: { + name: 'Name', + provider: 'Provider', + model: 'Model', + target: 'Target', + lastModified: 'Last Modified', + actions: 'Actions', + edit: 'Edit', + }, + profileCreateDialog: { + createProfile: 'Create Profile', + appliedModelToTiers: 'Applied "{{model}}" to all model tiers', + profileCreated: 'Profile "{{name}}" created', + failedCreate: 'Failed to create profile', + chooseProviderHint: 'Choose a provider preset or configure a custom API endpoint.', + basicInformation: 'Basic Information', + modelConfiguration: 'Model Configuration', + usedInCli: 'Used in CLI:', + apiBaseUrl: 'API Base URL', + baseUrlPlaceholder: 'https://api.example.com/v1', + prefilledFromPreset: 'Pre-filled from {{name}}. You can customize if needed.', + optionalForPreset: 'Optional for {{name}}. Leave blank to use native Anthropic auth.', + endpointHint: 'The endpoint that accepts OpenAI-compatible and Anthropic requests', + optional: '(optional)', + apiKeyOptionalPlaceholder: 'Optional - only if auth is enabled', + apiKeyPlaceholder: 'sk-...', + apiKeyOptionalHint: 'Only needed if your local endpoint has authentication enabled', + defaultTargetCli: 'Default Target CLI', + modelMapping: 'Model Mapping', + modelMappingDesc: + 'Map Claude Code tiers (Opus/Sonnet/Haiku) to models supported by your provider.', + searchModelsPlaceholder: 'Type to search (e.g., opus, sonnet, gpt-4o)...', + noModelsFound: 'No models found for "{{query}}"', + loadingModels: 'Loading models...', + defaultModel: 'Default Model', + sonnetMapping: 'Sonnet Mapping', + opusMapping: 'Opus Mapping', + haikuMapping: 'Haiku Mapping', + sonnetMappingPlaceholder: 'e.g. gpt-4o, claude-sonnet-4', + opusMappingPlaceholder: 'e.g. o1, claude-opus-4.5', + haikuMappingPlaceholder: 'e.g. gpt-4o-mini, claude-3.5-haiku', + free: 'Free', + }, + profileDialogLegacy: { + editProfile: 'Edit Profile', + }, + supportEntryCard: { + actionRequired: 'Action Required', + }, + settingsPage: { + title: 'Settings', + loading: 'Loading...', + failedLoad: 'Failed to load settings.', + tabs: { + web: 'Web', + env: 'Env', + think: 'Think', + proxy: 'Proxy', + auth: 'Auth', + backup: 'Backup', + channels: 'Channels', + imageAnalysis: 'Image', + }, + websearchSection: { + title: 'Web Search', + description: 'CLI-based web search configuration.', + }, + thinkingSection: { + title: 'Thinking', + description: 'Configure extended thinking/reasoning for supported models.', + directOverride: 'Direct override', + youType: 'You type:', + ccsAdds: 'CCS adds:', + executionChain: 'Execution chain', + primaryBackends: 'Primary backends', + legacyCliFallbacks: 'Legacy CLI fallbacks', + managedPayload: 'Managed payload', + sharedTargetMetadata: 'Shared target metadata', + ideTargetMetadata: 'IDE target metadata', + ideSettingsPath: 'IDE settings path', + ideHost: 'IDE host', + resolvedBinding: 'Resolved binding', + bindingName: 'Binding name', + inSync: 'In sync', + currentTargetPath: 'Current target path', + warnings: 'Warnings', + notes: 'Notes', + workspacePresets: 'Workspace presets', + draft: 'Draft', + advanced: 'Advanced', + recommended: 'Recommended setup flow', + configureModelFirst: 'Configure a model first', + }, + proxySection: { + title: 'Proxy', + loadingImageSettings: 'Loading image settings...', + }, + channelsSection: { + title: 'Official Channels', + description: 'View and manage official release channels.', + }, + imageAnalysisSection: { + title: 'Image Analysis', + description: 'Configure image analysis settings.', + loading: 'Loading image settings...', + }, + }, + codexPage: { + title: 'Codex', + controlCenter: 'Control Center', + overview: 'Overview', + docs: 'Docs', + nativeRuntime: 'Native Runtime', + ccsProvider: 'CCS Provider', + setup: 'Setup', + }, + apiPage: { + title: 'API Profiles', + subtitle: 'Manage your API profiles and endpoints.', + }, + claudeExtensionPage: { + title: 'Claude Extension', + subtitle: 'Claude Extension integration settings.', + claudeAuth: 'Claude auth', + status: 'Status', + targetMetadata: 'IDE target metadata', + }, + sharedPageV2: { + title: 'Shared', + subtitle: 'Shared data management.', + }, + homePageV2: { + title: 'Home', + logsMoved: 'Logs moved to a dedicated workspace', + profiles: 'Profiles', + cliproxy: 'CLIProxy', + accounts: 'Accounts', + health: 'Health', + }, + analyticsPageV2: { + title: 'Analytics', + subtitle: 'Usage analytics and insights.', + }, + logsPageV2: { + title: 'Logs', + subtitle: 'View and manage system logs.', + }, + healthPageV2: { + title: 'Health', + subtitle: 'System health monitoring.', + }, + aiProvidersPage: { + title: 'AI Providers', + subtitle: 'Manage AI provider configurations.', + unableToLoad: 'Unable to load AI Providers', + }, }, }, 'zh-CN': { @@ -1458,6 +2349,7 @@ const resources = { factoryDroid: 'Factory Droid', system: '系统', health: '健康', + logs: '日志', settings: '设置', openrouterTooltip: '精选:OpenRouter + Alibaba Coding Plan + Ollama', }, @@ -1587,6 +2479,7 @@ const resources = { cancel: '取消', savePreset: '保存预设', applyPreset: '应用预设', + deletePreset: '删除预设', }, componentModelSelector: { selectModel: '选择模型', @@ -1628,6 +2521,13 @@ const resources = { recommended: '推荐', allModelsCount: '全部模型({{count}})', noModelsAvailable: '暂无可用模型', + shadowed: '已遮蔽', + prefixOnly: '仅前缀', + current: '当前', + currentValue: '当前值', + preferredPinnedModel: '偏好的固定模型:', + pinnedRouteStatus: '固定路由状态:', + pinnedModelNotAdvertised: '固定模型当前未被代理广播:{{model}}', }, createAuthProfileDialog: { title: '创建新账号', @@ -2213,6 +3113,8 @@ const resources = { }, settingsTabs: { web: '网页', + image: '图片', + channels: '频道', env: '环境', think: '思考', proxy: '代理', @@ -2775,6 +3677,850 @@ const resources = { retryContent: '重试内容加载', noMarkdown: '暂无 Markdown 内容。', }, + heroSection: { + title: 'CCS Config', + subtitle: 'Claude Code Switch Dashboard', + }, + hubFooter: { + logs: '日志', + settings: '设置', + github: 'GitHub', + copyright: '© {{year}} kaitranntt', + }, + themeToggle: { + srLabel: '切换主题', + }, + ccsLogo: { + alt: 'CCS Logo', + text: 'CCS Config', + }, + claudekitBadge: { + title: 'Powered by ClaudeKit Framework', + alt: 'ClaudeKit', + poweredBy: 'Powered by', + claudekit: 'ClaudeKit', + }, + codeEditor: { + revealSensitive: '显示敏感值', + maskSensitive: '隐藏敏感值', + valid: '有效的 {{language}}', + readOnly: '(只读)', + }, + commandBuilder: { + title: '命令构建器', + searchPlaceholder: '输入或选择命令...', + copy: '复制', + run: '运行', + cmdConfig: '打开配置界面', + cmdCreateProfile: '创建新配置', + cmdSwitchProfile: '切换到指定配置', + cmdDoctor: '检查系统健康', + cmdListProviders: '列出可用 CLIProxy 提供商', + cmdAddProvider: '添加 CLIProxy 提供商', + }, + confirmDialog: { + confirm: '确认', + cancel: '取消', + }, + connectionIndicator: { + connected: '已连接', + connecting: '连接中...', + disconnected: '已断开', + reconnecting: '重新连接中...', + }, + docsLink: { + title: '查看文档', + }, + githubLink: { + title: '在 GitHub 上报告问题', + }, + globalEnvIndicator: { + injectedCount_one: '{{count}} 个全局环境变量将在运行时注入', + injectedCount_other: '{{count}} 个全局环境变量将在运行时注入', + overriddenCount: '({{count}} 个已被配置覆盖)', + skippedLabel: '已跳过(配置中已定义):', + configureInSettings: '在设置中配置', + }, + localhostDisclaimer: { + remoteReadonlyAuthDisabledLong: + '远程控制台当前为只读,因为主机未启用控制台认证。请在主机上重新启用控制台认证以解锁远程编辑。', + remoteReadonlyAuthDisabledShort: '远程控制台为只读,直到主机重新启用控制台认证。', + remoteReadonlySetupLong: '远程控制台为只读,直到在主机上运行 ccs config auth setup。', + remoteReadonlySetupShort: '远程控制台为只读,直到完成主机认证配置。', + localLong: '本控制台在本地运行,所有数据保留在本机。', + localShort: '本地控制台 - 数据保留在本机。', + dismiss: '关闭提示', + }, + privacyToggle: { + modeOn: '隐私模式已开启 - 点击显示数据', + modeOff: '隐私模式已关闭 - 点击隐藏数据', + }, + projectSelectionDialog: { + title: '选择 Google Cloud 项目', + description: '选择用于 {{provider}} 认证的项目。', + autoSelectCountdown: '({{count}} 秒后自动选择默认值)', + default: '默认', + allProjects: '全部项目', + allProjectsDescription: '接入全部 {{count}} 个已列出的项目', + useDefault: '使用默认值', + selecting: '选择中...', + confirmSelection: '确认选择', + codeCopied: '验证码已复制', + copyVerificationCode: '复制验证码', + }, + quickCommands: { + title: '快捷命令', + startDefault: '启动默认', + startDefaultDesc: '使用默认配置启动 Claude', + glmProfile: 'GLM 配置', + glmProfileDesc: '切换到 GLM 模型', + healthCheck: '健康检查', + healthCheckDesc: '运行系统诊断', + delegateTask: '委托任务', + delegateTaskDesc: '委托到 GLM 配置', + }, + quotaTooltip: { + loadingQuota: '加载配额中...', + failedLoadQuota: '加载配额失败', + modelQuotas: '模型配额:', + rateLimits: '速率限制:', + plan: '计划:{{plan}}', + quotaSnapshots: '配额快照:', + unlimited: '无限制', + remaining: '剩余 {{remaining}}/{{entitlement}}', + tier: '档位', + tierId: '档位 ID', + state: '状态', + credits: '额度', + modelQuotasLower: '模型配额:', + allBucketsReport: '所有桶报告 {{tokenType}}', + requestsRemaining: '剩余 {{count}} 次请求', + inputTokensRemaining: '剩余 {{count}} 个输入 token', + outputTokensRemaining: '剩余 {{count}} 个输出 token', + amountRemaining: '剩余 {{count}}', + fiveHourLimit: '5 小时用量限制', + weeklyLimit: '每周用量限制', + weeklyOpus: '每周用量(Opus)', + weeklySonnet: '每周用量(Sonnet)', + weeklyOAuthApps: '每周用量(OAuth 应用)', + weeklyCowork: '每周用量(Cowork)', + extraUsage: '额外用量', + premiumInteractions: '高级交互次数', + chat: '对话', + completions: '补全', + resets: '重置于 {{time}}', + fiveHourResets: '5 小时重置于 {{time}}', + weeklyResets: '每周重置于 {{time}}', + }, + sponsorButton: { + title: '在 GitHub 上赞助此项目', + sponsor: '赞助', + }, + valueMetrics: { + apiCostSaved: 'API 成本节省', + tokensSaved: 'Token 节省', + queriesFaster: '查询加速', + errorsReduced: '错误减少', + vsLastMonth: '对比上月', + throughCaching: '通过缓存', + averageSpeedup: '平均加速', + withRetryLogic: '通过重试逻辑', + performanceMetrics: '性能指标', + monthlySummary: '月度概览', + totalSaved: '总节省', + tokensProcessed: '处理 Token 数', + queriesHandled: '处理请求数', + uptime: '正常运行时间', + }, + updatesSpotlight: { + openUpdatesCenter: '打开更新中心', + }, + deviceCodeDialog: { + authorize: '授权 {{provider}}', + enterCodeAtPage: '在授权页面输入下方验证码。', + expiresIn: '({{time}} 后过期)', + codeExpired: '(验证码已过期)', + copied: '已复制!', + copyCode: '复制验证码', + waitingForAuth: '等待授权中...', + openVerificationPage: '打开验证页面', + openProviderPage: '打开 {{provider}}', + copyCodeAria: '复制验证码', + codeCopiedAria: '验证码已复制', + }, + settingsDialog: { + editProfile: '编辑配置:{{name}}', + description: '为此配置设置环境变量和其他设置。', + loadingSettings: '加载设置中...', + envTab: '环境变量', + rawJsonTab: '原始 JSON', + generalTab: '常规', + noEnvVars: '尚未配置环境变量。', + noEnvVarsHint: '在 settings.json 文件中添加变量。', + loadingEditor: '加载编辑器中...', + profileInfo: '配置信息', + profileInfoDesc: '此配置文件的详细信息。', + path: '路径', + lastModified: '最后修改', + cancel: '取消', + saving: '保存中...', + saveChanges: '保存更改', + conflictTitle: '文件被外部修改', + conflictDesc: '此设置文件已被其他进程修改。用你的更改覆盖还是丢弃?', + overwrite: '覆盖', + }, + setupWizard: { + title: '快速设置向导', + stepProviderDesc: '选择一个提供商开始', + stepAuthDesc: '完成提供商认证', + stepAccountDesc: '选择要使用的账号', + stepVariantDesc: '创建自定义变体', + stepSuccessDesc: '设置完成!', + authStep: { + authenticateWith: '通过 {{provider}} 认证以添加账号', + authenticating: '认证中...', + authenticateInBrowser: '在浏览器中认证', + completeOAuth: '在浏览器中完成 OAuth 流程...', + orUseTerminal: '或使用终端', + runCommandHint: '在终端中运行此命令:', + back: '返回', + checking: '检查中...', + refreshStatus: '刷新状态', + }, + accountStep: { + selectAccount: '选择账号({{count}})', + defaultAccount: '默认账号', + or: '或', + addNewAccount: '添加新账号', + addNewAccountDesc: '使用其他账号认证', + back: '返回', + }, + variantStep: { + back: '返回', + skip: '跳过', + }, + successStep: { + title: '变体已创建!', + subtitle: '你的自定义变体已准备就绪', + usage: '用法:', + done: '完成', + }, + }, + accountSurfaceCard: { + business: '企业', + personal: '个人', + variant: '变体', + }, + accountCardStats: { + notUsedYet: '尚未使用', + }, + accountQuotaPanel: { + weekly: '每周', + loadingQuota: '加载配额中...', + }, + userMenu: { + signedInAs: '已登录为 {{username}}', + }, + authMonitorLive: { + live: '实时', + accountMonitor: '账号监控', + updated: '更新于 {{time}}', + updatedNow: '刚刚更新', + requestsLabel: '请求', + stats: '统计', + successRate: '成功率', + missingProjectId: '缺少项目 ID', + noActivity: '无活动', + }, + providerCard: { + missingProjectIdAria: '缺少项目 ID', + }, + loginPage: { + showPassword: '显示密码', + hidePassword: '隐藏密码', + }, + cliproxyStatsOverview: { + sessionStatistics: '会话统计', + realTimeMetrics: '来自 {{backend}} 的实时使用指标', + offline: '离线', + running: '运行中', + noActiveSession: '无活跃会话', + noActiveSessionHint: + '使用 ccs gemini、ccs codex 或 ccs agy 启动 CLIProxy 会话后即可查看实时统计。', + failedLoadStats: '加载统计数据失败', + totalRequests: '总请求数', + successCount: '{{count}} 次成功', + successRate: '成功率', + totalTokens: '总 Token 数', + estimatedCost: '预估 ${{cost}}', + modelsUsed: '使用模型数', + modelUsageDistribution: '模型使用分布', + requestCount: '{{count}} 次请求', + }, + cliproxyTable: { + name: '名称', + provider: '提供商', + model: '模型', + account: '账号', + status: '状态', + default: '默认', + actions: '操作', + }, + cliproxyTabs: { + overview: '概览', + variants: '变体', + aiProviders: 'AI 提供商', + controlPanel: '控制面板', + }, + cliproxyHeader: { + ccsLevelAccountManagement: 'CCS 级账号管理', + cliproxyNotAvailable: 'CLIProxy 不可用', + cliproxyControlPanel: 'CLIProxy 控制面板', + noVariants: '未找到 CLIProxy 变体。', + addAccountToStart: '添加账号以开始', + }, + routingGuidance: { + roundRobin: '轮询模式均匀分配用量。', + fillFirst: '优先填满模式让备用账号保持冷启动直到需要时。', + routingStrategy: '路由策略', + optionalRouting: '可选路由', + }, + extendedContext: { + extendedContext: '扩展上下文', + }, + cliproxyConfig: { + unsavedChanges: '未保存的更改', + original: '原始值', + modified: '已修改', + reviewChanges: '查看更改', + loadingEditor: '加载编辑器中...', + }, + providerEditor: { + provider: '提供商', + filePath: '文件路径', + lastModified: '最后修改', + defaultTarget: '默认目标', + quickUsage: '快速使用', + modelMapping: '模型映射', + status: '状态', + loadingSettings: '加载设置中...', + loadingEditor: '加载编辑器中...', + noAccountsConnected: '未连接账号', + addAccountToStart: '添加账号以开始', + gcpProjectIdReadonly: 'GCP 项目 ID(只读)', + projectIdNA: '项目 ID:无', + missingProjectId: '缺少项目 ID', + missingProjectIdHint: '可能导致错误。请移除该账号并重新添加以获取项目 ID。', + useIncognito: '使用隐身模式', + aliases: '别名', + current: '当前', + currentValue: '当前值', + composite: '复合', + defaultLabel: '默认', + requiredSetup: '必要设置', + connectorName: '连接器名称', + proxyUrl: '代理 URL', + proxyUrlSet: '代理 URL 已设置', + excludedModels: '排除模型', + headers: '请求头', + secret: '密钥', + prefix: '前缀', + modelMappings: '模型映射', + baseUri: 'Base URL', + apiKeys: 'API Key', + presets: '应用预设模型映射', + createVariant: '创建 CLIProxy 变体', + agyDenylist: 'Antigravity 禁用列表:Claude Opus 4.5 和 Claude Sonnet 4.5 已弃用。', + }, + providerEditorAccountItem: { + modelsUsed: '使用模型数', + }, + bulkActionBar: { + applyPreset: '应用预设', + }, + modelConfigSection: { + defaultModel: '默认模型', + }, + rawEditorSection: { + rawConfig: '原始配置', + }, + providerEditorHeader: { + connectorName: '连接器名称', + }, + aiProvidersFamilyRail: { + current: '当前', + }, + aiProvidersEntryCard: { + apiKeys: 'API Key', + }, + aiProvidersEntryDialog: { + connectorName: '连接器名称', + baseUri: 'Base URL', + proxyUrl: '代理 URL', + secret: '密钥', + prefix: '前缀', + excludedModels: '排除模型', + headers: '请求头', + modelMappings: '模型映射', + requiredSetup: '必要设置', + optionalRouting: '可选路由', + }, + codex: { + controlCenter: '控制中心', + overview: '概览', + docs: '文档', + nativeCodexRuntime: '原生 Codex 运行时', + ccsCodexProvider: 'CCS Codex 提供商 / 桥接', + codexDocs: 'Codex 文档', + supportedFlows: '支持的工作流', + twoSupportedPaths: '两种支持路径:', + nativeLabel: '原生:', + nativeDesc: 'Codex 是 CCS v1 的一等公民运行时目标。', + ccsBridge: 'CCS 桥接', + apiProfilesDefault: 'API 配置默认仍使用 Claude 或 Droid。', + recommendedSetupFlow: '推荐设置流程', + fastestPath: '最快路径', + officialChannels: '官方渠道', + codexCli: 'Codex CLI', + openNativeCodex: '打开原生 Codex', + runBuiltInCodex: '在 Codex 上运行内置命令', + runBuiltInCodexExplicit: '在 Codex 上运行内置命令(显式)', + openCodexDashboard: '打开 Codex Dashboard', + status: '状态', + profiles: '配置', + createNewProfile: '创建新配置', + createNewProvider: '创建新提供商', + createNewMcpServer: '创建新 MCP 服务器', + defaultProvider: '默认提供商', + useDefault: '使用默认值', + useGlobalProvider: '使用全局提供商', + useProviderDefault: '使用提供商默认值', + quickFillWarning: '快速填充内容仅供参考,保存前请仔细检查。', + thisFileUpstreamOwned: '此文件由 Codex CLI 上游维护。', + notes: '备注', + approvalPolicy: '审批策略', + sandboxMode: '沙盒模式', + reasoningEffort: '推理强度', + useGlobalEffort: '使用全局强度', + reasoningEffortCapitalized: '推理强度', + thinkingBudgetTokens: '思考预算 Token', + modelContextWindow: '模型上下文窗口', + autoCompactTokenLimit: '自动压缩 Token 上限', + toolOutputTokenLimit: '工具输出 Token 上限', + webSearch: '网页搜索', + personality: '人格设定', + model: '模型', + rawOnly: '仅原始', + trusted: '受信任', + untrusted: '不受信任', + noProjectTrustEntries: '没有已保存的项目信任条目。', + codexNativeRecipe: '已保存原生 Codex 配方', + gptContextCap: 'GPT-5.4 上下文上限', + usageLimitCost: '用量上限成本超过 272K', + longContextOverride: '长上下文覆盖', + counts2x: '计数 2 倍', + normalUsageWindow: '常规使用窗口', + useCodexDefault: '使用 Codex 默认值', + stdio: 'stdio', + streamableHttp: 'streamable-http', + responses: 'responses', + defaultTargetCli: '默认目标 CLI', + executionChain: '执行链', + targetPath: '当前目标路径', + userConfig: '用户配置', + configYaml: 'config.yaml', + flow: '工作流', + docsTab: '文档', + }, + droidSettings: { + quickControls: '快捷控制', + reasoningControls: '推理控制', + thinkingBudget: '思考预算', + anthropicOnly: '仅 Anthropic 模型', + byokCustomModels: 'BYOK 自定义模型', + }, + rawJsonSettingsEditor: { + title: '原始设置编辑器', + }, + copilotConfigForm: { + copilotConfiguration: 'Copilot 配置', + deprecatedModels: '检测到已弃用的 Copilot 模型', + failedLoadStatus: '加载状态失败', + useWithClaudeCode: '通过 Claude Code 使用你的 GitHub Copilot 订阅', + githubCopilotControls: 'GitHub Copilot 在上游控制提示词/上下文限制。', + provider: '提供商', + filePath: '文件路径', + status: '状态', + enabled: '已启用', + disabled: '已禁用', + loadingEditor: '加载编辑器中...', + modelMapping: '模型映射', + quickUsage: '快速使用', + noPremiumUsage: '无高级用量', + }, + copilotPresets: { + gpt5Codex: 'GPT-5.3 Codex', + claude46: 'Claude 4.6', + gemini3: 'Gemini 3', + }, + healthCard: { + allSystemsNominal: '所有系统正常', + machineChecks: '机器检查', + }, + analyticsCards: { + cacheCost: '缓存成本', + hitRate: '命中率', + inputOutputRatio: '输入/输出比', + noCacheData: '暂无缓存数据', + noModelData: '暂无模型数据', + noSessionData: '暂无会话数据', + noTokenData: '暂无 Token 数据', + totalCost: '总成本', + totalTokens: '总 Token 数', + usageInsights: '使用洞察', + }, + dateRangeFilter: { + pickADate: '选择日期', + }, + logsConfig: { + level: '级别', + message: '消息', + source: '来源', + time: '时间', + proc: '进程', + open: '打开', + run: '运行', + refreshEntries: '刷新条目', + }, + logsDetailPanel: { + details: '详情', + }, + logsFilters: { + filters: '筛选', + }, + logsOverviewCards: { + overview: '概览', + }, + logsPageSkeleton: { + loadingLogs: '加载日志中...', + }, + monitoringErrorLogs: { + logContent: '日志内容', + }, + analyticsPages: { + chartsGrid: '图表', + costByModel: '按模型成本', + }, + toasts: { + profileCreated: '配置创建成功', + profileUpdated: '配置更新成功', + profileDeleted: '配置删除成功', + orphanProfilesComplete: '孤立配置注册完成', + profileCopied: '配置复制成功', + profileImported: '配置导入成功', + authRequired: '{{provider}} 授权需要认证', + authSuccess: '{{provider}} 认证成功!', + authFailed: '{{provider}} 认证失败', + deviceCodeExpired: '验证码已过期,请重试。', + codeCopied: '验证码已复制到剪贴板', + failedCopy: '复制验证码失败', + configSaved: '配置保存成功', + configSaveFailed: '保存失败:{{error}}', + invalidYaml: 'YAML 无效,无法保存', + configUpdatedExternally: '配置已被外部更新', + settingsFileUpdated: '设置文件已更新', + accountsUpdated: '账号已更新', + noProfilesToSync: '没有可同步的配置', + syncFailed: '同步失败:{{error}}', + providerAuthSuccess: '{{provider}} 认证成功', + providerDeviceCodeInCallback: '提供商在回调模式中返回了设备码流程', + loggingConfigSaved: '日志配置已保存。', + loggingConfigSaveFailed: '保存日志配置失败。', + unifiedConfigUpdated: '配置更新成功', + migrationPreviewComplete: '迁移预览完成', + migrationComplete: '迁移成功', + migrationFailed: '迁移失败', + rollbackComplete: '回滚成功', + rollbackFailed: '回滚失败', + defaultAccountSet: '默认账号已设为「{{name}}」', + defaultAccountReset: '默认账号已重置为 CCS', + accountDeleted: '账号「{{name}}」已删除', + contextUpdated: '已将「{{name}}」的上下文更新为 {{summary}}', + legacyConfirmError: '账号「{{name}}」需要显式确认。请在该账号上使用"编辑历史同步"。', + legacyConfirmFailed: '旧版账号「{{name}}」确认失败:{{error}}', + legacyConfirmSuccess_one: '{{count}} 个旧版账号已确认', + legacyConfirmSuccess_other: '{{count}} 个旧版账号已确认', + noLegacyAccounts: '没有需要确认的旧版账号', + routingStrategySet: '路由策略已设为 {{strategy}}', + variantCreated: '变体创建成功', + variantUpdated: '变体更新成功', + variantDeleted: '变体删除成功', + defaultAccountUpdated: '默认账号已更新', + accountRemoved: '账号已移除', + accountPaused: '账号已暂停', + accountResumed: '账号已恢复', + accountAdded: '已为 {{provider}} 添加账号', + kiroImported: '已导入 Kiro 账号:{{name}}', + kiroTokenImported: 'Kiro token 已导入', + modelUpdated: '模型已更新', + presetSaved: '预设「{{name}}」已保存', + presetDeleted: '预设已删除', + cliproxyAlreadyRunning: 'CLIProxy 已在运行', + cliproxyStarted: 'CLIProxy 启动成功', + cliproxyStartFailed: 'CLIProxy 启动失败', + cliproxyStopped: 'CLIProxy 已停止', + cliproxyStopFailed: 'CLIProxy 停止失败', + presetApplied: '已应用「{{name}}」预设', + presetAppliedCustom: '已应用自定义预设', + settingsSavedWithAdjustments: '设置已保存(含模型调整)', + settingsSaved: '设置已保存', + failedSaveSettings: '保存设置失败', + codexRefreshFailed: '刷新 Codex 快照失败。原始编辑已保留。', + codexRefreshError: '刷新 Codex 快照失败。', + codexFixToml: '请先修复 TOML 再保存。', + codexSaved: '已保存 Codex config.toml。', + codexChangedExternally: 'config.toml 已被外部修改,请刷新后重试。', + codexSaveFailed: '保存 Codex config.toml 失败。', + codexUpdateFailed: '更新 Codex 配置失败。', + noOrphanProfiles: '未发现孤立配置', + profilesRegistered: '已注册 {{count}} 个配置{{skipped}}', + destinationEmpty: '目标配置名称不能为空', + profileExportDownloaded: '配置导出已下载', + profileImportFailed: '导入配置包失败', + }, + profileEditorSections: { + imageAnalysis: '图片分析', + loadingImageSettings: '加载图片设置中...', + skipPermissionPrompts: '启动时跳过权限提示', + useNativeImageReading: '使用原生图片读取', + skipTransformer: '跳过转换器', + friendlyUi: '友好界面', + info: '信息', + }, + imageAnalysisStatus: { + sectionTitle: '图片', + openSettings: '打开设置', + useNativeImageReading: '使用原生图片读取', + refreshingPreview: '刷新预览中', + savedStatus: '已保存状态', + livePreview: '实时预览', + disabledGlobally: '全局已禁用', + targetBypassesHook: '{{target}} 绕过了 hook', + nativeImageReading: '原生图片读取', + setupNeeded: '需要设置', + needsAuth: '需要认证', + needsProxy: '需要代理', + nativeFallback: '原生回退', + transformerReady: '转换器就绪', + badgeDisabled: '已禁用', + badgeBypassed: '已绕过', + badgeNative: '原生', + badgeSetup: '需设置', + badgeAuth: '认证', + badgeProxy: '代理', + badgeReady: '就绪', + capabilityVerified: '已验证', + capabilityUnknown: '未知', + toggleSummaryNativeCapable: '{{model}} 支持图片,CCS 将在此跳过转换器。', + toggleSummaryNativeModel: 'CCS 将对 {{model}} 优先使用原生读取。', + toggleSummaryNativeDefault: 'CCS 将对此配置优先使用原生图片读取。', + toggleSummaryNativeFileAccess: '此配置当前仍使用原生文件访问。', + toggleSummaryInactiveTarget: '当前选择 {{target}} 时,已保存的 Claude 端图片路由不生效。', + toggleSummaryTransformerRoute: '转换器路由:{{backend}}{{modelSuffix}}。', + noteDisabledGlobally: '图片功能在 CCS 设置中被全局禁用。', + noteTargetBypassesHook: '当前目标 {{target}} 绕过了 Claude Read hook。', + notePersistHook: '请先持久化配置 hook,然后才能在此使用转换器路由。', + targetLabel: { + claude: 'Claude Code', + droid: 'Factory Droid', + codex: 'Codex CLI', + }, + }, + openrouterBadge: { + new: '新', + integration: 'OpenRouter 集成', + }, + openrouterBanner: { + accessModels: '通过 OpenRouter 访问 {{count}}+ 模型', + add: '添加', + }, + openrouterModelPicker: { + searchModels: '搜索模型', + newestModels: '最新模型', + }, + openrouterPromoCard: { + title: 'OpenRouter', + description: '通过一个 API 端点访问数百个模型。', + }, + profileCard: { + profile: '配置', + openRouter: 'OpenRouter 配置', + claudeCode: 'Claude Code', + claudeCodeDefault: 'Claude Code(默认)', + factoryDroid: 'Factory Droid', + codexCli: 'Codex CLI', + ccsProfile: 'CCS 配置', + }, + profileDeck: { + profiles: '配置', + failedToLoad: '加载配置失败:{{message}}', + noProfiles: '尚未配置。创建你的第一个配置以开始。', + }, + profilesTable: { + name: '名称', + provider: '提供商', + model: '模型', + target: '目标', + lastModified: '最后修改', + actions: '操作', + edit: '编辑', + }, + profileCreateDialog: { + createProfile: '创建配置', + appliedModelToTiers: '已将「{{model}}」应用到所有模型档位', + profileCreated: '配置「{{name}}」已创建', + failedCreate: '创建配置失败', + chooseProviderHint: '选择提供商预设,或配置自定义 API 端点。', + basicInformation: '基本信息', + modelConfiguration: '模型配置', + usedInCli: 'CLI 中使用:', + apiBaseUrl: 'API Base URL', + baseUrlPlaceholder: 'https://api.example.com/v1', + prefilledFromPreset: '从 {{name}} 预填。可按需调整。', + optionalForPreset: '{{name}} 的可选项。留空使用原生 Anthropic 认证。', + endpointHint: '接受 OpenAI 兼容和 Anthropic 请求的端点', + optional: '(可选)', + apiKeyOptionalPlaceholder: '可选 - 仅在启用认证时需要', + apiKeyPlaceholder: 'sk-...', + apiKeyOptionalHint: '仅在本地端点启用了认证时才需要', + defaultTargetCli: '默认目标 CLI', + modelMapping: '模型映射', + modelMappingDesc: '将 Claude Code 档位(Opus/Sonnet/Haiku)映射到提供商支持的模型。', + searchModelsPlaceholder: '输入搜索(例如:opus、sonnet、gpt-4o)...', + noModelsFound: '未找到匹配「{{query}}」的模型', + loadingModels: '加载模型中...', + defaultModel: '默认模型', + sonnetMapping: 'Sonnet 映射', + opusMapping: 'Opus 映射', + haikuMapping: 'Haiku 映射', + sonnetMappingPlaceholder: '例如:gpt-4o、claude-sonnet-4', + opusMappingPlaceholder: '例如:o1、claude-opus-4.5', + haikuMappingPlaceholder: '例如:gpt-4o-mini、claude-3.5-haiku', + free: '免费', + }, + profileDialogLegacy: { + editProfile: '编辑配置', + }, + supportEntryCard: { + actionRequired: '待处理', + }, + settingsPage: { + title: '设置', + loading: '加载中...', + failedLoad: '加载设置失败。', + tabs: { + web: '网页', + env: '环境', + think: '思考', + proxy: '代理', + auth: '认证', + backup: '备份', + channels: '频道', + imageAnalysis: '图片', + }, + websearchSection: { + title: '网页搜索', + description: 'CLI 网页搜索配置。', + }, + thinkingSection: { + title: '思考', + description: '为支持的模型配置扩展思考/推理。', + directOverride: '直接覆盖', + youType: '你输入:', + ccsAdds: 'CCS 添加:', + executionChain: '执行链', + primaryBackends: '主要后端', + legacyCliFallbacks: '旧版 CLI 回退', + managedPayload: '托管载荷', + sharedTargetMetadata: '共享目标元数据', + ideTargetMetadata: 'IDE 目标元数据', + ideSettingsPath: 'IDE 设置路径', + ideHost: 'IDE 主机', + resolvedBinding: '已解析绑定', + bindingName: '绑定名称', + inSync: '已同步', + currentTargetPath: '当前目标路径', + warnings: '告警', + notes: '备注', + workspacePresets: '工作区预设', + draft: '草稿', + advanced: '高级', + recommended: '推荐设置流程', + configureModelFirst: '请先配置模型', + }, + proxySection: { + title: '代理', + loadingImageSettings: '加载图片设置中...', + }, + channelsSection: { + title: '官方渠道', + description: '查看和管理官方发布渠道。', + }, + imageAnalysisSection: { + title: '图片分析', + description: '配置图片分析设置。', + loading: '加载图片设置中...', + }, + }, + codexPage: { + title: 'Codex', + controlCenter: '控制中心', + overview: '概览', + docs: '文档', + nativeRuntime: '原生运行时', + ccsProvider: 'CCS 提供商', + setup: '安装', + }, + apiPage: { + title: 'API 配置', + subtitle: '管理你的 API 配置和端点。', + }, + claudeExtensionPage: { + title: 'Claude Extension', + subtitle: 'Claude Extension 集成设置。', + claudeAuth: 'Claude 认证', + status: '状态', + targetMetadata: 'IDE 目标元数据', + }, + sharedPageV2: { + title: '共享', + subtitle: '共享数据管理。', + }, + homePageV2: { + title: '首页', + logsMoved: '日志已移至专用工作区', + profiles: '配置', + cliproxy: 'CLIProxy', + accounts: '账号', + health: '健康', + }, + analyticsPageV2: { + title: '分析', + subtitle: '使用分析与洞察。', + }, + logsPageV2: { + title: '日志', + subtitle: '查看和管理系统日志。', + }, + healthPageV2: { + title: '健康', + subtitle: '系统健康监控。', + }, + aiProvidersPage: { + title: 'AI 提供商', + subtitle: '管理 AI 提供商配置。', + unableToLoad: '无法加载 AI 提供商', + }, }, }, vi: { @@ -2809,6 +4555,7 @@ const resources = { health: 'Sức khỏe', settings: 'Cài đặt', openrouterTooltip: 'Nổi bật: OpenRouter + Alibaba Coding Plan + Ollama', + logs: 'Nhật ký', }, home: { profiles: 'Hồ sơ', @@ -2946,6 +4693,7 @@ const resources = { cancel: 'Hủy bỏ', savePreset: 'Lưu preset', applyPreset: 'Áp dụng cài sẵn', + deletePreset: 'Xóa cài sẵn', }, componentModelSelector: { selectModel: 'Chọn mô hình', @@ -2987,6 +4735,13 @@ const resources = { recommended: 'Đề xuất', allModelsCount: 'Tất cả mô hình ({{count}})', noModelsAvailable: 'Không có mô hình khả dụng', + shadowed: 'Bị che khuất', + prefixOnly: 'Chỉ tiền tố', + current: 'Hiện tại', + currentValue: 'Giá trị hiện tại', + preferredPinnedModel: 'Mô hình ghim ưu tiên:', + pinnedRouteStatus: 'Trạng thái tuyến ghim:', + pinnedModelNotAdvertised: 'Mô hình ghim hiện không được proxy quảng bá: {{model}}', }, createAuthProfileDialog: { title: 'Tạo tài khoản mới', @@ -3636,6 +5391,8 @@ const resources = { }, settingsTabs: { web: 'Web', + image: 'Hình ảnh', + channels: 'Kênh', env: 'Env', think: 'Tư duy', proxy: 'Proxy', @@ -4221,6 +5978,860 @@ const resources = { retryContent: 'Thử lại nội dung', noMarkdown: 'Không có nội dung Markdown khả dụng.', }, + heroSection: { + title: 'CCS Config', + subtitle: 'Bảng điều khiển Claude Code Switch', + }, + hubFooter: { + logs: 'Nhật ký', + settings: 'Cài đặt', + github: 'GitHub', + copyright: '© {{year}} kaitranntt', + }, + themeToggle: { + srLabel: 'Chuyển đổi giao diện', + }, + ccsLogo: { + alt: 'Logo CCS', + text: 'CCS Config', + }, + claudekitBadge: { + title: 'Được vận hành bởi ClaudeKit Framework', + alt: 'ClaudeKit', + poweredBy: 'Được vận hành bởi', + claudekit: 'ClaudeKit', + }, + codeEditor: { + revealSensitive: 'Hiện giá trị nhạy cảm', + maskSensitive: 'Ẩn giá trị nhạy cảm', + valid: '{{language}} hợp lệ', + readOnly: '(Chỉ đọc)', + }, + commandBuilder: { + title: 'Trình tạo lệnh', + searchPlaceholder: 'Gõ hoặc chọn lệnh...', + copy: 'Sao chép', + run: 'Chạy', + cmdConfig: 'Mở giao diện cấu hình', + cmdCreateProfile: 'Tạo hồ sơ mới', + cmdSwitchProfile: 'Chuyển sang hồ sơ', + cmdDoctor: 'Kiểm tra sức khỏe hệ thống', + cmdListProviders: 'Liệt kê nhà cung cấp CLIProxy', + cmdAddProvider: 'Thêm nhà cung cấp CLIProxy', + }, + confirmDialog: { + confirm: 'Xác nhận', + cancel: 'Hủy', + }, + connectionIndicator: { + connected: 'Đã kết nối', + connecting: 'Đang kết nối...', + disconnected: 'Đã ngắt kết nối', + reconnecting: 'Đang kết nối lại...', + }, + docsLink: { + title: 'Xem tài liệu', + }, + githubLink: { + title: 'Báo cáo vấn đề trên GitHub', + }, + globalEnvIndicator: { + injectedCount_one: '{{count}} biến env toàn cục sẽ được áp dụng khi chạy', + injectedCount_other: '{{count}} biến env toàn cục sẽ được áp dụng khi chạy', + overriddenCount: '({{count}} bị ghi đè bởi hồ sơ)', + skippedLabel: 'Đã bỏ qua (hồ sơ đã định nghĩa):', + configureInSettings: 'Cấu hình trong Cài đặt', + }, + localhostDisclaimer: { + remoteReadonlyAuthDisabledLong: + 'Dashboard từ xa ở chế độ chỉ đọc vì dashboard auth đang bị tắt trên máy host. Bật lại dashboard auth trên máy host để mở khóa thay đổi từ xa.', + remoteReadonlyAuthDisabledShort: + 'Dashboard từ xa chỉ đọc cho đến khi dashboard auth được bật lại trên máy host.', + remoteReadonlySetupLong: + 'Dashboard từ xa chỉ đọc cho đến khi bạn chạy ccs config auth setup trên máy host.', + remoteReadonlySetupShort: + 'Dashboard từ xa chỉ đọc cho đến khi auth được cấu hình trên máy host.', + localLong: 'Dashboard này chạy cục bộ. Toàn bộ dữ liệu nằm trên máy của bạn.', + localShort: 'Dashboard cục bộ - dữ liệu nằm trên thiết bị của bạn.', + dismiss: 'Bỏ qua thông báo', + }, + privacyToggle: { + modeOn: 'Chế độ riêng tư BẬT - Nhấp để hiện dữ liệu', + modeOff: 'Chế độ riêng tư TẮT - Nhấp để ẩn dữ liệu', + }, + projectSelectionDialog: { + title: 'Chọn dự án Google Cloud', + description: 'Chọn dự án để dùng cho xác thực {{provider}}.', + autoSelectCountdown: '(Tự động chọn mặc định sau {{count}}s)', + default: 'Mặc định', + allProjects: 'Tất cả dự án', + allProjectsDescription: 'Thêm tất cả {{count}} dự án đã liệt kê', + useDefault: 'Dùng mặc định', + selecting: 'Đang chọn...', + confirmSelection: 'Xác nhận lựa chọn', + codeCopied: 'Đã sao chép mã', + copyVerificationCode: 'Sao chép mã xác minh', + }, + quickCommands: { + title: 'Lệnh nhanh', + startDefault: 'Khởi chạy mặc định', + startDefaultDesc: 'Chạy Claude với hồ sơ mặc định', + glmProfile: 'Hồ sơ GLM', + glmProfileDesc: 'Chuyển sang mô hình GLM', + healthCheck: 'Kiểm tra sức khỏe', + healthCheckDesc: 'Chạy chẩn đoán hệ thống', + delegateTask: 'Giao việc', + delegateTaskDesc: 'Giao việc cho hồ sơ GLM', + }, + quotaTooltip: { + loadingQuota: 'Đang tải quota...', + failedLoadQuota: 'Không tải được quota', + modelQuotas: 'Hạn ngạch mô hình:', + rateLimits: 'Giới hạn tốc độ:', + plan: 'Gói: {{plan}}', + quotaSnapshots: 'Ảnh chụp hạn ngạch:', + unlimited: 'Không giới hạn', + remaining: 'Còn {{remaining}}/{{entitlement}}', + tier: 'Tier', + tierId: 'Tier ID', + state: 'Trạng thái', + credits: 'Credits', + modelQuotasLower: 'Hạn ngạch mô hình:', + allBucketsReport: 'Tất cả bucket báo cáo {{tokenType}}', + requestsRemaining: 'Còn {{count}} yêu cầu', + inputTokensRemaining: 'Còn {{count}} token đầu vào', + outputTokensRemaining: 'Còn {{count}} token đầu ra', + amountRemaining: 'Còn {{count}}', + fiveHourLimit: 'Giới hạn dùng 5 giờ', + weeklyLimit: 'Giới hạn dùng hàng tuần', + weeklyOpus: 'Dùng hàng tuần (Opus)', + weeklySonnet: 'Dùng hàng tuần (Sonnet)', + weeklyOAuthApps: 'Dùng hàng tuần (OAuth apps)', + weeklyCowork: 'Dùng hàng tuần (Cowork)', + extraUsage: 'Lượt dùng thêm', + premiumInteractions: 'Tương tác Premium', + chat: 'Chat', + completions: 'Hoàn thành', + resets: 'Reset lúc {{time}}', + fiveHourResets: 'Reset 5h lúc {{time}}', + weeklyResets: 'Reset hàng tuần lúc {{time}}', + }, + sponsorButton: { + title: 'Tài trợ dự án này trên GitHub', + sponsor: 'Tài trợ', + }, + valueMetrics: { + apiCostSaved: 'Chi phí API tiết kiệm', + tokensSaved: 'Token tiết kiệm', + queriesFaster: 'Truy vấn nhanh hơn', + errorsReduced: 'Lỗi giảm', + vsLastMonth: 'so với tháng trước', + throughCaching: 'thông qua cache', + averageSpeedup: 'tăng tốc trung bình', + withRetryLogic: 'với logic thử lại', + performanceMetrics: 'Chỉ số hiệu suất', + monthlySummary: 'Tổng kết hàng tháng', + totalSaved: 'Tổng tiết kiệm', + tokensProcessed: 'Token đã xử lý', + queriesHandled: 'Truy vấn đã xử lý', + uptime: 'Thời gian hoạt động', + }, + updatesSpotlight: { + openUpdatesCenter: 'Mở Trung tâm cập nhật', + }, + deviceCodeDialog: { + authorize: 'Xác thực {{provider}}', + enterCodeAtPage: 'Nhập mã bên dưới tại trang xác thực.', + expiresIn: '(Hết hạn sau {{time}})', + codeExpired: '(Mã đã hết hạn)', + copied: 'Đã sao chép!', + copyCode: 'Sao chép mã', + waitingForAuth: 'Đang chờ xác thực...', + openVerificationPage: 'Mở trang xác minh', + openProviderPage: 'Mở {{provider}}', + copyCodeAria: 'Sao chép mã xác minh', + codeCopiedAria: 'Đã sao chép mã', + }, + settingsDialog: { + editProfile: 'Chỉnh sửa hồ sơ: {{name}}', + description: 'Cấu hình biến môi trường và cài đặt cho hồ sơ này.', + loadingSettings: 'Đang tải cài đặt...', + envTab: 'Môi trường', + rawJsonTab: 'JSON thô', + generalTab: 'Chung', + noEnvVars: 'Không có biến môi trường nào được cấu hình.', + noEnvVarsHint: 'Thêm biến trong tệp settings.json của bạn.', + loadingEditor: 'Đang tải trình soạn thảo...', + profileInfo: 'Thông tin hồ sơ', + profileInfoDesc: 'Chi tiết về tệp cấu hình này.', + path: 'Đường dẫn', + lastModified: 'Sửa đổi lần cuối', + cancel: 'Hủy', + saving: 'Đang lưu...', + saveChanges: 'Lưu thay đổi', + conflictTitle: 'Tệp đã bị thay đổi bên ngoài', + conflictDesc: + 'Tệp cài đặt này đã bị thay đổi bởi một tiến trình khác. Ghi đè thay đổi của bạn hay hủy?', + overwrite: 'Ghi đè', + }, + setupWizard: { + title: 'Trình thiết lập nhanh', + stepProviderDesc: 'Chọn nhà cung cấp để bắt đầu', + stepAuthDesc: 'Xác thực với nhà cung cấp', + stepAccountDesc: 'Chọn tài khoản để sử dụng', + stepVariantDesc: 'Tạo biến thể tùy chỉnh', + stepSuccessDesc: 'Thiết lập hoàn tất!', + authStep: { + authenticateWith: 'Xác thực với {{provider}} để thêm tài khoản', + authenticating: 'Đang xác thực...', + authenticateInBrowser: 'Xác thực trong trình duyệt', + completeOAuth: 'Hoàn tất luồng OAuth trong trình duyệt...', + orUseTerminal: 'Hoặc dùng terminal', + runCommandHint: 'Chạy lệnh này trong terminal:', + back: 'Quay lại', + checking: 'Đang kiểm tra...', + refreshStatus: 'Làm mới trạng thái', + }, + accountStep: { + selectAccount: 'Chọn tài khoản ({{count}})', + defaultAccount: 'Tài khoản mặc định', + or: 'Hoặc', + addNewAccount: 'Thêm tài khoản mới', + addNewAccountDesc: 'Xác thực với một tài khoản khác', + back: 'Quay lại', + }, + variantStep: { + back: 'Quay lại', + skip: 'Bỏ qua', + }, + successStep: { + title: 'Biến thể đã được tạo!', + subtitle: 'Biến thể tùy chỉnh đã sẵn sàng để sử dụng', + usage: 'Cách dùng:', + done: 'Xong', + }, + }, + accountSurfaceCard: { + business: 'Biz', + personal: 'Cá nhân', + variant: 'Biến thể', + }, + accountCardStats: { + notUsedYet: 'Chưa sử dụng', + }, + accountQuotaPanel: { + weekly: 'Hàng tuần', + loadingQuota: 'Đang tải quota...', + }, + userMenu: { + signedInAs: 'Đăng nhập sebagai {{username}}', + }, + authMonitorLive: { + live: 'TRỰC TIẾP', + accountMonitor: 'Theo dõi tài khoản', + updated: 'Cập nhật lúc {{time}}', + updatedNow: 'Vừa cập nhật', + requestsLabel: 'req', + stats: 'Thống kê', + successRate: 'Tỷ lệ thành công', + missingProjectId: 'Thiếu Project ID', + noActivity: 'không hoạt động', + }, + providerCard: { + missingProjectIdAria: 'Thiếu Project ID', + }, + loginPage: { + showPassword: 'Hiện mật khẩu', + hidePassword: 'Ẩn mật khẩu', + }, + cliproxyStatsOverview: { + sessionStatistics: 'Thống kê phiên', + realTimeMetrics: 'Chỉ số thời gian thực từ {{backend}}', + offline: 'Ngoại tuyến', + running: 'Đang chạy', + noActiveSession: 'Không có phiên hoạt động', + noActiveSessionHint: + 'Bắt đầu phiên CLIProxy bằng ccs gemini, ccs codex hoặc ccs agy để xem thống kê thời gian thực.', + failedLoadStats: 'Không tải được thống kê', + totalRequests: 'Tổng yêu cầu', + successCount: '{{count}} thành công', + successRate: 'Tỷ lệ thành công', + totalTokens: 'Tổng token', + estimatedCost: '~${{cost}} ước tính', + modelsUsed: 'Mô hình đã dùng', + modelUsageDistribution: 'Phân bố sử dụng mô hình', + requestCount: '{{count}} yêu cầu', + }, + cliproxyTable: { + name: 'Tên', + provider: 'Nhà cung cấp', + model: 'Mô hình', + account: 'Tài khoản', + status: 'Trạng thái', + default: 'Mặc định', + actions: 'Hành động', + }, + cliproxyTabs: { + overview: 'Tổng quan', + variants: 'Biến thể', + aiProviders: 'AI Providers', + controlPanel: 'Bảng điều khiển', + }, + cliproxyHeader: { + ccsLevelAccountManagement: 'Quản lý tài khoản cấp CCS', + cliproxyNotAvailable: 'CLIProxy không khả dụng', + cliproxyControlPanel: 'Bảng điều khiển CLIProxy', + noVariants: 'Không tìm thấy biến thể CLIProxy nào.', + addAccountToStart: 'Thêm tài khoản để bắt đầu', + }, + routingGuidance: { + roundRobin: 'Round-robin phân bổ đều lượt dùng.', + fillFirst: 'Fill-first giữ tài khoản dự phòng cho đến khi cần thiết.', + routingStrategy: 'Chiến lược định tuyến', + optionalRouting: 'Định tuyến tùy chọn', + }, + extendedContext: { + extendedContext: 'Ngữ cảnh mở rộng', + }, + cliproxyConfig: { + unsavedChanges: 'Thay đổi chưa lưu', + original: 'Gốc', + modified: 'Đã sửa đổi', + reviewChanges: 'Xem lại thay đổi', + loadingEditor: 'Đang tải trình soạn thảo...', + }, + providerEditor: { + provider: 'Nhà cung cấp', + filePath: 'Đường dẫn tệp', + lastModified: 'Sửa đổi lần cuối', + defaultTarget: 'Mục tiêu mặc định', + quickUsage: 'Sử dụng nhanh', + modelMapping: 'Ánh xạ mô hình', + status: 'Trạng thái', + loadingSettings: 'Đang tải cài đặt...', + loadingEditor: 'Đang tải trình soạn thảo...', + noAccountsConnected: 'Chưa kết nối tài khoản nào', + addAccountToStart: 'Thêm tài khoản để bắt đầu', + gcpProjectIdReadonly: 'GCP Project ID (chỉ đọc)', + projectIdNA: 'Project ID: N/A', + missingProjectId: 'Thiếu Project ID', + missingProjectIdHint: + 'Điều này có thể gây lỗi. Xóa tài khoản và thêm lại để lấy Project ID.', + useIncognito: 'Dùng ẩn danh', + aliases: 'Bí danh', + current: 'Hiện tại', + currentValue: 'Giá trị hiện tại', + composite: 'tổng hợp', + defaultLabel: 'mặc định', + requiredSetup: 'Cài đặt cần thiết', + connectorName: 'Tên connector', + proxyUrl: 'URL proxy', + proxyUrlSet: 'Đã đặt URL proxy', + excludedModels: 'Mô hình loại trừ', + headers: 'Headers', + secret: 'Secret', + prefix: 'Tiền tố', + modelMappings: 'Ánh xạ mô hình', + baseUri: 'URL cơ sở', + apiKeys: 'Khóa API', + presets: 'Áp dụng ánh xạ mô hình đã cấu hình sẵn', + createVariant: 'Tạo biến thể CLIProxy', + agyDenylist: + 'Danh sách loại trừ Antigravity: Claude Opus 4.5 và Claude Sonnet 4.5 đã bị loại bỏ.', + }, + providerEditorAccountItem: { + modelsUsed: 'Mô hình đã dùng', + }, + bulkActionBar: { + applyPreset: 'Áp dụng preset', + }, + modelConfigSection: { + defaultModel: 'Mô hình mặc định', + }, + rawEditorSection: { + rawConfig: 'Cấu hình thô', + }, + providerEditorHeader: { + connectorName: 'Tên connector', + }, + aiProvidersFamilyRail: { + current: 'Hiện tại', + }, + aiProvidersEntryCard: { + apiKeys: 'Khóa API', + }, + aiProvidersEntryDialog: { + connectorName: 'Tên connector', + baseUri: 'URL cơ sở', + proxyUrl: 'URL proxy', + secret: 'Secret', + prefix: 'Tiền tố', + excludedModels: 'Mô hình loại trừ', + headers: 'Headers', + modelMappings: 'Ánh xạ mô hình', + requiredSetup: 'Cài đặt cần thiết', + optionalRouting: 'Định tuyến tùy chọn', + }, + codex: { + controlCenter: 'Trung tâm điều khiển', + overview: 'Tổng quan', + docs: 'Tài liệu', + nativeCodexRuntime: 'Codex Runtime gốc', + ccsCodexProvider: 'CCS Codex provider / bridge', + codexDocs: 'Tài liệu Codex', + supportedFlows: 'Các luồng được hỗ trợ', + twoSupportedPaths: 'Hai đường dẫn được hỗ trợ:', + nativeLabel: 'Gốc:', + nativeDesc: 'Codex là target runtime hạng nhất trong CCS v1.', + ccsBridge: 'CCS Bridge', + apiProfilesDefault: 'API profiles mặc định dùng Claude hoặc Droid.', + recommendedSetupFlow: 'Luồng thiết lập khuyến nghị', + fastestPath: 'Đường dẫn nhanh nhất', + officialChannels: 'Kênh chính thức', + codexCli: 'Codex CLI', + openNativeCodex: 'Mở Codex gốc', + runBuiltInCodex: 'Chạy Codex tích hợp trên Codex', + runBuiltInCodexExplicit: 'Chạy Codex tích hợp trên Codex (rõ ràng)', + openCodexDashboard: 'Mở dashboard Codex', + status: 'Trạng thái', + profiles: 'Hồ sơ', + createNewProfile: 'Tạo hồ sơ mới', + createNewProvider: 'Tạo nhà cung cấp mới', + createNewMcpServer: 'Tạo MCP server mới', + defaultProvider: 'Nhà cung cấp mặc định', + useDefault: 'Dùng mặc định', + useGlobalProvider: 'Dùng nhà cung cấp toàn cục', + useProviderDefault: 'Dùng mặc định của nhà cung cấp', + quickFillWarning: 'Chỉ điền nhanh. Hãy xem lại trước khi lưu.', + thisFileUpstreamOwned: 'Tệp này thuộc sở hữu của Codex CLI upstream.', + notes: 'Ghi chú', + approvalPolicy: 'Chính sách phê duyệt', + sandboxMode: 'Chế độ sandbox', + reasoningEffort: 'Mức nỗ lực lý luận', + useGlobalEffort: 'Dùng mức toàn cục', + reasoningEffortCapitalized: 'Mức nỗ lực lý luận', + thinkingBudgetTokens: 'Token ngân sách tư duy', + modelContextWindow: 'Cửa sổ ngữ cảnh mô hình', + autoCompactTokenLimit: 'Giới hạn token tự động nén', + toolOutputTokenLimit: 'Giới hạn token đầu ra tool', + webSearch: 'Tìm kiếm web', + personality: 'Tính cách', + model: 'Mô hình', + rawOnly: 'Chỉ thô', + trusted: 'đã tin cậy', + untrusted: 'chưa tin cậy', + noProjectTrustEntries: 'Không có mục tin cậy dự án nào được lưu.', + codexNativeRecipe: 'Đã lưu công thức Codex gốc', + gptContextCap: 'Giới hạn ngữ cảnh GPT-5.4', + usageLimitCost: 'Chi phí vượt mức 272K', + longContextOverride: 'Ghi đè ngữ cảnh dài', + counts2x: 'Tính 2x', + normalUsageWindow: 'Cửa sổ sử dụng bình thường', + useCodexDefault: 'Dùng mặc định Codex', + stdio: 'stdio', + streamableHttp: 'streamable-http', + responses: 'phản hồi', + defaultTargetCli: 'CLI mục tiêu mặc định', + executionChain: 'Chuỗi thực thi', + targetPath: 'Đường dẫn mục tiêu hiện tại', + userConfig: 'Cấu hình người dùng', + configYaml: 'config.yaml', + flow: 'Luồng', + docsTab: 'Tài liệu', + }, + droidSettings: { + quickControls: 'Điều khiển nhanh', + reasoningControls: 'Điều khiển lý luận', + thinkingBudget: 'Ngân sách tư duy', + anthropicOnly: 'Chỉ mô hình Anthropic', + byokCustomModels: 'Mô hình tùy chỉnh BYOK', + }, + rawJsonSettingsEditor: { + title: 'Trình soạn thảo cài đặt thô', + }, + copilotConfigForm: { + copilotConfiguration: 'Cấu hình Copilot', + deprecatedModels: 'Phát hiện mô hình Copilot đã lỗi thời', + failedLoadStatus: 'Không tải được trạng thái', + useWithClaudeCode: 'Dùng đăng ký GitHub Copilot với Claude Code', + githubCopilotControls: 'GitHub Copilot kiểm soát giới hạn prompt/ngữ cảnh ở upstream.', + provider: 'Nhà cung cấp', + filePath: 'Đường dẫn tệp', + status: 'Trạng thái', + enabled: 'Đã bật', + disabled: 'Đã tắt', + loadingEditor: 'Đang tải trình soạn thảo...', + modelMapping: 'Ánh xạ mô hình', + quickUsage: 'Sử dụng nhanh', + noPremiumUsage: 'Không có số liệu sử dụng premium', + }, + copilotPresets: { + gpt5Codex: 'GPT-5.3 Codex', + claude46: 'Claude 4.6', + gemini3: 'Gemini 3', + }, + healthCard: { + allSystemsNominal: 'Tất cả hệ thống hoạt động bình thường', + machineChecks: 'Kiểm tra máy', + }, + analyticsCards: { + cacheCost: 'Chi phí cache', + hitRate: 'Tỷ lệ trúng', + inputOutputRatio: 'Tỷ lệ Đầu vào/Đầu ra', + noCacheData: 'Không có dữ liệu cache', + noModelData: 'Không có dữ liệu mô hình', + noSessionData: 'Không có dữ liệu phiên', + noTokenData: 'Không có dữ liệu token', + totalCost: 'Tổng chi phí', + totalTokens: 'Tổng token', + usageInsights: 'Phân tích sử dụng', + }, + dateRangeFilter: { + pickADate: 'Chọn ngày', + }, + logsConfig: { + level: 'Mức', + message: 'Thông điệp', + source: 'Nguồn', + time: 'Thời gian', + proc: 'Tiến trình', + open: 'Mở', + run: 'Chạy', + refreshEntries: 'Làm mới mục', + }, + logsDetailPanel: { + details: 'Chi tiết', + }, + logsFilters: { + filters: 'Bộ lọc', + }, + logsOverviewCards: { + overview: 'Tổng quan', + }, + logsPageSkeleton: { + loadingLogs: 'Đang tải nhật ký...', + }, + monitoringErrorLogs: { + logContent: 'Nội dung nhật ký', + }, + analyticsPages: { + chartsGrid: 'Biểu đồ', + costByModel: 'Chi phí theo mô hình', + }, + toasts: { + profileCreated: 'Đã tạo hồ sơ thành công', + profileUpdated: 'Đã cập nhật hồ sơ thành công', + profileDeleted: 'Đã xóa hồ sơ thành công', + orphanProfilesComplete: 'Hoàn tất đăng ký hồ sơ mồ côi', + profileCopied: 'Đã sao chép hồ sơ thành công', + profileImported: 'Đã nhập hồ sơ thành công', + authRequired: 'Yêu cầu xác thực {{provider}}', + authSuccess: 'Xác thực {{provider}} thành công!', + authFailed: 'Xác thực {{provider}} thất bại', + deviceCodeExpired: 'Mã thiết bị đã hết hạn. Vui lòng thử lại.', + codeCopied: 'Đã sao chép mã vào bảng nhớ tạm', + failedCopy: 'Không sao chép được mã', + configSaved: 'Đã lưu cấu hình thành công', + configSaveFailed: 'Lưu thất bại: {{error}}', + invalidYaml: 'Không thể lưu YAML không hợp lệ', + configUpdatedExternally: 'Cấu hình đã được cập nhật bên ngoài', + settingsFileUpdated: 'Tệp cài đặt đã được cập nhật', + accountsUpdated: 'Tài khoản đã được cập nhật', + noProfilesToSync: 'Không có hồ sơ để đồng bộ', + syncFailed: 'Đồng bộ thất bại: {{error}}', + providerAuthSuccess: 'Xác thực {{provider}} thành công', + providerDeviceCodeInCallback: 'Nhà cung cấp trả về Device Code flow trong chế độ callback', + loggingConfigSaved: 'Đã lưu cấu hình ghi nhật ký.', + loggingConfigSaveFailed: 'Không lưu được cấu hình ghi nhật ký.', + unifiedConfigUpdated: 'Đã cập nhật cấu hình thành công', + migrationPreviewComplete: 'Xem trước di chuyển hoàn tất', + migrationComplete: 'Di chuyển hoàn tất thành công', + migrationFailed: 'Di chuyển thất bại', + rollbackComplete: 'Khôi phục hoàn tất thành công', + rollbackFailed: 'Khôi phục thất bại', + defaultAccountSet: 'Tài khoản mặc định đã đặt thành "{{name}}"', + defaultAccountReset: 'Tài khoản mặc định đã đặt lại về CCS', + accountDeleted: 'Đã xóa tài khoản "{{name}}"', + contextUpdated: 'Đã cập nhật ngữ cảnh "{{name}}" thành {{summary}}', + legacyConfirmError: + 'Tài khoản "{{name}}" cần xác nhận rõ ràng. Dùng Chỉnh sửa Đồng bộ lịch sử trên tài khoản này.', + legacyConfirmFailed: 'Tài khoản cũ "{{name}}" xác nhận thất bại: {{error}}', + legacyConfirmSuccess_one: 'Đã xác nhận {{count}} tài khoản cũ', + legacyConfirmSuccess_other: 'Đã xác nhận {{count}} tài khoản cũ', + noLegacyAccounts: 'Không có tài khoản cũ nào cần xác nhận', + routingStrategySet: 'Chiến lược định tuyến đã đặt thành {{strategy}}', + variantCreated: 'Đã tạo biến thể thành công', + variantUpdated: 'Đã cập nhật biến thể thành công', + variantDeleted: 'Đã xóa biến thể thành công', + defaultAccountUpdated: 'Đã cập nhật tài khoản mặc định', + accountRemoved: 'Đã xóa tài khoản', + accountPaused: 'Đã tạm dừng tài khoản', + accountResumed: 'Đã tiếp tục tài khoản', + accountAdded: 'Đã thêm tài khoản cho {{provider}}', + kiroImported: 'Đã nhập tài khoản Kiro: {{name}}', + kiroTokenImported: 'Đã nhập token Kiro', + modelUpdated: 'Đã cập nhật mô hình', + presetSaved: 'Đã lưu preset "{{name}}"', + presetDeleted: 'Đã xóa preset', + cliproxyAlreadyRunning: 'CLIProxy đã đang chạy', + cliproxyStarted: 'CLIProxy đã khởi động thành công', + cliproxyStartFailed: 'Không khởi động được CLIProxy', + cliproxyStopped: 'Đã dừng CLIProxy', + cliproxyStopFailed: 'Không dừng được CLIProxy', + presetApplied: 'Đã áp dụng preset "{{name}}"', + presetAppliedCustom: 'Đã áp dụng preset tùy chỉnh', + settingsSavedWithAdjustments: 'Cài đặt đã lưu với điều chỉnh mô hình', + settingsSaved: 'Đã lưu cài đặt', + failedSaveSettings: 'Không lưu được cài đặt', + codexRefreshFailed: 'Không làm mới được snapshot Codex. Các chỉnh sửa thô đã được giữ.', + codexRefreshError: 'Không làm mới được snapshot Codex.', + codexFixToml: 'Sửa TOML trước khi lưu.', + codexSaved: 'Đã lưu Codex config.toml.', + codexChangedExternally: 'config.toml đã thay đổi bên ngoài. Làm mới và thử lại.', + codexSaveFailed: 'Không lưu được Codex config.toml.', + codexUpdateFailed: 'Không cập nhật được cấu hình Codex.', + noOrphanProfiles: 'Không tìm thấy cài đặt hồ sơ mồ côi', + profilesRegistered: 'Đã đăng ký {{count}} hồ sơ{{skipped}}', + destinationEmpty: 'Tên hồ sơ đích không được để trống', + profileExportDownloaded: 'Đã tải xuống xuất hồ sơ', + profileImportFailed: 'Không nhập được gói hồ sơ', + }, + profileEditorSections: { + imageAnalysis: 'Phân tích hình ảnh', + loadingImageSettings: 'Đang tải cài đặt hình ảnh...', + skipPermissionPrompts: 'Bỏ qua nhắc quyền khi khởi chạy', + useNativeImageReading: 'Dùng đọc hình ảnh gốc', + skipTransformer: 'Bỏ qua transformer', + friendlyUi: 'Giao diện thân thiện', + info: 'Thông tin', + }, + imageAnalysisStatus: { + sectionTitle: 'Hình ảnh', + openSettings: 'Mở Cài đặt', + useNativeImageReading: 'Dùng đọc hình ảnh gốc', + refreshingPreview: 'Đang làm mới xem trước', + savedStatus: 'Trạng thái đã lưu', + livePreview: 'Xem trước trực tiếp', + disabledGlobally: 'Đã tắt toàn cục', + targetBypassesHook: '{{target}} bỏ qua hook', + nativeImageReading: 'Đọc hình ảnh gốc', + setupNeeded: 'Cần thiết lập', + needsAuth: 'Cần xác thực', + needsProxy: 'Cần proxy', + nativeFallback: 'Dự phòng gốc', + transformerReady: 'Transformer sẵn sàng', + badgeDisabled: 'Đã tắt', + badgeBypassed: 'Đã bỏ qua', + badgeNative: 'Gốc', + badgeSetup: 'Thiết lập', + badgeAuth: 'Xác thực', + badgeProxy: 'Proxy', + badgeReady: 'Sẵn sàng', + capabilityVerified: 'Đã xác minh', + capabilityUnknown: 'Không xác định', + toggleSummaryNativeCapable: + '{{model}} có vẻ hỗ trợ hình ảnh. CCS sẽ bỏ qua transformer ở đây.', + toggleSummaryNativeModel: 'CCS sẽ ưu tiên đọc gốc cho {{model}}.', + toggleSummaryNativeDefault: 'CCS sẽ ưu tiên đọc hình ảnh gốc cho hồ sơ này.', + toggleSummaryNativeFileAccess: 'Hồ sơ này hiện đang dùng truy cập tệp gốc.', + toggleSummaryInactiveTarget: + 'Định tuyến hình ảnh phía Claude đã lưu không hoạt động khi {{target}} đang được chọn.', + toggleSummaryTransformerRoute: 'Tuyến transformer: {{backend}}{{modelSuffix}}.', + noteDisabledGlobally: 'Hình ảnh đã bị tắt toàn cục trong cài đặt CCS.', + noteTargetBypassesHook: 'Mục tiêu hiện tại {{target}} bỏ qua Claude Read hook.', + notePersistHook: 'Persist hook hồ sơ trước khi tuyến transformer có thể chạy ở đây.', + targetLabel: { + claude: 'Claude Code', + droid: 'Factory Droid', + codex: 'Codex CLI', + }, + }, + openrouterBadge: { + new: 'MỚI', + integration: 'Tích hợp OpenRouter', + }, + openrouterBanner: { + accessModels: 'Truy cập {{count}}+ mô hình qua OpenRouter', + add: 'Thêm', + }, + openrouterModelPicker: { + searchModels: 'Tìm kiếm mô hình', + newestModels: 'Mô hình mới nhất', + }, + openrouterPromoCard: { + title: 'OpenRouter', + description: 'Truy cập hàng trăm mô hình qua một API endpoint.', + }, + profileCard: { + profile: 'Hồ sơ', + openRouter: 'Hồ sơ OpenRouter', + claudeCode: 'Claude Code', + claudeCodeDefault: 'Claude Code (mặc định)', + factoryDroid: 'Factory Droid', + codexCli: 'Codex CLI', + ccsProfile: 'Hồ sơ CCS', + }, + profileDeck: { + profiles: 'Hồ sơ', + failedToLoad: 'Không tải được hồ sơ: {{message}}', + noProfiles: 'Chưa có hồ sơ nào. Tạo hồ sơ đầu tiên để bắt đầu.', + }, + profilesTable: { + name: 'Tên', + provider: 'Nhà cung cấp', + model: 'Mô hình', + target: 'Mục tiêu', + lastModified: 'Sửa đổi lần cuối', + actions: 'Hành động', + edit: 'Chỉnh sửa', + }, + profileCreateDialog: { + createProfile: 'Tạo hồ sơ', + appliedModelToTiers: 'Đã áp dụng "{{model}}" cho tất cả tier mô hình', + profileCreated: 'Đã tạo hồ sơ "{{name}}"', + failedCreate: 'Không tạo được hồ sơ', + chooseProviderHint: 'Chọn preset nhà cung cấp hoặc cấu hình API endpoint tùy chỉnh.', + basicInformation: 'Thông tin cơ bản', + modelConfiguration: 'Cấu hình mô hình', + usedInCli: 'Dùng trong CLI:', + apiBaseUrl: 'URL cơ sở API', + baseUrlPlaceholder: 'https://api.example.com/v1', + prefilledFromPreset: 'Điền sẵn từ {{name}}. Bạn có thể tùy chỉnh nếu cần.', + optionalForPreset: 'Tùy chọn cho {{name}}. Để trống để dùng xác thực Anthropic gốc.', + endpointHint: 'Endpoint chấp nhận yêu cầu tương thích OpenAI và Anthropic', + optional: '(tùy chọn)', + apiKeyOptionalPlaceholder: 'Tùy chọn - chỉ khi bật xác thực', + apiKeyPlaceholder: 'sk-...', + apiKeyOptionalHint: 'Chỉ cần khi endpoint cục bộ bật xác thực', + defaultTargetCli: 'CLI mục tiêu mặc định', + modelMapping: 'Ánh xạ mô hình', + modelMappingDesc: + 'Ánh xạ các tier Claude Code (Opus/Sonnet/Haiku) sang mô hình được nhà cung cấp hỗ trợ.', + searchModelsPlaceholder: 'Gõ để tìm (vd: opus, sonnet, gpt-4o)...', + noModelsFound: 'Không tìm thấy mô hình cho "{{query}}"', + loadingModels: 'Đang tải mô hình...', + defaultModel: 'Mô hình mặc định', + sonnetMapping: 'Ánh xạ Sonnet', + opusMapping: 'Ánh xạ Opus', + haikuMapping: 'Ánh xạ Haiku', + sonnetMappingPlaceholder: 'vd: gpt-4o, claude-sonnet-4', + opusMappingPlaceholder: 'vd: o1, claude-opus-4.5', + haikuMappingPlaceholder: 'vd: gpt-4o-mini, claude-3.5-haiku', + free: 'Miễn phí', + }, + profileDialogLegacy: { + editProfile: 'Chỉnh sửa hồ sơ', + }, + supportEntryCard: { + actionRequired: 'Cần hành động', + }, + settingsPage: { + title: 'Cài đặt', + loading: 'Đang tải...', + failedLoad: 'Không tải được cài đặt.', + tabs: { + web: 'Web', + env: 'Env', + think: 'Tư duy', + proxy: 'Proxy', + auth: 'Xác thực', + backup: 'Sao lưu', + channels: 'Kênh', + imageAnalysis: 'Hình ảnh', + }, + websearchSection: { + title: 'Tìm kiếm Web', + description: 'Cấu hình tìm kiếm web dựa trên CLI.', + }, + thinkingSection: { + title: 'Tư duy', + description: 'Cấu hình tư duy/lý luận mở rộng cho các mô hình được hỗ trợ.', + directOverride: 'Ghi đè trực tiếp', + youType: 'Bạn gõ:', + ccsAdds: 'CCS thêm:', + executionChain: 'Chuỗi thực thi', + primaryBackends: 'Backend chính', + legacyCliFallbacks: 'Fallback CLI cũ', + managedPayload: 'Payload được quản lý', + sharedTargetMetadata: 'Metadata mục tiêu dùng chung', + ideTargetMetadata: 'Metadata mục tiêu IDE', + ideSettingsPath: 'Đường dẫn cài đặt IDE', + ideHost: 'Host IDE', + resolvedBinding: 'Binding đã giải quyết', + bindingName: 'Tên binding', + inSync: 'Đồng bộ', + currentTargetPath: 'Đường dẫn mục tiêu hiện tại', + warnings: 'Cảnh báo', + notes: 'Ghi chú', + workspacePresets: 'Preset workspace', + draft: 'Bản nháp', + advanced: 'Nâng cao', + recommended: 'Luồng thiết lập khuyến nghị', + configureModelFirst: 'Cấu hình mô hình trước', + }, + proxySection: { + title: 'Proxy', + loadingImageSettings: 'Đang tải cài đặt hình ảnh...', + }, + channelsSection: { + title: 'Kênh chính thức', + description: 'Xem và quản lý các kênh phát hành chính thức.', + }, + imageAnalysisSection: { + title: 'Phân tích hình ảnh', + description: 'Cấu hình cài đặt phân tích hình ảnh.', + loading: 'Đang tải cài đặt hình ảnh...', + }, + }, + codexPage: { + title: 'Codex', + controlCenter: 'Trung tâm điều khiển', + overview: 'Tổng quan', + docs: 'Tài liệu', + nativeRuntime: 'Runtime gốc', + ccsProvider: 'CCS Provider', + setup: 'Thiết lập', + }, + apiPage: { + title: 'Hồ sơ API', + subtitle: 'Quản lý hồ sơ API và các endpoint.', + }, + claudeExtensionPage: { + title: 'Claude Extension', + subtitle: 'Cài đặt tích hợp Claude Extension.', + claudeAuth: 'Xác thực Claude', + status: 'Trạng thái', + targetMetadata: 'Metadata mục tiêu IDE', + }, + sharedPageV2: { + title: 'Dùng chung', + subtitle: 'Quản lý dữ liệu dùng chung.', + }, + homePageV2: { + title: 'Trang chủ', + logsMoved: 'Nhật ký đã chuyển sang workspace riêng', + profiles: 'Hồ sơ', + cliproxy: 'CLIProxy', + accounts: 'Tài khoản', + health: 'Sức khỏe', + }, + analyticsPageV2: { + title: 'Phân tích', + subtitle: 'Phân tích sử dụng và thông tin chi tiết.', + }, + logsPageV2: { + title: 'Nhật ký', + subtitle: 'Xem và quản lý nhật ký hệ thống.', + }, + healthPageV2: { + title: 'Sức khỏe', + subtitle: 'Giám sát sức khỏe hệ thống.', + }, + aiProvidersPage: { + title: 'AI Providers', + subtitle: 'Quản lý cấu hình AI providers.', + unableToLoad: 'Không thể tải AI Providers', + }, }, }, ja: { @@ -4253,6 +6864,7 @@ const resources = { factoryDroid: 'Factory Droid', system: 'システム', health: 'ヘルス', + logs: 'ログ', settings: '設定', openrouterTooltip: '注目: OpenRouter + Alibaba Coding Plan + Ollama', }, @@ -4391,6 +7003,7 @@ const resources = { cancel: 'キャンセル', savePreset: 'プリセットを保存', applyPreset: 'プリセットを適用', + deletePreset: 'プリセットを削除', }, componentModelSelector: { selectModel: 'モデルを選択', @@ -4432,6 +7045,13 @@ const resources = { recommended: '推奨', allModelsCount: 'すべてのモデル ({{count}})', noModelsAvailable: '利用可能なモデルはありません', + shadowed: 'シャドウ済み', + prefixOnly: 'プレフィックスのみ', + current: '現在', + currentValue: '現在の値', + preferredPinnedModel: '優先ピンモデル:', + pinnedRouteStatus: 'ピンルートの状態:', + pinnedModelNotAdvertised: 'ピンモデルは現在プロキシから通知されていません: {{model}}', }, createAuthProfileDialog: { title: '新しいアカウントを作成', @@ -5081,6 +7701,8 @@ const resources = { }, settingsTabs: { web: 'Web検索', + image: '画像', + channels: 'チャンネル', env: '環境変数', think: '思考', proxy: 'プロキシ', @@ -5672,6 +8294,870 @@ const resources = { retryContent: '再読み込み', noMarkdown: 'Markdownコンテンツはありません。', }, + + accountCardStats: { + notUsedYet: '未使用', + }, + accountQuotaPanel: { + weekly: '週間', + loadingQuota: 'クォータを読み込み中...', + }, + accountSurfaceCard: { + business: 'ビジネス', + personal: '個人', + variant: 'バリアント', + }, + aiProvidersEntryCard: { + apiKeys: 'API Keys', + }, + aiProvidersEntryDialog: { + connectorName: 'コネクタ名', + baseUri: 'ベース URL', + proxyUrl: 'プロキシ URL', + secret: 'シークレット', + prefix: 'プレフィックス', + excludedModels: '除外モデル', + headers: 'ヘッダー', + modelMappings: 'モデルマッピング', + requiredSetup: '必要なセットアップ', + optionalRouting: 'オプションのルーティング', + }, + aiProvidersFamilyRail: { + current: '現在', + }, + aiProvidersPage: { + title: 'AI プロバイダー', + subtitle: 'AI プロバイダーの設定を管理します。', + unableToLoad: 'AI プロバイダーを読み込めませんでした', + }, + analyticsCards: { + cacheCost: 'キャッシュコスト', + hitRate: 'ヒット率', + inputOutputRatio: '入力/出力比', + noCacheData: 'キャッシュデータがありません', + noModelData: 'モデルデータがありません', + noSessionData: 'セッションデータがありません', + noTokenData: 'トークンデータがありません', + totalCost: '合計コスト', + totalTokens: '合計トークン', + usageInsights: '利用インサイト', + }, + analyticsPageV2: { + title: '分析', + subtitle: '利用分析とインサイト。', + }, + analyticsPages: { + chartsGrid: 'チャート', + costByModel: 'モデル別コスト', + }, + apiPage: { + title: 'API プロファイル', + subtitle: 'API プロファイルとエンドポイントを管理します。', + }, + authMonitorLive: { + live: 'ライブ', + accountMonitor: 'アカウントモニター', + updated: '{{time}} に更新', + updatedNow: 'たった今更新', + requestsLabel: '件', + stats: '統計', + successRate: '成功率', + missingProjectId: 'プロジェクト ID がありません', + noActivity: 'アクティビティなし', + }, + bulkActionBar: { + applyPreset: 'プリセットを適用', + }, + ccsLogo: { + alt: 'CCS ロゴ', + text: 'CCS Config', + }, + claudeExtensionPage: { + title: 'Claude Extension', + subtitle: 'Claude Extension の連携設定。', + claudeAuth: 'Claude 認証', + status: 'ステータス', + targetMetadata: 'IDE ターゲットメタデータ', + }, + claudekitBadge: { + title: 'Powered by ClaudeKit Framework', + alt: 'ClaudeKit', + poweredBy: 'Powered by', + claudekit: 'ClaudeKit', + }, + cliproxyConfig: { + unsavedChanges: '未保存の変更', + original: '変更前', + modified: '変更後', + reviewChanges: '変更を確認', + loadingEditor: 'エディターを読み込み中...', + }, + cliproxyHeader: { + ccsLevelAccountManagement: 'CCS レベルのアカウント管理', + cliproxyNotAvailable: 'CLIProxy は利用できません', + cliproxyControlPanel: 'CLIProxy コントロールパネル', + noVariants: 'CLIProxy バリアントが見つかりません。', + addAccountToStart: 'アカウントを追加して開始', + }, + cliproxyStatsOverview: { + sessionStatistics: 'セッション統計', + realTimeMetrics: '{{backend}} からのリアルタイム利用指標', + offline: 'オフライン', + running: '稼働中', + noActiveSession: 'アクティブなセッションなし', + noActiveSessionHint: + 'リアルタイム統計を表示するには、ccs gemini、ccs codex、または ccs agy で CLIProxy セッションを開始してください。', + failedLoadStats: '統計の読み込みに失敗しました', + totalRequests: '総リクエスト数', + successCount: '{{count}} 件成功', + successRate: '成功率', + totalTokens: '総トークン数', + estimatedCost: '推定約 ${{cost}}', + modelsUsed: '使用モデル', + modelUsageDistribution: 'モデル別利用分布', + requestCount: '{{count}} リクエスト', + }, + cliproxyTable: { + name: '名前', + provider: 'プロバイダー', + model: 'モデル', + account: 'アカウント', + status: 'ステータス', + default: 'デフォルト', + actions: '操作', + }, + cliproxyTabs: { + overview: '概要', + variants: 'バリアント', + aiProviders: 'AI プロバイダー', + controlPanel: 'コントロールパネル', + }, + codeEditor: { + revealSensitive: '機密値を表示', + maskSensitive: '機密値を隠す', + valid: '有効な {{language}}', + readOnly: '(読み取り専用)', + }, + codex: { + controlCenter: 'コントロールセンター', + overview: '概要', + docs: 'ドキュメント', + nativeCodexRuntime: 'ネイティブ Codex ランタイム', + ccsCodexProvider: 'CCS Codex プロバイダー / ブリッジ', + codexDocs: 'Codex ドキュメント', + supportedFlows: '対応フロー', + twoSupportedPaths: '2つのサポートパス:', + nativeLabel: 'ネイティブ:', + nativeDesc: 'Codex は CCS v1 でファーストクラスのランタイム専用ターゲットです。', + ccsBridge: 'CCS Bridge', + apiProfilesDefault: 'API プロファイルのデフォルトは引き続き Claude または Droid です。', + recommendedSetupFlow: '推奨セットアップフロー', + fastestPath: '最速パス', + officialChannels: '公式チャンネル', + codexCli: 'Codex CLI', + openNativeCodex: 'ネイティブ Codex を開く', + runBuiltInCodex: '内蔵 Codex で Codex を実行', + runBuiltInCodexExplicit: '内蔵 Codex で Codex を実行(明示)', + openCodexDashboard: 'Codex ダッシュボードを開く', + status: 'ステータス', + profiles: 'プロファイル', + createNewProfile: '新しいプロファイルを作成', + createNewProvider: '新しいプロバイダーを作成', + createNewMcpServer: '新しい MCP サーバーを作成', + defaultProvider: 'デフォルトプロバイダー', + useDefault: 'デフォルトを使用', + useGlobalProvider: 'グローバルプロバイダーを使用', + useProviderDefault: 'プロバイダーのデフォルトを使用', + quickFillWarning: 'クイック入力のみです。保存前に確認してください。', + thisFileUpstreamOwned: 'このファイルは Codex CLI の上流管理対象です。', + notes: 'メモ', + approvalPolicy: '承認ポリシー', + sandboxMode: 'サンドボックスモード', + reasoningEffort: '推論強度', + useGlobalEffort: 'グローバル設定を使用', + reasoningEffortCapitalized: '推論強度', + thinkingBudgetTokens: '思考予算トークン', + modelContextWindow: 'モデルコンテキストウィンドウ', + autoCompactTokenLimit: '自動圧縮トークン上限', + toolOutputTokenLimit: 'ツール出力トークン上限', + webSearch: 'Web 検索', + personality: 'パーソナリティ', + model: 'モデル', + rawOnly: 'Raw のみ', + trusted: '信頼済み', + untrusted: '未信頼', + noProjectTrustEntries: '明示的なプロジェクト信頼エントリはありません。', + codexNativeRecipe: 'ネイティブ Codex レシピを保存しました', + gptContextCap: 'GPT-5.4 コンテキスト上限', + usageLimitCost: '272K 超過時の利用制限コスト', + longContextOverride: '長文コンテキストオーバーライド', + counts2x: '2倍カウント', + normalUsageWindow: '通常利用ウィンドウ', + useCodexDefault: 'Codex のデフォルトを使用', + stdio: 'stdio', + streamableHttp: 'streamable-http', + responses: 'responses', + defaultTargetCli: 'デフォルトターゲット CLI', + executionChain: '実行チェーン', + targetPath: '現在のターゲットパス', + userConfig: 'ユーザー設定', + configYaml: 'config.yaml', + flow: 'フロー', + docsTab: 'ドキュメント', + }, + codexPage: { + title: 'Codex', + controlCenter: 'コントロールセンター', + overview: '概要', + docs: 'ドキュメント', + nativeRuntime: 'ネイティブランタイム', + ccsProvider: 'CCS プロバイダー', + setup: 'セットアップ', + }, + commandBuilder: { + title: 'コマンドビルダー', + searchPlaceholder: 'コマンドを入力または選択...', + copy: 'コピー', + run: '実行', + cmdConfig: '設定画面を開く', + cmdCreateProfile: '新しいプロファイルを作成', + cmdSwitchProfile: 'プロファイルを切り替え', + cmdDoctor: 'システムヘルスチェック', + cmdListProviders: '利用可能な CLIProxy プロバイダーを一覧表示', + cmdAddProvider: 'CLIProxy プロバイダーを追加', + }, + confirmDialog: { + confirm: '確認', + cancel: 'キャンセル', + }, + connectionIndicator: { + connected: '接続済み', + connecting: '接続中...', + disconnected: '切断', + reconnecting: '再接続中...', + }, + copilotConfigForm: { + copilotConfiguration: 'Copilot 設定', + deprecatedModels: '非推奨の Copilot モデルが検出されました', + failedLoadStatus: 'ステータスの読み込みに失敗しました', + useWithClaudeCode: 'GitHub Copilot サブスクリプションを Claude Code で利用する', + githubCopilotControls: + 'GitHub Copilot はプロンプト/コンテキストの制限を上流で管理しています。', + provider: 'プロバイダー', + filePath: 'ファイルパス', + status: 'ステータス', + enabled: '有効', + disabled: '無効', + loadingEditor: 'エディターを読み込み中...', + modelMapping: 'モデルマッピング', + quickUsage: 'クイック実行', + noPremiumUsage: 'プレミアム利用回数なし', + }, + copilotPresets: { + gpt5Codex: 'GPT-5.3 Codex', + claude46: 'Claude 4.6', + gemini3: 'Gemini 3', + }, + dateRangeFilter: { + pickADate: '日付を選択', + }, + deviceCodeDialog: { + authorize: '{{provider}} を認証', + enterCodeAtPage: '認証ページで下のコードを入力してください。', + expiresIn: '({{time}} 後に期限切れ)', + codeExpired: '(コードの有効期限が切れました)', + copied: 'コピーしました!', + copyCode: 'コードをコピー', + waitingForAuth: '認証を待機中...', + openVerificationPage: '認証ページを開く', + openProviderPage: '{{provider}} を開く', + copyCodeAria: '確認コードをコピー', + codeCopiedAria: 'コードをコピーしました', + }, + docsLink: { + title: 'ドキュメントを表示', + }, + droidSettings: { + quickControls: 'クイックコントロール', + reasoningControls: '推論コントロール', + thinkingBudget: '思考予算', + anthropicOnly: 'Anthropic モデルのみ', + byokCustomModels: 'BYOK カスタムモデル', + }, + extendedContext: { + extendedContext: '拡張コンテキスト', + }, + githubLink: { + title: 'GitHub で問題を報告', + }, + globalEnvIndicator: { + injectedCount_one: '{{count}} 件のグローバル環境変数が実行時に注入されます', + injectedCount_other: '{{count}} 件のグローバル環境変数が実行時に注入されます', + overriddenCount: '({{count}} 件はプロファイルで上書き)', + skippedLabel: 'スキップ(プロファイルで既に定義済み):', + configureInSettings: '設定で構成', + }, + healthCard: { + allSystemsNominal: 'すべてのシステムが正常です', + machineChecks: 'マシンチェック', + }, + healthPageV2: { + title: 'ヘルス', + subtitle: 'システムヘルスの監視。', + }, + heroSection: { + title: 'CCS Config', + subtitle: 'Claude Code Switch Dashboard', + }, + homePageV2: { + title: 'ホーム', + logsMoved: 'ログは専用ワークスペースに移動しました', + profiles: 'プロファイル', + cliproxy: 'CLIProxy', + accounts: 'アカウント', + health: 'ヘルス', + }, + hubFooter: { + logs: 'ログ', + settings: '設定', + github: 'GitHub', + copyright: '\u00a9 {{year}} kaitranntt', + }, + imageAnalysisStatus: { + sectionTitle: '画像', + openSettings: '設定を開く', + useNativeImageReading: 'ネイティブ画像読み取りを使用', + refreshingPreview: 'プレビューを更新中', + savedStatus: '保存済みステータス', + livePreview: 'ライブプレビュー', + disabledGlobally: 'グローバルで無効', + targetBypassesHook: '{{target}} はフックをバイパスします', + nativeImageReading: 'ネイティブ画像読み取り', + setupNeeded: 'セットアップが必要', + needsAuth: '認証が必要', + needsProxy: 'プロキシが必要', + nativeFallback: 'ネイティブフォールバック', + transformerReady: 'トランスフォーマー準備完了', + badgeDisabled: '無効', + badgeBypassed: 'バイパス中', + badgeNative: 'ネイティブ', + badgeSetup: 'セットアップ', + badgeAuth: '認証', + badgeProxy: 'プロキシ', + badgeReady: '準備完了', + capabilityVerified: '検証済み', + capabilityUnknown: '不明', + toggleSummaryNativeCapable: + '{{model}} は画像対応のようです。CCS はここではトランスフォーマーをバイパスします。', + toggleSummaryNativeModel: 'CCS は {{model}} でネイティブ読み取りを優先します。', + toggleSummaryNativeDefault: 'CCS はこのプロファイルでネイティブ画像読み取りを優先します。', + toggleSummaryNativeFileAccess: + 'このプロファイルは現在ネイティブファイルアクセスのままです。', + toggleSummaryInactiveTarget: + '{{target}} が選択されている間は、保存済みの Claude 側画像ルーティングは無効です。', + toggleSummaryTransformerRoute: 'トランスフォーマールート: {{backend}}{{modelSuffix}}。', + noteDisabledGlobally: '画像は CCS 設定でグローバルに無効になっています。', + noteTargetBypassesHook: + '現在のターゲット {{target}} は Claude Read フックをバイパスします。', + notePersistHook: + 'トランスフォーマールーティングをここで有効にする前に、プロファイルフックを保存してください。', + targetLabel: { + claude: 'Claude Code', + droid: 'Factory Droid', + codex: 'Codex CLI', + }, + }, + localhostDisclaimer: { + remoteReadonlyAuthDisabledLong: + 'ホストでダッシュボード認証が無効になっているため、リモートダッシュボードは読み取り専用です。リモートでの変更を有効にするには、ホスト側でダッシュボード認証を再度有効にしてください。', + remoteReadonlyAuthDisabledShort: + 'ホストでダッシュボード認証が再有効化されるまで、リモートダッシュボードは読み取り専用です。', + remoteReadonlySetupLong: + 'ホストで ccs config auth setup を実行するまで、リモートダッシュボードは読み取り専用です。', + remoteReadonlySetupShort: + 'ホストの認証が設定されるまで、リモートダッシュボードは読み取り専用です。', + localLong: + 'このダッシュボードはローカルで動作しています。データはすべてお使いのマシンに残ります。', + localShort: 'ローカルダッシュボード - データはお使いのデバイスに保存されます。', + dismiss: '免責事項を閉じる', + }, + loginPage: { + showPassword: 'パスワードを表示', + hidePassword: 'パスワードを隠す', + }, + logsConfig: { + level: 'レベル', + message: 'メッセージ', + source: 'ソース', + time: '時刻', + proc: 'プロセス', + open: '開く', + run: '実行', + refreshEntries: 'エントリを更新', + }, + logsDetailPanel: { + details: '詳細', + }, + logsFilters: { + filters: 'フィルター', + }, + logsOverviewCards: { + overview: '概要', + }, + logsPageSkeleton: { + loadingLogs: 'ログを読み込み中...', + }, + logsPageV2: { + title: 'ログ', + subtitle: 'システムログの表示と管理。', + }, + modelConfigSection: { + defaultModel: 'デフォルトモデル', + }, + monitoringErrorLogs: { + logContent: 'ログ内容', + }, + openrouterBadge: { + new: 'NEW', + integration: 'OpenRouter 連携', + }, + openrouterBanner: { + accessModels: 'OpenRouter で {{count}}+ のモデルにアクセス', + add: '追加', + }, + openrouterModelPicker: { + searchModels: 'モデルを検索', + newestModels: '最新モデル', + }, + openrouterPromoCard: { + title: 'OpenRouter', + description: '1つの API エンドポイントで数百のモデルにアクセス。', + }, + privacyToggle: { + modeOn: 'プライバシーモード ON - クリックしてデータを表示', + modeOff: 'プライバシーモード OFF - クリックしてデータを隠す', + }, + profileCard: { + profile: 'プロファイル', + openRouter: 'OpenRouter プロファイル', + claudeCode: 'Claude Code', + claudeCodeDefault: 'Claude Code(デフォルト)', + factoryDroid: 'Factory Droid', + codexCli: 'Codex CLI', + ccsProfile: 'CCS プロファイル', + }, + profileCreateDialog: { + createProfile: 'プロファイルを作成', + appliedModelToTiers: 'すべてのモデルティアに「{{model}}」を適用しました', + profileCreated: 'プロファイル「{{name}}」を作成しました', + failedCreate: 'プロファイルの作成に失敗しました', + chooseProviderHint: + 'プロバイダーのプリセットを選ぶか、カスタム API エンドポイントを設定してください。', + basicInformation: '基本情報', + modelConfiguration: 'モデル設定', + usedInCli: 'CLI での使用:', + apiBaseUrl: 'API ベース URL', + baseUrlPlaceholder: 'https://api.example.com/v1', + prefilledFromPreset: '{{name}} から自動入力されています。必要に応じて変更できます。', + optionalForPreset: + '{{name}} では任意です。空欄の場合はネイティブの Anthropic 認証を使用します。', + endpointHint: 'OpenAI 互換および Anthropic リクエストを受け付けるエンドポイント', + optional: '(任意)', + apiKeyOptionalPlaceholder: '任意 - 認証が有効な場合のみ', + apiKeyPlaceholder: 'sk-...', + apiKeyOptionalHint: 'ローカルエンドポイントで認証が有効な場合のみ必要です', + defaultTargetCli: 'デフォルトターゲット CLI', + modelMapping: 'モデルマッピング', + modelMappingDesc: + 'Claude Code のティア(Opus/Sonnet/Haiku)をプロバイダー対応モデルにマッピングします。', + searchModelsPlaceholder: '検索(例: opus, sonnet, gpt-4o)...', + noModelsFound: '「{{query}}」に一致するモデルが見つかりません', + loadingModels: 'モデルを読み込み中...', + defaultModel: 'デフォルトモデル', + sonnetMapping: 'Sonnet マッピング', + opusMapping: 'Opus マッピング', + haikuMapping: 'Haiku マッピング', + sonnetMappingPlaceholder: '例: gpt-4o, claude-sonnet-4', + opusMappingPlaceholder: '例: o1, claude-opus-4.5', + haikuMappingPlaceholder: '例: gpt-4o-mini, claude-3.5-haiku', + free: '無料', + }, + profileDeck: { + profiles: 'プロファイル', + failedToLoad: 'プロファイルの読み込みに失敗しました: {{message}}', + noProfiles: 'プロファイルが設定されていません。最初のプロファイルを作成して始めましょう。', + }, + profileDialogLegacy: { + editProfile: 'プロファイルを編集', + }, + profileEditorSections: { + imageAnalysis: '画像分析', + loadingImageSettings: '画像設定を読み込み中...', + skipPermissionPrompts: '起動時に権限プロンプトをスキップ', + useNativeImageReading: 'ネイティブ画像読み取りを使用', + skipTransformer: 'トランスフォーマーをスキップ', + friendlyUi: 'フレンドリー UI', + info: '情報', + }, + profilesTable: { + name: '名前', + provider: 'プロバイダー', + model: 'モデル', + target: 'ターゲット', + lastModified: '最終更新', + actions: '操作', + edit: '編集', + }, + projectSelectionDialog: { + title: 'Google Cloud プロジェクトを選択', + description: '{{provider}} の認証に使用するプロジェクトを選択してください。', + autoSelectCountdown: '({{count}}秒後にデフォルトを自動選択)', + default: 'デフォルト', + allProjects: '全プロジェクト', + allProjectsDescription: 'リスト内の {{count}} プロジェクトをすべてオンボード', + useDefault: 'デフォルトを使用', + selecting: '選択中...', + confirmSelection: '選択を確認', + codeCopied: 'コードをコピーしました', + copyVerificationCode: '確認コードをコピー', + }, + providerCard: { + missingProjectIdAria: 'プロジェクト ID がありません', + }, + providerEditor: { + provider: 'プロバイダー', + filePath: 'ファイルパス', + lastModified: '最終更新', + defaultTarget: 'デフォルトターゲット', + quickUsage: 'クイック実行', + modelMapping: 'モデルマッピング', + status: 'ステータス', + loadingSettings: '設定を読み込み中...', + loadingEditor: 'エディターを読み込み中...', + noAccountsConnected: '接続されたアカウントなし', + addAccountToStart: 'アカウントを追加して開始', + gcpProjectIdReadonly: 'GCP プロジェクト ID(読み取り専用)', + projectIdNA: 'プロジェクト ID: N/A', + missingProjectId: 'プロジェクト ID がありません', + missingProjectIdHint: + 'エラーの原因になる可能性があります。アカウントを削除して再追加し、プロジェクト ID を取得してください。', + useIncognito: 'シークレットモードを使用', + aliases: 'エイリアス', + current: '現在', + currentValue: '現在の値', + composite: '複合', + defaultLabel: 'デフォルト', + requiredSetup: '必要なセットアップ', + connectorName: 'コネクタ名', + proxyUrl: 'プロキシ URL', + proxyUrlSet: 'プロキシ URL 設定済み', + excludedModels: '除外モデル', + headers: 'ヘッダー', + secret: 'シークレット', + prefix: 'プレフィックス', + modelMappings: 'モデルマッピング', + baseUri: 'ベース URL', + apiKeys: 'API Keys', + presets: '事前設定済みのモデルマッピングを適用', + createVariant: 'CLIProxy バリアントを作成', + agyDenylist: + 'Antigravity ブロックリスト: Claude Opus 4.5 と Claude Sonnet 4.5 は非推奨です。', + }, + providerEditorAccountItem: { + modelsUsed: '使用モデル', + }, + providerEditorHeader: { + connectorName: 'コネクタ名', + }, + quickCommands: { + title: 'クイックコマンド', + startDefault: 'デフォルトで起動', + startDefaultDesc: 'デフォルトプロファイルで Claude を起動', + glmProfile: 'GLM プロファイル', + glmProfileDesc: 'GLM モデルに切り替え', + healthCheck: 'ヘルスチェック', + healthCheckDesc: 'システム診断を実行', + delegateTask: 'タスクを委譲', + delegateTaskDesc: 'GLM プロファイルに委譲', + }, + quotaTooltip: { + loadingQuota: 'クォータを読み込み中...', + failedLoadQuota: 'クォータの読み込みに失敗しました', + modelQuotas: 'モデルクォータ:', + rateLimits: 'レート制限:', + plan: 'プラン: {{plan}}', + quotaSnapshots: 'クォータスナップショット:', + unlimited: '無制限', + remaining: '残り {{remaining}}/{{entitlement}}', + tier: 'ティア', + tierId: 'ティア ID', + state: '状態', + credits: 'クレジット', + modelQuotasLower: 'モデルクォータ:', + allBucketsReport: '全バケットが {{tokenType}} を報告', + requestsRemaining: '残り {{count}} リクエスト', + inputTokensRemaining: '残り {{count}} 入力トークン', + outputTokensRemaining: '残り {{count}} 出力トークン', + amountRemaining: '残り {{count}}', + fiveHourLimit: '5時間利用制限', + weeklyLimit: '週間利用制限', + weeklyOpus: '週間利用(Opus)', + weeklySonnet: '週間利用(Sonnet)', + weeklyOAuthApps: '週間利用(OAuth アプリ)', + weeklyCowork: '週間利用(Cowork)', + extraUsage: '追加利用', + premiumInteractions: 'プレミアムインタラクション', + chat: 'チャット', + completions: '補完', + resets: '{{time}} にリセット', + fiveHourResets: '5時間リセット {{time}}', + weeklyResets: '週間リセット {{time}}', + }, + rawEditorSection: { + rawConfig: 'Raw 設定', + }, + rawJsonSettingsEditor: { + title: 'Raw 設定エディター', + }, + routingGuidance: { + roundRobin: 'ラウンドロビンで利用を分散します。', + fillFirst: 'Fill first は、バックアップアカウントが必要になるまで待機させます。', + routingStrategy: 'ルーティング戦略', + optionalRouting: 'オプションのルーティング', + }, + settingsDialog: { + editProfile: 'プロファイルを編集: {{name}}', + description: 'このプロファイルの環境変数と設定を構成します。', + loadingSettings: '設定を読み込み中...', + envTab: '環境変数', + rawJsonTab: 'Raw JSON', + generalTab: '全般', + noEnvVars: '環境変数は設定されていません。', + noEnvVarsHint: 'settings.json ファイルで変数を追加してください。', + loadingEditor: 'エディターを読み込み中...', + profileInfo: 'プロファイル情報', + profileInfoDesc: 'この設定ファイルの詳細情報。', + path: 'パス', + lastModified: '最終更新', + cancel: 'キャンセル', + saving: '保存中...', + saveChanges: '変更を保存', + conflictTitle: 'ファイルが外部で変更されました', + conflictDesc: + 'この設定ファイルは別のプロセスで変更されました。変更で上書きしますか?それとも破棄しますか?', + overwrite: '上書き', + }, + settingsPage: { + title: '設定', + loading: '読み込み中...', + failedLoad: '設定の読み込みに失敗しました。', + tabs: { + web: 'Web', + env: '環境変数', + think: '思考', + proxy: 'プロキシ', + auth: '認証', + backup: 'バックアップ', + channels: 'チャンネル', + imageAnalysis: '画像', + }, + websearchSection: { + title: 'Web 検索', + description: 'CLI ベースの Web 検索設定。', + }, + thinkingSection: { + title: '思考', + description: '対応モデルの高度な思考 / 推論設定。', + directOverride: '直接上書き', + youType: '入力:', + ccsAdds: 'CCS が追加:', + executionChain: '実行チェーン', + primaryBackends: 'プライマリバックエンド', + legacyCliFallbacks: 'レガシー CLI フォールバック', + managedPayload: '管理対象ペイロード', + sharedTargetMetadata: '共有ターゲットメタデータ', + ideTargetMetadata: 'IDE ターゲットメタデータ', + ideSettingsPath: 'IDE 設定パス', + ideHost: 'IDE ホスト', + resolvedBinding: '解決済みバインディング', + bindingName: 'バインディング名', + inSync: '同期済み', + currentTargetPath: '現在のターゲットパス', + warnings: '警告', + notes: 'メモ', + workspacePresets: 'ワークスペースプリセット', + draft: '下書き', + advanced: '詳細設定', + recommended: '推奨セットアップフロー', + configureModelFirst: '先にモデルを設定してください', + }, + proxySection: { + title: 'プロキシ', + loadingImageSettings: '画像設定を読み込み中...', + }, + channelsSection: { + title: '公式チャンネル', + description: '公式リリースチャンネルを表示・管理。', + }, + imageAnalysisSection: { + title: '画像分析', + description: '画像分析の設定。', + loading: '画像設定を読み込み中...', + }, + }, + setupWizard: { + title: 'クイックセットアップウィザード', + stepProviderDesc: 'プロバイダーを選択して開始', + stepAuthDesc: 'プロバイダーで認証', + stepAccountDesc: '使用するアカウントを選択', + stepVariantDesc: 'カスタムバリアントを作成', + stepSuccessDesc: 'セットアップ完了!', + authStep: { + authenticateWith: '{{provider}} で認証してアカウントを追加', + authenticating: '認証中...', + authenticateInBrowser: 'ブラウザーで認証', + completeOAuth: 'ブラウザーで OAuth フローを完了してください...', + orUseTerminal: 'またはターミナルを使用', + runCommandHint: 'ターミナルで次のコマンドを実行:', + back: '戻る', + checking: '確認中...', + refreshStatus: 'ステータスを更新', + }, + accountStep: { + selectAccount: 'アカウントを選択({{count}})', + defaultAccount: 'デフォルトアカウント', + or: 'または', + addNewAccount: '新しいアカウントを追加', + addNewAccountDesc: '別のアカウントで認証', + back: '戻る', + }, + variantStep: { + back: '戻る', + skip: 'スキップ', + }, + successStep: { + title: 'バリアントを作成しました!', + subtitle: 'カスタムバリアントが使用可能です', + usage: '使用方法:', + done: '完了', + }, + }, + sharedPageV2: { + title: '共有', + subtitle: '共有データ管理。', + }, + sponsorButton: { + title: 'GitHub でこのプロジェクトをスポンサー', + sponsor: 'スポンサー', + }, + supportEntryCard: { + actionRequired: '対応が必要', + }, + themeToggle: { + srLabel: 'テーマを切り替え', + }, + toasts: { + profileCreated: 'プロファイルを作成しました', + profileUpdated: 'プロファイルを更新しました', + profileDeleted: 'プロファイルを削除しました', + orphanProfilesComplete: '孤立プロファイルの登録が完了しました', + profileCopied: 'プロファイルをコピーしました', + profileImported: 'プロファイルをインポートしました', + authRequired: '{{provider}} の認証が必要です', + authSuccess: '{{provider}} の認証に成功しました!', + authFailed: '{{provider}} の認証に失敗しました', + deviceCodeExpired: 'デバイスコードの有効期限が切れました。もう一度お試しください。', + codeCopied: 'コードをクリップボードにコピーしました', + failedCopy: 'コードのコピーに失敗しました', + configSaved: '設定を保存しました', + configSaveFailed: '保存に失敗しました: {{error}}', + invalidYaml: '無効な YAML は保存できません', + configUpdatedExternally: '設定が外部で更新されました', + settingsFileUpdated: '設定ファイルが更新されました', + accountsUpdated: 'アカウントを更新しました', + noProfilesToSync: '同期するプロファイルがありません', + syncFailed: '同期に失敗しました: {{error}}', + providerAuthSuccess: '{{provider}} の認証に成功しました', + providerDeviceCodeInCallback: + 'コールバックモードでプロバイダーがデバイスコードフローを返しました', + loggingConfigSaved: 'ログ設定を保存しました。', + loggingConfigSaveFailed: 'ログ設定の保存に失敗しました。', + unifiedConfigUpdated: '設定を更新しました', + migrationPreviewComplete: '移行プレビューが完了しました', + migrationComplete: '移行が完了しました', + migrationFailed: '移行に失敗しました', + rollbackComplete: 'ロールバックが完了しました', + rollbackFailed: 'ロールバックに失敗しました', + defaultAccountSet: 'デフォルトアカウントを「{{name}}」に設定しました', + defaultAccountReset: 'デフォルトアカウントを CCS に戻しました', + accountDeleted: 'アカウント「{{name}}」を削除しました', + contextUpdated: '「{{name}}」のコンテキストを {{summary}} に更新しました', + legacyConfirmError: + 'アカウント「{{name}}」は明示的な確認が必要です。このアカウントの「履歴同期を編集」を使用してください。', + legacyConfirmFailed: 'レガシーアカウント「{{name}}」の確認に失敗しました: {{error}}', + legacyConfirmSuccess_one: '{{count}} 件のレガシーアカウントを確認しました', + legacyConfirmSuccess_other: '{{count}} 件のレガシーアカウントを確認しました', + noLegacyAccounts: '確認が必要なレガシーアカウントはありません', + routingStrategySet: 'ルーティング戦略を {{strategy}} に設定しました', + variantCreated: 'バリアントを作成しました', + variantUpdated: 'バリアントを更新しました', + variantDeleted: 'バリアントを削除しました', + defaultAccountUpdated: 'デフォルトアカウントを更新しました', + accountRemoved: 'アカウントを削除しました', + accountPaused: 'アカウントを一時停止しました', + accountResumed: 'アカウントを再開しました', + accountAdded: '{{provider}} のアカウントを追加しました', + kiroImported: 'Kiro アカウントをインポートしました: {{name}}', + kiroTokenImported: 'Kiro トークンをインポートしました', + modelUpdated: 'モデルを更新しました', + presetSaved: 'プリセット「{{name}}」を保存しました', + presetDeleted: 'プリセットを削除しました', + cliproxyAlreadyRunning: 'CLIProxy はすでに稼働していました', + cliproxyStarted: 'CLIProxy を起動しました', + cliproxyStartFailed: 'CLIProxy の起動に失敗しました', + cliproxyStopped: 'CLIProxy を停止しました', + cliproxyStopFailed: 'CLIProxy の停止に失敗しました', + presetApplied: 'プリセット「{{name}}」を適用しました', + presetAppliedCustom: 'カスタムプリセットを適用しました', + settingsSavedWithAdjustments: '設定を保存しました(モデル調整あり)', + settingsSaved: '設定を保存しました', + failedSaveSettings: '設定の保存に失敗しました', + codexRefreshFailed: + 'Codex スナップショットの更新に失敗しました。Raw 編集は保持されました。', + codexRefreshError: 'Codex スナップショットの更新に失敗しました。', + codexFixToml: '保存する前に TOML を修正してください。', + codexSaved: 'Codex config.toml を保存しました。', + codexChangedExternally: 'config.toml が外部で変更されました。更新して再試行してください。', + codexSaveFailed: 'Codex config.toml の保存に失敗しました。', + codexUpdateFailed: 'Codex 設定の更新に失敗しました。', + noOrphanProfiles: '孤立プロファイル設定は見つかりませんでした', + profilesRegistered: '{{count}} 件のプロファイルを登録しました{{skipped}}', + destinationEmpty: '送信先プロファイル名は空にできません', + profileExportDownloaded: 'プロファイルエクスポートをダウンロードしました', + profileImportFailed: 'プロファイルバンドルのインポートに失敗しました', + }, + updatesSpotlight: { + openUpdatesCenter: '更新センターを開く', + }, + userMenu: { + signedInAs: '{{username}} としてサインイン中', + }, + valueMetrics: { + apiCostSaved: 'API コスト削減', + tokensSaved: '節約トークン', + queriesFaster: '高速化されたクエリ', + errorsReduced: '削減されたエラー', + vsLastMonth: '前月比', + throughCaching: 'キャッシュによる', + averageSpeedup: '平均高速化', + withRetryLogic: 'リトライロジックによる', + performanceMetrics: 'パフォーマンス指標', + monthlySummary: '月間サマリー', + totalSaved: '合計節約額', + tokensProcessed: '処理トークン数', + queriesHandled: '処理クエリ数', + uptime: '稼働時間', + }, }, }, } as const; diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index a37ba349..025ddc01 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -139,6 +139,7 @@ function resolveGeminiPreviewModelId( } /** Model catalog data - mirrors src/cliproxy/model-catalog.ts */ +// TODO i18n: missing keys for MODEL_CATALOGS displayNames, model names, and descriptions export const MODEL_CATALOGS: Record = { agy: { provider: 'agy', diff --git a/ui/src/lib/openrouter-utils.ts b/ui/src/lib/openrouter-utils.ts index d651b62c..84d0e16a 100644 --- a/ui/src/lib/openrouter-utils.ts +++ b/ui/src/lib/openrouter-utils.ts @@ -4,6 +4,7 @@ */ import type { OpenRouterModel, CategorizedModel, ModelCategory } from './openrouter-types'; +import i18n from './i18n'; const CACHE_KEY = 'ccs:openrouter-models'; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours @@ -19,8 +20,8 @@ export function pricePerMillion(perToken: string): number { /** Format price for display */ export function formatPrice(perToken: string): string { const perMillion = pricePerMillion(perToken); - if (perMillion === 0) return 'Free'; - if (perMillion < 0.01) return '<$0.01'; + if (perMillion === 0) return i18n.t('openrouterUtils.priceFree'); + if (perMillion < 0.01) return i18n.t('openrouterUtils.priceLessThanCent'); if (perMillion < 1) return `$${perMillion.toFixed(2)}`; return `$${perMillion.toFixed(perMillion < 10 ? 2 : 0)}`; } @@ -29,7 +30,11 @@ export function formatPrice(perToken: string): string { export function formatPricingPair(pricing: { prompt: string; completion: string }): string { const promptPrice = formatPrice(pricing.prompt); const completionPrice = formatPrice(pricing.completion); - if (promptPrice === 'Free' && completionPrice === 'Free') return 'Free'; + if ( + promptPrice === i18n.t('openrouterUtils.priceFree') && + completionPrice === i18n.t('openrouterUtils.priceFree') + ) + return i18n.t('openrouterUtils.priceFree'); return `${promptPrice}/${completionPrice}`; } @@ -248,10 +253,13 @@ export function formatModelAge(created: number): string { const now = Date.now() / 1000; // Convert to seconds const diff = now - created; - if (diff < 86400) return 'Today'; - if (diff < 172800) return 'Yesterday'; - if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`; - if (diff < 2592000) return `${Math.floor(diff / 604800)}w ago`; - if (diff < 31536000) return `${Math.floor(diff / 2592000)}mo ago`; - return `${Math.floor(diff / 31536000)}y ago`; + if (diff < 86400) return i18n.t('openrouterUtils.ageToday'); + if (diff < 172800) return i18n.t('openrouterUtils.ageYesterday'); + if (diff < 604800) + return i18n.t('openrouterUtils.ageDaysAgo', { count: Math.floor(diff / 86400) }); + if (diff < 2592000) + return i18n.t('openrouterUtils.ageWeeksAgo', { count: Math.floor(diff / 604800) }); + if (diff < 31536000) + return i18n.t('openrouterUtils.ageMonthsAgo', { count: Math.floor(diff / 2592000) }); + return i18n.t('openrouterUtils.ageYearsAgo', { count: Math.floor(diff / 31536000) }); } diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index 35e9822e..7b26a31e 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -239,7 +239,7 @@ const PROVIDER_NAMES: Record = { export function getProviderDisplayName(provider: unknown): string { const normalized = normalizeProviderInput(provider); if (!normalized) { - return 'Unknown provider'; + return 'Unknown provider'; // TODO i18n: missing key } return PROVIDER_NAMES[normalized] || String(provider); } @@ -283,7 +283,7 @@ export function isDeviceCodeProvider(provider: unknown): boolean { export function getDeviceCodeProviderDisplayName(provider: unknown): string { const normalized = normalizeProviderInput(provider); if (!normalized) { - return 'Unknown provider'; + return 'Unknown provider'; // TODO i18n: missing key } if (isValidProvider(normalized)) { return DEVICE_CODE_PROVIDER_DISPLAY_NAMES[normalized] || getProviderDisplayName(normalized); @@ -296,10 +296,10 @@ export function getDeviceCodeProviderInstruction(provider: unknown): string { const normalized = normalizeProviderInput(provider); if (isValidProvider(normalized)) { return ( - DEVICE_CODE_PROVIDER_INSTRUCTIONS[normalized] || 'Complete the authorization in your browser.' + DEVICE_CODE_PROVIDER_INSTRUCTIONS[normalized] || 'Complete the authorization in your browser.' // TODO i18n: missing key ); } - return 'Complete the authorization in your browser.'; + return 'Complete the authorization in your browser.'; // TODO i18n: missing key } /** Kiro auth methods exposed in CCS UI (aligned with CLIProxyAPIPlus support). */ @@ -326,36 +326,36 @@ export const DEFAULT_KIRO_AUTH_METHOD: KiroAuthMethod = 'aws'; export const KIRO_AUTH_METHOD_OPTIONS: readonly KiroAuthMethodOption[] = [ { id: 'aws', - label: 'AWS Builder ID (Recommended)', - description: 'Device code flow for AWS organizations and Builder ID accounts.', + label: 'AWS Builder ID (Recommended)', // TODO i18n: missing key for kiro auth method aws + description: 'Device code flow for AWS organizations and Builder ID accounts.', // TODO i18n: missing key flowType: 'device_code', startEndpoint: 'start', }, { id: 'aws-authcode', - label: 'AWS Builder ID (Auth Code)', - description: 'Authorization code flow via CLI binary.', + label: 'AWS Builder ID (Auth Code)', // TODO i18n: missing key + description: 'Authorization code flow via CLI binary.', // TODO i18n: missing key flowType: 'authorization_code', startEndpoint: 'start', }, { id: 'google', - label: 'Google OAuth', - description: 'Social OAuth flow with callback URL support.', + label: 'Google OAuth', // TODO i18n: missing key + description: 'Social OAuth flow with callback URL support.', // TODO i18n: missing key flowType: 'authorization_code', startEndpoint: 'start-url', }, { id: 'github', - label: 'GitHub OAuth', - description: 'Social OAuth flow via management API callback.', + label: 'GitHub OAuth', // TODO i18n: missing key + description: 'Social OAuth flow via management API callback.', // TODO i18n: missing key flowType: 'authorization_code', startEndpoint: 'start-url', }, { id: 'idc', - label: 'AWS Identity Center (IDC)', - description: 'Use your organization start URL with auth code or device flow.', + label: 'AWS Identity Center (IDC)', // TODO i18n: missing key + description: 'Use your organization start URL with auth code or device flow.', // TODO i18n: missing key flowType: 'authorization_code', startEndpoint: 'start', }, diff --git a/ui/src/lib/support-updates-catalog.ts b/ui/src/lib/support-updates-catalog.ts index 8d3cbd61..ac248b36 100644 --- a/ui/src/lib/support-updates-catalog.ts +++ b/ui/src/lib/support-updates-catalog.ts @@ -48,10 +48,10 @@ export interface CliSupportEntry { } export const SUPPORT_SCOPE_LABELS: Record = { - target: 'Target CLI', - cliproxy: 'CLIProxy Provider', - 'api-profiles': 'API Profile', - websearch: 'WebSearch', + target: 'Target CLI', // TODO i18n: missing key for support scope target + cliproxy: 'CLIProxy Provider', // TODO i18n: missing key for support scope cliproxy + 'api-profiles': 'API Profile', // TODO i18n: missing key for support scope api-profiles + websearch: 'WebSearch', // TODO i18n: missing key for support scope websearch }; export const SUPPORT_NOTICES: SupportNotice[] = [ diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 0105d1e1..e50d0c5b 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -246,13 +246,13 @@ export interface TieredModel { export function getTierLabel(tier: ModelTier): string { switch (tier) { case 'primary': - return 'Claude & GPT'; + return i18n.t('utils.tierPrimary'); case 'gemini-3': - return 'Gemini 3'; + return i18n.t('utils.tierGemini3'); case 'gemini-2': - return 'Gemini 2.5'; + return i18n.t('utils.tierGemini2'); case 'other': - return 'Other'; + return i18n.t('utils.tierOther'); } } @@ -405,16 +405,16 @@ export function getCodexWindowDisplayLabel( switch (getCodexWindowKind(label)) { case 'usage-5h': - return '5h usage limit'; + return i18n.t('quotaTooltip.fiveHourLimit'); case 'usage-weekly': - return 'Weekly usage limit'; + return i18n.t('quotaTooltip.weeklyLimit'); case 'code-review-5h': case 'code-review-weekly': case 'code-review': { const inferred = inferCodeReviewCadence(currentWindow, context); - if (inferred === '5h') return 'Code review (5h)'; - if (inferred === 'weekly') return 'Code review (weekly)'; - return 'Code review'; + if (inferred === '5h') return i18n.t('utils.codeReview5h'); + if (inferred === 'weekly') return i18n.t('utils.codeReviewWeekly'); + return i18n.t('utils.codeReview'); } case 'unknown': return label; diff --git a/ui/src/pages/analytics/components/charts-grid.tsx b/ui/src/pages/analytics/components/charts-grid.tsx index 4b32135b..6995373f 100644 --- a/ui/src/pages/analytics/components/charts-grid.tsx +++ b/ui/src/pages/analytics/components/charts-grid.tsx @@ -13,6 +13,7 @@ import { TrendingUp, PieChart } from 'lucide-react'; import { usePrivacy } from '@/contexts/privacy-context'; import { CostByModelCard } from './cost-by-model-card'; import type { ModelUsage, PaginatedSessions, DailyUsage, HourlyUsage } from '@/hooks/use-usage'; +// TODO i18n: import { useTranslation } from 'react-i18next'; when keys are ready interface ChartsGridProps { viewMode: 'daily' | 'hourly'; @@ -42,6 +43,8 @@ export function ChartsGrid({ onModelClick, }: ChartsGridProps) { const { privacyMode } = usePrivacy(); + // TODO i18n: uncomment when keys for "Last 24 Hours" / "Usage Trends" / "Model Usage" are added + // const { t } = useTranslation(); return (
    @@ -50,6 +53,7 @@ export function ChartsGrid({ + {/* TODO i18n: missing keys for "Last 24 Hours" / "Usage Trends" */} {viewMode === 'hourly' ? 'Last 24 Hours' : 'Usage Trends'} @@ -77,6 +81,7 @@ export function ChartsGrid({ + {/* TODO i18n: missing key for "Model Usage" */} Model Usage diff --git a/ui/src/pages/analytics/components/cost-by-model-card.tsx b/ui/src/pages/analytics/components/cost-by-model-card.tsx index 13693ab4..60eb621d 100644 --- a/ui/src/pages/analytics/components/cost-by-model-card.tsx +++ b/ui/src/pages/analytics/components/cost-by-model-card.tsx @@ -11,6 +11,7 @@ import { getModelColor, cn } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { formatTokens } from '../utils'; import type { ModelUsage } from '@/hooks/use-usage'; +import { useTranslation } from 'react-i18next'; interface CostByModelCardProps { models: ModelUsage[] | undefined; @@ -25,12 +26,14 @@ export function CostByModelCard({ onModelClick, privacyMode, }: CostByModelCardProps) { + const { t } = useTranslation(); + return ( - Cost by Model + {t('analyticsPages.costByModel')} diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index 425b1316..74cb842b 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -38,6 +38,10 @@ import type { ProviderPreset } from '@/lib/provider-presets'; import { cn } from '@/lib/utils'; import { CopyButton } from '@/components/ui/copy-button'; import { useTranslation } from 'react-i18next'; +// TODO i18n: missing keys for apiProfiles: noOrphansFound, confirmRegisterOrphans, +// registeredWithSkipped, registeredProfiles, copyPrompt, destinationEmpty, +// exportRedacted, exportDownloaded, importFailed, sidebarTitle, sidebarSubtitle, +// discoverOrphans, importProfileBundle import { toast } from 'sonner'; import { useNavigate } from 'react-router-dom'; @@ -116,21 +120,29 @@ export function ApiPage() { try { const result = await discoverOrphansMutation.mutateAsync(); if (result.orphans.length === 0) { - toast.success('No orphan profile settings found'); + toast.success(t('apiProfiles.noOrphansFound')); return; } const validCount = result.orphans.filter((orphan) => orphan.validation.valid).length; const shouldRegister = window.confirm( - `Found ${result.orphans.length} orphan settings file(s). Register ${validCount} valid profile(s) now?` + t('apiProfiles.confirmRegisterOrphans', { + total: result.orphans.length, + valid: validCount, + }) ); if (!shouldRegister) return; const registration = await registerOrphansMutation.mutateAsync({}); const skippedMessage = - registration.skipped.length > 0 ? `, skipped ${registration.skipped.length}` : ''; - toast.success(`Registered ${registration.registered.length} profile(s)${skippedMessage}`); + registration.skipped.length > 0 + ? t('apiProfiles.registeredWithSkipped', { count: registration.skipped.length }) + : ''; + toast.success( + t('apiProfiles.registeredProfiles', { count: registration.registered.length }) + + skippedMessage + ); } catch (error) { toast.error((error as Error).message); } @@ -139,13 +151,13 @@ export function ApiPage() { const handleCopySelectedProfile = async () => { if (!selectedProfileData) return; const destinationInput = window.prompt( - `Copy profile "${selectedProfileData.name}" to new profile name:`, + t('apiProfiles.copyPrompt', { name: selectedProfileData.name }), `${selectedProfileData.name}-copy` ); if (!destinationInput) return; const destination = destinationInput.trim(); if (!destination) { - toast.error('Destination profile name cannot be empty'); + toast.error(t('apiProfiles.destinationEmpty')); return; } @@ -169,11 +181,9 @@ export function ApiPage() { const result = await exportProfileMutation.mutateAsync({ name: selectedProfileData.name }); triggerDownload(`${selectedProfileData.name}.ccs-profile.json`, result.bundle); if (result.redacted) { - toast.info( - 'Export created with redacted token. Use include-secrets flow in CLI if needed.' - ); + toast.info(t('apiProfiles.exportRedacted')); } else { - toast.success('Profile export downloaded'); + toast.success(t('apiProfiles.exportDownloaded')); } } catch (error) { toast.error((error as Error).message); @@ -200,7 +210,7 @@ export function ApiPage() { toast.info(result.warnings.join('\n')); } } catch (error) { - toast.error((error as Error).message || 'Failed to import profile bundle'); + toast.error((error as Error).message || t('apiProfiles.importFailed')); } }; @@ -214,7 +224,7 @@ export function ApiPage() {
    -

    Profiles

    +

    {t('apiProfiles.sidebarTitle')}

    @@ -223,8 +233,8 @@ export function ApiPage() { variant="outline" onClick={() => void handleDiscoverOrphans()} disabled={discoverOrphansMutation.isPending || registerOrphansMutation.isPending} - aria-label="Discover orphan profiles" - title="Discover orphan profiles" + aria-label={t('apiProfiles.discoverOrphans')} + title={t('apiProfiles.discoverOrphans')} > @@ -253,7 +263,7 @@ export function ApiPage() {

    - Premium APIs, local runtimes, custom endpoints + {t('apiProfiles.sidebarSubtitle')}

    diff --git a/ui/src/pages/claude-extension.tsx b/ui/src/pages/claude-extension.tsx index c17057ac..9e20d896 100644 --- a/ui/src/pages/claude-extension.tsx +++ b/ui/src/pages/claude-extension.tsx @@ -10,6 +10,7 @@ import { Sparkles, Trash2, } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -284,6 +285,7 @@ function BindingListItem({ } export function ClaudeExtensionPage() { + const { t } = useTranslation(); const optionsQuery = useClaudeExtensionOptions(); const bindingsQuery = useClaudeExtensionBindings(); const createBinding = useCreateClaudeExtensionBinding(); @@ -415,8 +417,9 @@ export function ClaudeExtensionPage() {
    -

    Claude Extension

    +

    {t('claudeExtensionPage.title')}

    + {/* TODO i18n: missing key for subtitle */} Saved IDE bindings for CCS profiles

    @@ -428,6 +431,7 @@ export function ClaudeExtensionPage() {
    @@ -438,30 +442,34 @@ export function ClaudeExtensionPage() { + {/* TODO i18n: missing key for "Create binding"/"Binding editor" */} {creating ? 'Create binding' : 'Binding editor'} + {/* TODO i18n: missing key for binding editor description */} Save a profile + IDE path once, then apply or reset it from the dashboard.
    + {/* TODO i18n: missing key for "Binding name" */}
    Binding name
    updateDraft('name', event.target.value)} - placeholder="VS Code · work profile" + placeholder="VS Code · work profile" /* TODO i18n: missing key */ />
    + {/* TODO i18n: missing key for "CCS profile" */}
    CCS profile
    updateDraft('host', value as BindingDraft['host'])} > - + {hosts.map((host) => ( @@ -497,13 +507,15 @@ export function ClaudeExtensionPage() {
    -
    IDE settings path
    +
    + {t('settingsPage.thinkingSection.ideSettingsPath')} +
    updateDraft('ideSettingsPath', event.target.value)} placeholder={ selectedHost?.defaultSettingsPath || - 'Leave blank for the default user settings path' + 'Leave blank for the default user settings path' /* TODO i18n: missing key */ } />

    @@ -513,11 +525,12 @@ export function ClaudeExtensionPage() {

    + {/* TODO i18n: missing key for "Notes" */}
    Notes
    updateDraft('notes', event.target.value)} - placeholder="Optional reminder for this machine or workspace" + placeholder="Optional reminder for this machine or workspace" /* TODO i18n: missing key */ />
    @@ -532,9 +545,11 @@ export function ClaudeExtensionPage() { ) : ( )} + {/* TODO i18n: missing key for "Create"/"Save" */} {creating ? 'Create' : 'Save'}
    @@ -547,6 +562,7 @@ export function ClaudeExtensionPage() { disabled={deleteBinding.isPending} > + {/* TODO i18n: missing key for "Delete binding" */} Delete binding ) : null} @@ -555,6 +571,7 @@ export function ClaudeExtensionPage() {
    + {/* TODO i18n: missing key for "Saved bindings" */} Saved bindings
    @@ -574,6 +591,7 @@ export function ClaudeExtensionPage() { ) : ( + {/* TODO i18n: missing key for empty bindings text */} No saved bindings yet. Create one to manage apply, reset, and drift checks from the dashboard. @@ -595,17 +613,23 @@ export function ClaudeExtensionPage() { {selectedProfile.label} ) : null} {selectedHost ? {selectedHost.label} : null} - {creating ? Draft : null} + {creating ? ( + {t('settingsPage.thinkingSection.draft')} + ) : null} {status?.sharedSettings && isPlainStatusActive(status.sharedSettings) && isPlainStatusActive(status.ideSettings) ? ( - In sync + + {t('settingsPage.thinkingSection.inSync')} + ) : null}

    + {/* TODO i18n: missing key for default binding name */} {selectedBinding?.name || 'Claude extension binding'}

    + {/* TODO i18n: missing key for binding description */}

    Manage the shared Claude settings file and the IDE-local settings file as two scoped targets. @@ -624,6 +648,7 @@ export function ClaudeExtensionPage() { ) : ( )} + {/* TODO i18n: missing key for "Verify" */} Verify {setup ? ( @@ -644,8 +669,11 @@ export function ClaudeExtensionPage() { {!activeError ? ( - Overview - Advanced + Overview{' '} + {/* TODO i18n: missing key */} + + {t('settingsPage.thinkingSection.advanced')} + @@ -654,8 +682,8 @@ export function ClaudeExtensionPage() { title="Shared Claude settings" description="Writes the managed env block inside ~/.claude/settings.json so CLI and IDE behavior stay aligned." status={status?.sharedSettings} - applyLabel="Apply shared" - resetLabel="Reset shared" + applyLabel="Apply shared" /* TODO i18n: missing key */ + resetLabel="Reset shared" /* TODO i18n: missing key */ onApply={() => runBindingAction('shared', 'apply')} onReset={() => runBindingAction('shared', 'reset')} disabled={creating} @@ -665,8 +693,8 @@ export function ClaudeExtensionPage() { title={`${selectedHost?.label || 'IDE'} settings.json`} description="Writes only the Anthropic extension keys so unrelated editor preferences stay untouched." status={status?.ideSettings} - applyLabel="Apply IDE" - resetLabel="Reset IDE" + applyLabel="Apply IDE" /* TODO i18n: missing key */ + resetLabel="Reset IDE" /* TODO i18n: missing key */ onApply={() => runBindingAction('ide', 'apply')} onReset={() => runBindingAction('ide', 'reset')} disabled={creating} @@ -677,7 +705,10 @@ export function ClaudeExtensionPage() {

    - Resolved binding + + {t('settingsPage.thinkingSection.resolvedBinding')} + + {/* TODO i18n: missing key for resolved binding description */} The binding uses the same profile resolution as `ccs persist` and `ccs env`. @@ -729,7 +760,10 @@ export function ClaudeExtensionPage() { - Managed payload + + {t('settingsPage.thinkingSection.managedPayload')} + + {/* TODO i18n: missing key for managed payload description */} Keep the main view short. The full JSON stays in the Advanced tab. @@ -780,6 +814,7 @@ export function ClaudeExtensionPage() { applyBinding.variables?.target === 'all' ? ( ) : null} + {/* TODO i18n: missing key for "Apply both targets" */} Apply both targets
    ) : (
    + {/* TODO i18n: missing key for "Save this draft..." */} Save this draft to unlock apply, reset, and verify actions.
    )} @@ -804,7 +841,9 @@ export function ClaudeExtensionPage() {
    - Warnings + + {t('settingsPage.thinkingSection.warnings')} + Operational details that can break the binding even when JSON is correct. @@ -831,7 +870,9 @@ export function ClaudeExtensionPage() { - Notes + + {t('settingsPage.thinkingSection.notes')} + Short context from CCS about account continuity and host-specific behavior. diff --git a/ui/src/pages/cliproxy-ai-providers.tsx b/ui/src/pages/cliproxy-ai-providers.tsx index 0d60c6bd..73f60c6f 100644 --- a/ui/src/pages/cliproxy-ai-providers.tsx +++ b/ui/src/pages/cliproxy-ai-providers.tsx @@ -52,6 +52,7 @@ import { Workflow, Zap, } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; function SummaryCard({ label, value, hint }: { label: string; value: string; hint?: string }) { return ( @@ -1403,6 +1404,7 @@ function EmptyEntryWorkspace({ export function CliproxyAiProvidersPage() { const location = useLocation(); const navigate = useNavigate(); + const { t } = useTranslation(); const { data, error, isLoading, isFetching, refetch } = useCliproxyAiProviders(); const createMutation = useCreateCliproxyAiProviderEntry(); const updateMutation = useUpdateCliproxyAiProviderEntry(); @@ -1475,7 +1477,7 @@ export function CliproxyAiProvidersPage() {
    -
    Unable to load AI Providers
    +
    {t('aiProvidersPage.unableToLoad')}
    {message}
    diff --git a/ui/src/pages/logs.tsx b/ui/src/pages/logs.tsx index e96d1ab9..ffc755fe 100644 --- a/ui/src/pages/logs.tsx +++ b/ui/src/pages/logs.tsx @@ -20,6 +20,7 @@ import { LogsEntryList } from '@/components/logs/logs-entry-list'; import { LogsFilters } from '@/components/logs/logs-filters'; import { LogsPageSkeleton } from '@/components/logs/logs-page-skeleton'; import { getSourceLabelMap, useLogsWorkspace, useUpdateLogsConfig } from '@/hooks/use-logs'; +// TODO i18n: import { useTranslation } from 'react-i18next'; when keys are ready const DESKTOP_LOGS_BREAKPOINT = 1200; const LEFT_PANEL_WIDTH = 336; @@ -66,6 +67,8 @@ function CollapsedPaneToggle({ } export function LogsPage() { + // TODO i18n: uncomment when keys for Syncing/Refresh and other strings are added + // const { t } = useTranslation(); const workspace = useLogsWorkspace(); const updateConfig = useUpdateLogsConfig(); const sourceLabels = getSourceLabelMap(workspace.sourcesQuery.data ?? []); @@ -179,8 +182,8 @@ export function LogsPage() { )}
    {workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching - ? 'Syncing' - : 'Refresh'} + ? /* TODO i18n: missing key for "Syncing" */ 'Syncing' + : /* TODO i18n: missing key for "Refresh" */ 'Refresh'}
    diff --git a/ui/src/pages/settings/sections/channels.tsx b/ui/src/pages/settings/sections/channels.tsx index 36d1a001..cf5ecc8e 100644 --- a/ui/src/pages/settings/sections/channels.tsx +++ b/ui/src/pages/settings/sections/channels.tsx @@ -15,6 +15,7 @@ import { ShieldAlert, Trash2, } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { useOfficialChannelsConfig } from '../hooks/use-official-channels-config'; import { useRawConfig } from '../hooks'; import type { OfficialChannelId } from '../types'; @@ -84,6 +85,7 @@ function getSelectedChannelLabel( } export default function ChannelsSection() { + const { t } = useTranslation(); const { config, status, @@ -148,7 +150,7 @@ export default function ChannelsSection() {
    - Loading + {t('settings.loading')}
    ); @@ -182,11 +184,13 @@ export default function ChannelsSection() {
    -

    Official Channels

    +

    {t('settingsPage.channelsSection.title')}

    + {/* TODO i18n: missing key for channels description paragraphs */}

    Configure official Claude channels here, then run ccs normally on a supported native Claude session.

    + {/* TODO i18n: missing key for channels storage description */}

    CCS stores only channel selection in config.yaml. Claude keeps the machine-level channel state under ~/.claude/channels/. @@ -208,10 +212,12 @@ export default function ChannelsSection() {

    {status.summary.nextStep}

    + {/* TODO i18n: missing key for "Machine checks" */}

    Machine checks

    Bun + {/* TODO i18n: missing key for "Installed"/"Missing" */} {status.bunInstalled ? 'Installed' : 'Missing'}
    @@ -224,6 +230,7 @@ export default function ChannelsSection() {
    Claude auth + {/* TODO i18n: missing key for "Unknown" */} {status.auth.authMethod ?? 'Unknown'}
    @@ -241,6 +248,7 @@ export default function ChannelsSection() { {status && (
    + {/* TODO i18n: missing key for "Fastest path" and step descriptions */}

    Fastest path

    1. Turn on the channels you want below.

    @@ -252,6 +260,7 @@ export default function ChannelsSection() {

    {status.supportMessage}

    + {/* TODO i18n: missing key for "Advanced notes and scope" */} Advanced notes and scope @@ -267,6 +276,7 @@ export default function ChannelsSection() {
    + {/* TODO i18n: missing key for "If you run ccs now" */}

    If you run ccs now

    @@ -278,10 +288,12 @@ export default function ChannelsSection() {
    + {/* TODO i18n: missing key for "You type:" */} You type:{' '} {status.launchPreview.command}
    + {/* TODO i18n: missing key for "CCS adds:" */} CCS adds:{' '} {status.launchPreview.appendedArgs.length > 0 ? status.launchPreview.appendedArgs.join(' ') @@ -391,6 +403,7 @@ export default function ChannelsSection() { disabled={saving || !tokenDraft.trim()} > + {/* TODO i18n: missing key for "Save Token" */} Save Token
    @@ -406,6 +420,7 @@ export default function ChannelsSection() { )}
    + {/* TODO i18n: missing key for "Claude-side setup commands" */} Claude-side setup commands @@ -426,6 +441,7 @@ export default function ChannelsSection() {
    + {/* TODO i18n: missing key for channels injection disclaimer */} CCS injects --channels only for the current Claude session. Telegram, Discord, and iMessage stop receiving messages when that Claude session exits. @@ -437,7 +453,10 @@ export default function ChannelsSection() {
    - + + {/* TODO i18n: missing key for skip permission description */}

    Optional advanced behavior. CCS adds --dangerously-skip-permissions{' '} only when at least one selected channel is being auto-enabled and you did not @@ -466,7 +485,7 @@ export default function ChannelsSection() {

    diff --git a/ui/src/pages/settings/sections/image-analysis/index.tsx b/ui/src/pages/settings/sections/image-analysis/index.tsx index a644dea9..a4fb1545 100644 --- a/ui/src/pages/settings/sections/image-analysis/index.tsx +++ b/ui/src/pages/settings/sections/image-analysis/index.tsx @@ -26,6 +26,7 @@ import { Sparkles, Trash2, } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { api, type ImageAnalysisDashboardData } from '@/lib/api-client'; import { cn } from '@/lib/utils'; import { useRawConfig } from '../../hooks'; @@ -115,6 +116,7 @@ function backendStateClass(state: ImageAnalysisDashboardData['backends'][number] } } +// TODO i18n: missing keys for currentTargetModeLabel values function currentTargetModeLabel( mode: ImageAnalysisDashboardData['profiles'][number]['currentTargetMode'] ): string { @@ -155,6 +157,7 @@ function currentTargetModeClass( } } +// TODO i18n: missing keys for backendStateLabel values function backendStateLabel(state: ImageBackend['state']): string { switch (state) { case 'starts_on_launch': @@ -170,6 +173,7 @@ function backendStateLabel(state: ImageBackend['state']): string { } } +// TODO i18n: missing keys for backendStatusNote values function backendStatusNote(backend: ImageBackend | undefined): string | null { if (!backend) { return 'No model configured.'; @@ -189,6 +193,7 @@ function backendStatusNote(backend: ImageBackend | undefined): string | null { } } +// TODO i18n: missing keys for routeSourceLabel values function routeSourceLabel(source: ImageProfile['resolutionSource']): string { switch (source) { case 'profile-backend': @@ -254,6 +259,7 @@ function getCoverageRowClass(index: number, profile: ImageProfile): string { return index % 2 === 0 ? 'bg-background/75' : 'bg-muted/18'; } +// TODO i18n: missing keys for summaryCompactDetail format strings function summaryCompactDetail(summary: ImageAnalysisDashboardData['summary']): string { const parts = [`${summary.activeProfileCount} routed`, `${summary.nativeProfileCount} native`]; @@ -361,6 +367,7 @@ function ImageSectionPanel({ } export default function ImageAnalysisSection() { + const { t } = useTranslation(); const { fetchRawConfig } = useRawConfig(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -530,7 +537,7 @@ export default function ImageAnalysisSection() { }); setData(payload); hydrateDraft(payload); - setSuccess('Image settings saved.'); + setSuccess(t('commonToast.settingsSaved')); await fetchRawConfig(); return true; } catch (err) { @@ -549,6 +556,7 @@ export default function ImageAnalysisSection() { hydrateDraft, mappingDrafts, providerModels, + t, timeout, ] ); @@ -638,7 +646,7 @@ export default function ImageAnalysisSection() {
    - Loading image settings... + {t('settingsPage.imageAnalysisSection.loading')}
    ); @@ -649,12 +657,14 @@ export default function ImageAnalysisSection() {
    - {error ?? 'Failed to load image settings.'} + + {error ?? t('settingsPage.imageAnalysisSection.description')} +
    @@ -702,7 +712,9 @@ export default function ImageAnalysisSection() {
    -

    Image

    +

    + {t('settingsPage.imageAnalysisSection.title')} +

    @@ -750,7 +762,7 @@ export default function ImageAnalysisSection() { tone="amber" eyebrow="Control deck" title="Core setup" - description="Global toggle, timeout, and fallback." + description={t('settingsPage.imageAnalysisSection.description')} icon={} meta={ @@ -837,11 +849,15 @@ export default function ImageAnalysisSection() { disabled={saving} > - + {configuredBackendIds.length === 0 ? ( - Configure a model first + + {t('settingsPage.thinkingSection.configureModelFirst')} + ) : ( configuredBackendIds.map((backendId) => ( @@ -954,7 +970,7 @@ export default function ImageAnalysisSection() {
    @@ -983,6 +999,7 @@ export default function ImageAnalysisSection() { void commitProviderModel(backendId, ''); }} > + {/* TODO i18n: missing key for "Clear" */} Clear )} @@ -1084,6 +1101,7 @@ export default function ImageAnalysisSection() { ) : ( )} + {/* TODO i18n: missing key for "Hide"/"Show" */} {showProfileRouting ? 'Hide' : 'Show'} {showProfileRouting && ( @@ -1104,6 +1122,7 @@ export default function ImageAnalysisSection() { disabled={configuredBackendIds.length === 0 || saving} > + {/* TODO i18n: missing key for "Add mapping" */} Add mapping )} @@ -1157,6 +1176,7 @@ export default function ImageAnalysisSection() { }} > + {/* TODO i18n: missing key for "Remove" */} Remove
    @@ -1166,7 +1186,7 @@ export default function ImageAnalysisSection() { value={row.profileName} list="image-profile-suggestions" disabled={saving} - placeholder="Profile or variant name" + placeholder="Profile or variant name" /* TODO i18n: missing key */ className="h-10 border-slate-400/15 bg-background/88 text-base" onChange={(event) => { updateMappingRow(row.id, { profileName: event.target.value }); @@ -1198,11 +1218,15 @@ export default function ImageAnalysisSection() { }} > - + {configuredBackendIds.length === 0 ? ( - Configure a model first + + {t('settingsPage.thinkingSection.configureModelFirst')} + ) : ( configuredBackendIds.map((backendId) => ( diff --git a/ui/src/pages/settings/sections/thinking/index.tsx b/ui/src/pages/settings/sections/thinking/index.tsx index bfe4f13e..d4c57e74 100644 --- a/ui/src/pages/settings/sections/thinking/index.tsx +++ b/ui/src/pages/settings/sections/thinking/index.tsx @@ -22,6 +22,10 @@ import { useThinkingConfig } from '../../hooks'; import type { ThinkingMode } from '../../types'; import { useTranslation } from 'react-i18next'; +// Thinking level labels are technical descriptors with token counts that stay +// consistent across locales. If locale-specific labels are needed later, add +// i18n keys and replace these with t() calls. +// TODO i18n: missing key for thinking level labels const THINKING_LEVELS = [ { value: 'minimal', label: 'Minimal (512 tokens)' }, { value: 'low', label: 'Low (1K tokens)' }, @@ -31,6 +35,7 @@ const THINKING_LEVELS = [ { value: 'auto', label: 'Auto (dynamic)' }, ]; +// TODO i18n: missing key for override level labels const OVERRIDE_LEVELS = [ { value: '__none__', label: 'None (use CLI flags only)' }, ...THINKING_LEVELS, @@ -334,6 +339,7 @@ export default function ThinkingSection() { {t('settingsThinking.apply')}
    + {/* TODO i18n: missing key for budget range text */}

    Range: {THINKING_BUDGET_MIN} to {THINKING_BUDGET_MAX}

    @@ -446,6 +452,7 @@ export default function ThinkingSection() { {/* Info Box */}

    {t('settingsThinking.cliEnvOverride')}

    + {/* TODO i18n: missing key for CLI/env override info text */}

    Override per session with flags or{' '} CCS_THINKING env var. diff --git a/ui/src/pages/settings/sections/websearch/index.tsx b/ui/src/pages/settings/sections/websearch/index.tsx index b7a19a13..4365cc4d 100644 --- a/ui/src/pages/settings/sections/websearch/index.tsx +++ b/ui/src/pages/settings/sections/websearch/index.tsx @@ -62,6 +62,7 @@ interface ProviderDefinition { fields?: ProviderFieldDefinition[]; } +// TODO i18n: missing keys for CHAIN_STEPS titles const CHAIN_STEPS = [ { id: 'exa', title: 'Exa', defaultEnabled: false }, { id: 'tavily', title: 'Tavily', defaultEnabled: false }, @@ -71,6 +72,7 @@ const CHAIN_STEPS = [ { id: 'legacy', title: 'Legacy CLI', defaultEnabled: false }, ] as const; +// TODO i18n: missing keys for BACKEND_PROVIDERS titles, descriptions, badges, footerNotes, field labels, helpTexts, placeholders const BACKEND_PROVIDERS: ProviderDefinition[] = [ { id: 'exa', @@ -194,6 +196,7 @@ const BACKEND_PROVIDERS: ProviderDefinition[] = [ }, ]; +// TODO i18n: missing keys for LEGACY_PROVIDERS titles, descriptions, badges, footerNotes, field labels, helpTexts, placeholders const LEGACY_PROVIDERS: ProviderDefinition[] = [ { id: 'gemini', @@ -292,6 +295,7 @@ function getStatusTone( return 'idle'; } +// TODO i18n: missing keys for getStatusLabel return values ("Ready", "Needs setup", "Disabled") function getStatusLabel(provider: CliStatus | undefined, enabled: boolean): string { if (enabled && provider?.available) { return 'Ready'; @@ -375,6 +379,7 @@ function isApiKeyProvider(providerId: ProviderId): providerId is WebSearchApiKey return providerId === 'exa' || providerId === 'tavily' || providerId === 'brave'; } +// TODO i18n: missing keys for getApiKeySummary return values function getApiKeySummary(apiKeyState: WebSearchApiKeyState | undefined): string { if (!apiKeyState?.configured) { return 'Not stored'; @@ -437,6 +442,7 @@ export default function WebSearchSection() { const legacyReady = legacyEnabled.some((provider) => providerStatus.get(provider.id)?.available); const legacySummary = + // TODO i18n: missing keys for legacy summary format strings ("Off", "X enabled", "N enabled") legacyEnabled.length === 0 ? 'Off' : legacyEnabled.length === 1 @@ -624,6 +630,7 @@ export default function WebSearchSection() {

    + {/* TODO i18n: missing key for "Execution chain" */}

    Execution chain

    @@ -686,6 +693,7 @@ export default function WebSearchSection() {

    + {/* TODO i18n: missing key for "Primary backends" */}

    Primary backends

    Real backends run top-down before any legacy CLI fallback. @@ -739,6 +747,7 @@ export default function WebSearchSection() {

    + {/* TODO i18n: missing key for "API Key" */} API Key

    @@ -747,12 +756,12 @@ export default function WebSearchSection() {

    {config?.apiKeys?.[apiKeyProviderId]?.maskedValue ? `${config.apiKeys[apiKeyProviderId]?.envVar} ${config.apiKeys[apiKeyProviderId]?.maskedValue}` - : `Store ${provider.badge} here so the backend is ready immediately after you enable it.`} + : /* TODO i18n: missing key for "Store X here..." */ `Store ${provider.badge} here so the backend is ready immediately after you enable it.`}

    {savedApiKeyProvider === provider.id && ( - Saved + {/* TODO i18n: missing key for "Saved" */ 'Saved'} )}
    @@ -768,8 +777,8 @@ export default function WebSearchSection() { } placeholder={ config?.apiKeys?.[apiKeyProviderId]?.configured - ? 'Enter a new key to rotate the stored secret' - : `Paste ${provider.badge}` + ? /* TODO i18n: missing key for "Enter a new key to rotate the stored secret" */ 'Enter a new key to rotate the stored secret' + : /* TODO i18n: missing key for "Paste X" */ `Paste ${provider.badge}` } className="bg-background/80 font-mono text-sm" disabled={saving} @@ -786,8 +795,8 @@ export default function WebSearchSection() { } > {config?.apiKeys?.[apiKeyProviderId]?.configured - ? 'Update key' - : 'Save key'} + ? /* TODO i18n: missing key for "Update key" */ 'Update key' + : /* TODO i18n: missing key for "Save key" */ 'Save key'} {(config?.apiKeys?.[apiKeyProviderId]?.source === 'global_env' || @@ -800,7 +809,9 @@ export default function WebSearchSection() { }} disabled={saving} > - Remove stored key + { + /* TODO i18n: missing key for "Remove stored key" */ 'Remove stored key' + } )}
    @@ -828,6 +839,7 @@ export default function WebSearchSection() {
    + {/* TODO i18n: missing key for "Legacy CLI fallbacks" */}

    Legacy CLI fallbacks

    Runs only after every enabled real backend fails. diff --git a/ui/src/pages/settings/sections/websearch/provider-card.tsx b/ui/src/pages/settings/sections/websearch/provider-card.tsx index 9f144a33..40caaab0 100644 --- a/ui/src/pages/settings/sections/websearch/provider-card.tsx +++ b/ui/src/pages/settings/sections/websearch/provider-card.tsx @@ -1,5 +1,6 @@ import type { KeyboardEvent, ReactNode } from 'react'; import { ExternalLink } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; @@ -116,6 +117,7 @@ export function ProviderCard({ footerNote, children, }: ProviderCardProps) { + const { t } = useTranslation(); const tone = PROVIDER_TONE_STYLES[badgeTone]; const status = getStatusToneStyles(statusTone); @@ -195,7 +197,7 @@ export function ProviderCard({ {field.saved && ( - Saved + {t('settings.saved')} )}

    @@ -241,7 +243,7 @@ export function ProviderCard({ className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline" > - View docs + {t('settingsWebsearch.viewDocs')} )}
    diff --git a/ui/src/pages/shared.tsx b/ui/src/pages/shared.tsx index 8a848519..5b632569 100644 --- a/ui/src/pages/shared.tsx +++ b/ui/src/pages/shared.tsx @@ -89,6 +89,7 @@ export function SharedPage() { const hasNoItems = !isLoading && !isError && allItems.length === 0; const hasNoMatches = !isLoading && !isError && allItems.length > 0 && filteredItems.length === 0; + // TODO i18n: missing key for "Shared item totals could not be loaded. Listing still works." const summaryErrorMessage = getSharedErrorMessage( summaryError, 'Shared item totals could not be loaded. Listing still works.' @@ -155,6 +156,7 @@ export function SharedPage() { {t('sharedPage.configurationRequired')} + {/* TODO i18n: missing key for "Run `ccs sync` to configure." */} {summary.symlinkStatus.message}. Run `ccs sync` to configure. @@ -399,6 +401,7 @@ function getSharedErrorMessage(error: unknown, fallbackMessage: string): string const normalized = error.message.toLowerCase(); if (normalized.includes('failed to fetch') || normalized.includes('network')) { + // TODO i18n: missing key for connection lost message return 'Connection to dashboard server lost or restarting. Keep `ccs config` running, then retry.'; }