feat: v0.13.0 — add i18n support with Chinese/English translations

Implements a zero-dependency i18n framework with TypeScript type safety.
Thanks to @gy212 for the original i18n infrastructure design (PR #89)
and comprehensive translation files (PR #90-96) that inspired this implementation.

## New files (5)

- src/i18n/en.ts: English translations (source of truth, ~270 keys),
  exports TranslationKey type for compile-time key completeness
- src/i18n/zh.ts: Chinese translations, typed as Record<TranslationKey, string>
- src/i18n/index.ts: translate() function with {param} interpolation,
  Locale type, SUPPORTED_LOCALES array
- src/components/layout/I18nProvider.tsx: React Context provider,
  loads/persists locale preference via /api/settings/app
- src/hooks/useTranslation.ts: useTranslation() hook returning
  { locale, setLocale, t }

## Infrastructure wiring

- src/app/layout.tsx: I18nProvider wrapped inside ThemeProvider
- src/app/api/settings/app/route.ts: added "locale" to ALLOWED_KEYS
  for persistence

## Language picker

- src/components/settings/GeneralSection.tsx: added language selection
  dropdown (Select component) in Settings > General, using
  SUPPORTED_LOCALES and setLocale()

## String replacements (33 component files)

All hardcoded user-facing strings replaced with t() calls:

High priority (chat):
- ChatListPanel.tsx: session list, search, empty state, relative time
- NavRail.tsx: nav labels, theme toggle, auto-approve indicator
- MessageInput.tsx: command descriptions, mode labels, attach tooltip
- MessageList.tsx: empty state, load earlier, loading indicator
- StreamingMessage.tsx: thinking, permission actions
- chat/[id]/page.tsx: session title fallback

Settings:
- GeneralSection.tsx: update card, auto-approve section, warning dialog
- SettingsLayout.tsx: title, description, sidebar labels
- CliSettingsSection.tsx: form/json tabs, buttons, dynamic field labels
  (skipDangerousModePermissionPrompt etc.), Enabled/Disabled states
- ProviderForm.tsx: dialog title, form labels, buttons
- ProviderManager.tsx: status badges, delete dialog, section headers

Extensions:
- extensions/page.tsx: title, tab labels
- SkillsManager.tsx: empty state, loading, search
- SkillEditor.tsx: toolbar buttons, placeholder
- SkillListItem.tsx: delete confirmation
- CreateSkillDialog.tsx: form labels, scope, templates, validation

Plugins:
- McpManager.tsx: header, tabs, loading state
- McpServerList.tsx: empty state, badges, field labels
- McpServerEditor.tsx: dialog title, form labels, buttons
- ConfigEditor.tsx: save/format buttons

Layout:
- RightPanel.tsx: panel header, open/close tooltips
- FileTree.tsx: search placeholder, empty states
- FilePreview.tsx: back button, line count, copy path
- DocPreview.tsx: iframe title
- ConnectionStatus.tsx: install prompts, version display
- InstallWizard.tsx: phase descriptions, button labels
- ImportSessionDialog.tsx: search, relative time, import buttons
- FolderPicker.tsx: dialog title, loading, buttons
- TaskList.tsx: filter tabs, input placeholder, empty states
This commit is contained in:
7418 committed 2026-02-24 16:50:57 +08:00
1 parent 85940dfde3
commit c7c3ba5302
38 files changed
+980 -217

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "codepilot",
"version": "0.12.0",
"version": "0.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codepilot",
"version": "0.12.0",
"version": "0.13.0",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.33",
"@hugeicons/core-free-icons": "^3.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codepilot",
"version": "0.12.0",
"version": "0.13.0",
"private": true,
"author": {
"name": "op7418",
+1
View File
@@ -10,6 +10,7 @@ const ALLOWED_KEYS = [
'anthropic_auth_token',
'anthropic_base_url',
'dangerously_skip_permissions',
'locale',
];
export async function GET() {
+4 -2
View File
@@ -9,6 +9,7 @@ import { Loading02Icon, PencilEdit01Icon } from "@hugeicons/core-free-icons";
import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { usePanel } from '@/hooks/usePanel';
import { useTranslation } from '@/hooks/useTranslation';
interface ChatSessionPageProps {
params: Promise<{ id: string }>;
@@ -29,9 +30,10 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
const [editTitle, setEditTitle] = useState('');
const titleInputRef = useRef<HTMLInputElement>(null);
const { setWorkingDirectory, setSessionId, setSessionTitle: setPanelSessionTitle, setPanelOpen } = usePanel();
const { t } = useTranslation();
const handleStartEditTitle = useCallback(() => {
setEditTitle(sessionTitle || 'New Conversation');
setEditTitle(sessionTitle || t('chat.newConversation'));
setIsEditingTitle(true);
}, [sessionTitle]);
@@ -88,7 +90,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
}
setSessionId(id);
setPanelOpen(true);
const title = data.session.title || 'New Conversation';
const title = data.session.title || t('chat.newConversation');
setSessionTitle(title);
setPanelSessionTitle(title);
setSessionModel(data.session.model || '');
+5 -3
View File
@@ -7,6 +7,7 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { Loading02Icon } from "@hugeicons/core-free-icons";
import { SkillsManager } from "@/components/skills/SkillsManager";
import { McpManager } from "@/components/plugins/McpManager";
import { useTranslation } from "@/hooks/useTranslation";
type ExtTab = "skills" | "mcp";
@@ -28,15 +29,16 @@ function ExtensionsPageInner() {
const searchParams = useSearchParams();
const initialTab = (searchParams.get("tab") as ExtTab) || "skills";
const [tab, setTab] = useState<ExtTab>(initialTab);
const { t } = useTranslation();
return (
<div className="flex h-full flex-col">
<div className="px-6 pt-4 pb-0">
<h1 className="text-xl font-semibold mb-3">Extensions</h1>
<h1 className="text-xl font-semibold mb-3">{t('extensions.title')}</h1>
<Tabs value={tab} onValueChange={(v) => setTab(v as ExtTab)}>
<TabsList>
<TabsTrigger value="skills">Skills</TabsTrigger>
<TabsTrigger value="mcp">MCP Servers</TabsTrigger>
<TabsTrigger value="skills">{t('extensions.skills')}</TabsTrigger>
<TabsTrigger value="mcp">{t('extensions.mcpServers')}</TabsTrigger>
</TabsList>
</Tabs>
</div>
+4 -1
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/layout/ThemeProvider";
import { I18nProvider } from "@/components/layout/I18nProvider";
import { AppShell } from "@/components/layout/AppShell";
const geistSans = Geist({
@@ -30,7 +31,9 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider>
<AppShell>{children}</AppShell>
<I18nProvider>
<AppShell>{children}</AppShell>
</I18nProvider>
</ThemeProvider>
</body>
</html>
+7 -5
View File
@@ -19,6 +19,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useTranslation } from '@/hooks/useTranslation';
interface FolderEntry {
name: string;
@@ -40,6 +41,7 @@ interface FolderPickerProps {
}
export function FolderPicker({ open, onOpenChange, onSelect, initialPath }: FolderPickerProps) {
const { t } = useTranslation();
const [currentDir, setCurrentDir] = useState('');
const [parentDir, setParentDir] = useState<string | null>(null);
const [directories, setDirectories] = useState<FolderEntry[]>([]);
@@ -99,7 +101,7 @@ export function FolderPicker({ open, onOpenChange, onSelect, initialPath }: Fold
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg overflow-hidden">
<DialogHeader>
<DialogTitle>Select Project Folder</DialogTitle>
<DialogTitle>{t('folderPicker.title')}</DialogTitle>
</DialogHeader>
{/* Path input */}
@@ -162,11 +164,11 @@ export function FolderPicker({ open, onOpenChange, onSelect, initialPath }: Fold
<ScrollArea className="h-64">
{loading ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Loading...
{t('folderPicker.loading')}
</div>
) : directories.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
No subdirectories
{t('folderPicker.noSubdirs')}
</div>
) : (
<div className="p-1">
@@ -188,11 +190,11 @@ export function FolderPicker({ open, onOpenChange, onSelect, initialPath }: Fold
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
{t('folderPicker.cancel')}
</Button>
<Button onClick={handleSelect} className="gap-2">
<HugeiconsIcon icon={FolderOpenIcon} className="h-4 w-4" />
Select This Folder
{t('folderPicker.select')}
</Button>
</DialogFooter>
</DialogContent>
+18 -13
View File
@@ -21,6 +21,8 @@ import {
StopIcon,
} from "@hugeicons/core-free-icons";
import { cn } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
import {
PromptInput,
PromptInputTextarea,
@@ -65,6 +67,7 @@ interface PopoverItem {
label: string;
value: string;
description?: string;
descriptionKey?: TranslationKey;
builtIn?: boolean;
immediate?: boolean;
installedSource?: "agents" | "claude";
@@ -91,15 +94,15 @@ const COMMAND_PROMPTS: Record<string, string> = {
};
const BUILT_IN_COMMANDS: PopoverItem[] = [
{ label: 'help', value: '/help', description: 'Show available commands and tips', builtIn: true, immediate: true, icon: HelpCircleIcon },
{ label: 'clear', value: '/clear', description: 'Clear conversation history', builtIn: true, immediate: true, icon: Delete02Icon },
{ label: 'cost', value: '/cost', description: 'Show token usage statistics', builtIn: true, immediate: true, icon: Coins01Icon },
{ label: 'compact', value: '/compact', description: 'Compress conversation context', builtIn: true, icon: FileZipIcon },
{ label: 'doctor', value: '/doctor', description: 'Diagnose project health', builtIn: true, icon: Stethoscope02Icon },
{ label: 'init', value: '/init', description: 'Initialize CLAUDE.md for project', builtIn: true, icon: FileEditIcon },
{ label: 'review', value: '/review', description: 'Review code quality', builtIn: true, icon: SearchList01Icon },
{ label: 'terminal-setup', value: '/terminal-setup', description: 'Configure terminal settings', builtIn: true, icon: CommandLineIcon },
{ label: 'memory', value: '/memory', description: 'Edit project memory file', builtIn: true, icon: BrainIcon },
{ label: 'help', value: '/help', description: 'Show available commands and tips', descriptionKey: 'messageInput.helpDesc', builtIn: true, immediate: true, icon: HelpCircleIcon },
{ label: 'clear', value: '/clear', description: 'Clear conversation history', descriptionKey: 'messageInput.clearDesc', builtIn: true, immediate: true, icon: Delete02Icon },
{ label: 'cost', value: '/cost', description: 'Show token usage statistics', descriptionKey: 'messageInput.costDesc', builtIn: true, immediate: true, icon: Coins01Icon },
{ label: 'compact', value: '/compact', description: 'Compress conversation context', descriptionKey: 'messageInput.compactDesc', builtIn: true, icon: FileZipIcon },
{ label: 'doctor', value: '/doctor', description: 'Diagnose project health', descriptionKey: 'messageInput.doctorDesc', builtIn: true, icon: Stethoscope02Icon },
{ label: 'init', value: '/init', description: 'Initialize CLAUDE.md for project', descriptionKey: 'messageInput.initDesc', builtIn: true, icon: FileEditIcon },
{ label: 'review', value: '/review', description: 'Review code quality', descriptionKey: 'messageInput.reviewDesc', builtIn: true, icon: SearchList01Icon },
{ label: 'terminal-setup', value: '/terminal-setup', description: 'Configure terminal settings', descriptionKey: 'messageInput.terminalSetupDesc', builtIn: true, icon: CommandLineIcon },
{ label: 'memory', value: '/memory', description: 'Edit project memory file', descriptionKey: 'messageInput.memoryDesc', builtIn: true, icon: BrainIcon },
];
interface ModeOption {
@@ -183,11 +186,12 @@ function FileAwareSubmitButton({
*/
function AttachFileButton() {
const attachments = usePromptInputAttachments();
const { t } = useTranslation();
return (
<PromptInputButton
onClick={() => attachments.openFileDialog()}
tooltip="Attach files"
tooltip={t('messageInput.attachFiles')}
>
<HugeiconsIcon icon={PlusSignIcon} className="h-3.5 w-3.5" />
</PromptInputButton>
@@ -331,6 +335,7 @@ export function MessageInput({
mode = 'code',
onModeChange,
}: MessageInputProps) {
const { t } = useTranslation();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
@@ -752,9 +757,9 @@ export function MessageInput({
<HugeiconsIcon icon={CommandLineIcon} className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="font-mono text-xs truncate">{item.label}</span>
{item.description && (
{(item.descriptionKey || item.description) && (
<span className="text-xs text-muted-foreground truncate max-w-[200px]">
{item.description}
{item.descriptionKey ? t(item.descriptionKey) : item.description}
</span>
)}
{!item.builtIn && item.installedSource && (
@@ -919,7 +924,7 @@ export function MessageInput({
)}
onClick={() => onModeChange?.(opt.value)}
>
{opt.label}
{opt.value === 'code' ? t('messageInput.modeCode') : opt.value === 'plan' ? t('messageInput.modePlan') : opt.label}
</button>
);
})}
+4 -2
View File
@@ -1,6 +1,7 @@
'use client';
import { useRef, useEffect } from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import type { Message, PermissionRequestEvent } from '@/types';
import {
Conversation,
@@ -57,6 +58,7 @@ export function MessageList({
loadingMore,
onLoadMore,
}: MessageListProps) {
const { t } = useTranslation();
// Scroll anchor: preserve position when older messages are prepended
const anchorIdRef = useRef<string | null>(null);
const prevMessageCountRef = useRef(messages.length);
@@ -86,7 +88,7 @@ export function MessageList({
<div className="flex flex-1 items-center justify-center">
<ConversationEmptyState
title="Claude Chat"
description="Start a conversation with Claude. Ask questions, get help with code, or explore ideas."
description={t('messageList.emptyDescription')}
icon={<CodePilotLogo className="h-16 w-16" />}
/>
</div>
@@ -103,7 +105,7 @@ export function MessageList({
disabled={loadingMore}
className="text-sm text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
>
{loadingMore ? 'Loading...' : 'Load earlier messages'}
{loadingMore ? t('messageList.loading') : t('messageList.loadEarlier')}
</button>
</div>
)}
+6 -4
View File
@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import {
Message as AIMessage,
MessageContent,
@@ -295,6 +296,7 @@ export function StreamingMessage({
permissionResolved,
onForceStop,
}: StreamingMessageProps) {
const { t } = useTranslation();
const runningTools = toolUses.filter(
(tool) => !toolResults.some((r) => r.tool_use_id === tool.id)
);
@@ -430,18 +432,18 @@ export function StreamingMessage({
variant="default"
onClick={() => onPermissionResponse?.('allow_session')}
>
Allow for Session
{t('streaming.allowForSession')}
</ConfirmationAction>
)}
</ConfirmationActions>
</ConfirmationRequest>
<ConfirmationAccepted>
<p className="text-xs text-green-600 dark:text-green-400">Allowed</p>
<p className="text-xs text-green-600 dark:text-green-400">{t('streaming.allowed')}</p>
</ConfirmationAccepted>
<ConfirmationRejected>
<p className="text-xs text-red-600 dark:text-red-400">Denied</p>
<p className="text-xs text-red-600 dark:text-red-400">{t('streaming.denied')}</p>
</ConfirmationRejected>
</Confirmation>
)}
@@ -454,7 +456,7 @@ export function StreamingMessage({
{/* Loading indicator when no content yet */}
{isStreaming && !content && toolUses.length === 0 && !pendingPermission && (
<div className="py-2">
<Shimmer>Thinking...</Shimmer>
<Shimmer>{t('streaming.thinking')}</Shimmer>
</div>
)}
+16 -14
View File
@@ -25,6 +25,7 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { usePanel } from "@/hooks/usePanel";
import { useTranslation } from "@/hooks/useTranslation";
import { ConnectionStatus } from "./ConnectionStatus";
import { ImportSessionDialog } from "./ImportSessionDialog";
import { FolderPicker } from "@/components/chat/FolderPicker";
@@ -35,7 +36,7 @@ interface ChatListPanelProps {
width?: number;
}
function formatRelativeTime(dateStr: string): string {
function formatRelativeTime(dateStr: string, t: (key: import('@/i18n').TranslationKey, params?: Record<string, string | number>) => string): string {
const date = new Date(dateStr.includes("T") ? dateStr : dateStr + "Z");
const now = new Date();
const diffMs = now.getTime() - date.getTime();
@@ -43,10 +44,10 @@ function formatRelativeTime(dateStr: string): string {
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m`;
if (diffHr < 24) return `${diffHr}h`;
if (diffDay < 7) return `${diffDay}d`;
if (diffMin < 1) return t('chatList.justNow');
if (diffMin < 60) return t('chatList.minutesAgo', { n: diffMin });
if (diffHr < 24) return t('chatList.hoursAgo', { n: diffHr });
if (diffDay < 7) return t('chatList.daysAgo', { n: diffDay });
return date.toLocaleDateString();
}
@@ -117,6 +118,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
const pathname = usePathname();
const router = useRouter();
const { streamingSessionId, pendingApprovalSessionId, workingDirectory } = usePanel();
const { t } = useTranslation();
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [hoveredSession, setHoveredSession] = useState<string | null>(null);
const [deletingSession, setDeletingSession] = useState<string | null>(null);
@@ -331,7 +333,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
onClick={handleNewChat}
>
<HugeiconsIcon icon={PlusSignIcon} className="h-3.5 w-3.5" />
New Chat
{t('chatList.newConversation')}
</Button>
<Tooltip>
<TooltipTrigger asChild>
@@ -342,10 +344,10 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
onClick={() => setFolderPickerOpen(true)}
>
<HugeiconsIcon icon={FolderOpenIcon} className="h-3.5 w-3.5" />
<span className="sr-only">Open project folder</span>
<span className="sr-only">{t('chatList.addProjectFolder')}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Open project folder</TooltipContent>
<TooltipContent side="bottom">{t('chatList.addProjectFolder')}</TooltipContent>
</Tooltip>
</div>
@@ -357,7 +359,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
className="absolute left-2.5 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder="Search threads..."
placeholder={t('chatList.searchSessions')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-7 text-xs"
@@ -374,7 +376,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
onClick={() => setImportDialogOpen(true)}
>
<HugeiconsIcon icon={FileImportIcon} className="h-3 w-3" />
Import CLI Session
{t('chatList.importFromCli')}
</Button>
</div>
@@ -383,7 +385,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
<div className="flex flex-col pb-3">
{filteredSessions.length === 0 ? (
<p className="px-2.5 py-3 text-[11px] text-muted-foreground/60">
{searchQuery ? "No matching threads" : "No conversations yet"}
{searchQuery ? "No matching threads" : t('chatList.noSessions')}
</p>
) : (
projectGroups.map((group) => {
@@ -539,7 +541,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
{badgeCfg.label}
</span>
<span className="text-[10px] text-muted-foreground/40">
{formatRelativeTime(session.updated_at)}
{formatRelativeTime(session.updated_at, t)}
</span>
</div>
</Link>
@@ -560,12 +562,12 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
className="h-3 w-3"
/>
<span className="sr-only">
Delete session
{t('chatList.delete')}
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
Delete
{t('chatList.delete')}
</TooltipContent>
</Tooltip>
)}
+7 -5
View File
@@ -11,6 +11,7 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslation } from "@/hooks/useTranslation";
import { InstallWizard } from "@/components/layout/InstallWizard";
interface ClaudeStatus {
@@ -23,6 +24,7 @@ const BACKED_OFF_INTERVAL = 60_000; // 60s after 3 consecutive stable results
const STABLE_THRESHOLD = 3;
export function ConnectionStatus() {
const { t } = useTranslation();
const [status, setStatus] = useState<ClaudeStatus | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [wizardOpen, setWizardOpen] = useState(false);
@@ -150,7 +152,7 @@ export function ConnectionStatus() {
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{connected ? "Claude Code Connected" : "Claude Code Not Connected"}
{connected ? t('connection.installed') : t('connection.notInstalled')}
</DialogTitle>
<DialogDescription>
{connected
@@ -165,7 +167,7 @@ export function ConnectionStatus() {
<span className="block h-2.5 w-2.5 shrink-0 rounded-full bg-emerald-500" />
<div>
<p className="font-medium text-emerald-700 dark:text-emerald-400">Active</p>
<p className="text-xs text-muted-foreground">Version {status?.version}</p>
<p className="text-xs text-muted-foreground">{t('connection.version', { version: status?.version ?? '' })}</p>
</div>
</div>
</div>
@@ -177,7 +179,7 @@ export function ConnectionStatus() {
</div>
<div>
<h4 className="font-medium mb-1.5">1. Install Claude Code</h4>
<h4 className="font-medium mb-1.5">1. {t('connection.installClaude')}</h4>
<code className="block rounded-md bg-muted px-3 py-2 text-xs">
npm install -g @anthropic-ai/claude-code
</code>
@@ -206,7 +208,7 @@ export function ConnectionStatus() {
}}
className="w-full"
>
Install Claude Code Automatically
{t('connection.installAuto')}
</Button>
</div>
)}
@@ -218,7 +220,7 @@ export function ConnectionStatus() {
variant="outline"
onClick={handleManualRefresh}
>
Refresh
{t('connection.refresh')}
</Button>
</DialogFooter>
</DialogContent>
+3 -1
View File
@@ -14,6 +14,7 @@ import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { usePanel } from "@/hooks/usePanel";
import { useTranslation } from "@/hooks/useTranslation";
import type { FilePreview as FilePreviewType } from "@/types";
const streamdownPlugins = { cjk, code, math, mermaid };
@@ -252,13 +253,14 @@ function RenderedView({
content: string;
filePath: string;
}) {
const { t } = useTranslation();
if (isHtml(filePath)) {
return (
<iframe
srcDoc={content}
sandbox=""
className="h-full w-full border-0"
title="HTML Preview"
title={t('docPreview.htmlPreview')}
/>
);
}
+61
View File
@@ -0,0 +1,61 @@
'use client';
import { createContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import { type Locale, type TranslationKey, translate } from '@/i18n';
interface I18nContextValue {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: TranslationKey, params?: Record<string, string | number>) => string;
}
export const I18nContext = createContext<I18nContextValue>({
locale: 'en',
setLocale: () => {},
t: (key) => key,
});
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>('en');
// Load persisted locale on mount
useEffect(() => {
async function loadLocale() {
try {
const res = await fetch('/api/settings/app');
if (res.ok) {
const data = await res.json();
const saved = data.settings?.locale;
if (saved === 'en' || saved === 'zh') {
setLocaleState(saved);
}
}
} catch {
// ignore — default to 'en'
}
}
loadLocale();
}, []);
const setLocale = useCallback((newLocale: Locale) => {
setLocaleState(newLocale);
// Persist to app settings
fetch('/api/settings/app', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: { locale: newLocale } }),
}).catch(() => {});
}, []);
const t = useCallback(
(key: TranslationKey, params?: Record<string, string | number>) =>
translate(locale, key, params),
[locale],
);
return (
<I18nContext.Provider value={{ locale, setLocale, t }}>
{children}
</I18nContext.Provider>
);
}
+18 -13
View File
@@ -23,6 +23,7 @@ import {
FileImportIcon,
MessageAddIcon,
} from "@hugeicons/core-free-icons";
import { useTranslation } from "@/hooks/useTranslation";
import { cn } from "@/lib/utils";
interface ClaudeSessionInfo {
@@ -45,7 +46,7 @@ interface ImportSessionDialogProps {
onOpenChange: (open: boolean) => void;
}
function formatRelativeTime(dateStr: string): string {
function formatRelativeTime(dateStr: string): { key: 'import.justNow' | 'import.minutesAgo' | 'import.hoursAgo' | 'import.daysAgo'; params?: Record<string, number> } | string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
@@ -53,10 +54,10 @@ function formatRelativeTime(dateStr: string): string {
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHr < 24) return `${diffHr}h ago`;
if (diffDay < 7) return `${diffDay}d ago`;
if (diffMin < 1) return { key: 'import.justNow' };
if (diffMin < 60) return { key: 'import.minutesAgo', params: { n: diffMin } };
if (diffHr < 24) return { key: 'import.hoursAgo', params: { n: diffHr } };
if (diffDay < 7) return { key: 'import.daysAgo', params: { n: diffDay } };
return date.toLocaleDateString();
}
@@ -71,6 +72,7 @@ export function ImportSessionDialog({
onOpenChange,
}: ImportSessionDialogProps) {
const router = useRouter();
const { t } = useTranslation();
const [sessions, setSessions] = useState<ClaudeSessionInfo[]>([]);
const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState<string | null>(null);
@@ -154,7 +156,7 @@ export function ImportSessionDialog({
icon={FileImportIcon}
className="h-5 w-5 text-primary"
/>
Import CLI Session
{t('import.title')}
</DialogTitle>
<DialogDescription>
Browse and import conversations from Claude Code CLI. Imported
@@ -169,7 +171,7 @@ export function ImportSessionDialog({
className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder="Search by project, message, or branch..."
placeholder={t('import.searchSessions')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 text-sm"
@@ -204,8 +206,8 @@ export function ImportSessionDialog({
/>
<p className="text-sm">
{searchQuery
? "No matching sessions"
: "No Claude Code CLI sessions found"}
? t('import.noSessions')
: t('import.noSessions')}
</p>
<p className="text-xs mt-1 opacity-60">
{searchQuery
@@ -264,7 +266,7 @@ export function ImportSessionDialog({
icon={Loading02Icon}
className="h-3 w-3 mr-1 animate-spin"
/>
Importing...
{t('import.importing')}
</>
) : (
<>
@@ -272,7 +274,7 @@ export function ImportSessionDialog({
icon={FileImportIcon}
className="h-3 w-3 mr-1"
/>
Import
{t('import.import')}
</>
)}
</Button>
@@ -295,14 +297,17 @@ export function ImportSessionDialog({
icon={MessageAddIcon}
className="h-2.5 w-2.5"
/>
{totalMessages} msg{totalMessages !== 1 ? "s" : ""}
{t(totalMessages !== 1 ? 'import.messagesPlural' : 'import.messages', { n: totalMessages })}
</span>
<span className="flex items-center gap-0.5 shrink-0">
<HugeiconsIcon
icon={ClockIcon}
className="h-2.5 w-2.5"
/>
{formatRelativeTime(session.updatedAt)}
{(() => {
const rel = formatRelativeTime(session.updatedAt);
return typeof rel === 'string' ? rel : t(rel.key, rel.params);
})()}
</span>
<span className="shrink-0">
{formatFileSize(session.fileSize)}
+11 -9
View File
@@ -21,6 +21,7 @@ import {
Copy01Icon,
Download04Icon,
} from "@hugeicons/core-free-icons";
import { useTranslation } from "@/hooks/useTranslation";
interface InstallProgress {
status: "idle" | "running" | "success" | "failed" | "cancelled";
@@ -93,6 +94,7 @@ export function InstallWizard({
onOpenChange,
onInstallComplete,
}: InstallWizardProps) {
const { t } = useTranslation();
const [phase, setPhase] = useState<WizardPhase>("checking");
const [progress, setProgress] = useState<InstallProgress | null>(null);
const [logs, setLogs] = useState<string[]>([]);
@@ -249,7 +251,7 @@ export function InstallWizard({
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Install Claude Code</DialogTitle>
<DialogTitle>{t('install.title')}</DialogTitle>
<DialogDescription>
{phase === "confirm"
? "Claude Code CLI was not detected. Install it now?"
@@ -292,7 +294,7 @@ export function InstallWizard({
{phase === "checking" && steps.length === 0 && (
<div className="flex items-center gap-2.5 text-sm text-muted-foreground">
<HugeiconsIcon icon={Loading02Icon} className="size-4 animate-spin" />
<span>Checking environment...</span>
<span>{t('install.checkingPrereqs')}</span>
</div>
)}
@@ -330,7 +332,7 @@ export function InstallWizard({
Already installed
</p>
<p className="text-muted-foreground text-xs">
Claude Code is already available.
{t('install.alreadyInstalled')}
</p>
</div>
</div>
@@ -342,7 +344,7 @@ export function InstallWizard({
<HugeiconsIcon icon={Tick01Icon} className="size-5 text-emerald-500 shrink-0" />
<div className="text-sm">
<p className="font-medium text-emerald-700 dark:text-emerald-400">
Installation complete
{t('install.complete')}
</p>
<p className="text-muted-foreground text-xs">
Claude Code CLI has been installed successfully.
@@ -374,7 +376,7 @@ export function InstallWizard({
onClick={handleCopyLogs}
>
<HugeiconsIcon icon={Copy01Icon} />
{copied ? "Copied" : "Copy Logs"}
{copied ? t('install.copied') : t('install.copyLogs')}
</Button>
)}
@@ -382,28 +384,28 @@ export function InstallWizard({
{phase === "confirm" && (
<Button size="sm" onClick={handleConfirmInstall}>
<HugeiconsIcon icon={Download04Icon} />
Install
{t('install.install')}
</Button>
)}
{/* Installing: cancel button */}
{phase === "installing" && (
<Button variant="destructive" size="sm" onClick={cancelInstall}>
Cancel
{t('install.cancel')}
</Button>
)}
{/* Failed: retry */}
{phase === "failed" && (
<Button size="sm" onClick={checkPrereqs}>
Retry
{t('install.retry')}
</Button>
)}
{/* Success / already-installed: done */}
{(phase === "success" || phase === "already-installed") && (
<Button size="sm" onClick={handleDone}>
Done
{t('install.done')}
</Button>
)}
</DialogFooter>
+14 -6
View File
@@ -19,6 +19,8 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useTranslation } from "@/hooks/useTranslation";
import type { TranslationKey } from "@/i18n";
interface NavRailProps {
@@ -38,6 +40,12 @@ export function NavRail({ onToggleChatList, hasUpdate, skipPermissionsActive }:
const pathname = usePathname();
const router = useRouter();
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
const navLabelKeys: Record<string, TranslationKey> = {
'Chats': 'nav.chats',
'Extensions': 'nav.extensions',
'Settings': 'nav.settings',
};
const emptySubscribe = useCallback(() => () => {}, []);
const mounted = useSyncExternalStore(emptySubscribe, () => true, () => false);
const isChatRoute = pathname === "/chat" || pathname.startsWith("/chat/");
@@ -76,7 +84,7 @@ export function NavRail({ onToggleChatList, hasUpdate, skipPermissionsActive }:
}}
>
<HugeiconsIcon icon={item.icon} className="h-4 w-4" />
<span className="sr-only">{item.label}</span>
<span className="sr-only">{t(navLabelKeys[item.label] ?? item.label as TranslationKey)}</span>
</Button>
) : (
<div className="relative">
@@ -91,7 +99,7 @@ export function NavRail({ onToggleChatList, hasUpdate, skipPermissionsActive }:
>
<Link href={item.href}>
<HugeiconsIcon icon={item.icon} className="h-4 w-4" />
<span className="sr-only">{item.label}</span>
<span className="sr-only">{t(navLabelKeys[item.label] ?? item.label as TranslationKey)}</span>
</Link>
</Button>
{item.href === "/settings" && hasUpdate && (
@@ -100,7 +108,7 @@ export function NavRail({ onToggleChatList, hasUpdate, skipPermissionsActive }:
</div>
)}
</TooltipTrigger>
<TooltipContent side="right">{item.label}</TooltipContent>
<TooltipContent side="right">{t(navLabelKeys[item.label] ?? item.label as TranslationKey)}</TooltipContent>
</Tooltip>
);
})}
@@ -118,7 +126,7 @@ export function NavRail({ onToggleChatList, hasUpdate, skipPermissionsActive }:
</span>
</div>
</TooltipTrigger>
<TooltipContent side="right">Auto-approve is ON</TooltipContent>
<TooltipContent side="right">{t('nav.autoApproveOn')}</TooltipContent>
</Tooltip>
)}
{mounted && (
@@ -135,11 +143,11 @@ export function NavRail({ onToggleChatList, hasUpdate, skipPermissionsActive }:
) : (
<HugeiconsIcon icon={Moon02Icon} className="h-4 w-4" />
)}
<span className="sr-only">Toggle theme</span>
<span className="sr-only">{t('nav.toggleTheme')}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{theme === "dark" ? "Light mode" : "Dark mode"}
{theme === "dark" ? t('nav.lightMode') : t('nav.darkMode')}
</TooltipContent>
</Tooltip>
)}
+7 -5
View File
@@ -10,6 +10,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { usePanel } from "@/hooks/usePanel";
import { useTranslation } from "@/hooks/useTranslation";
import { FileTree } from "@/components/project/FileTree";
interface RightPanelProps {
@@ -18,6 +19,7 @@ interface RightPanelProps {
export function RightPanel({ width }: RightPanelProps) {
const { panelOpen, setPanelOpen, workingDirectory, previewFile, setPreviewFile } = usePanel();
const { t } = useTranslation();
const handleFileAdd = useCallback((path: string) => {
window.dispatchEvent(new CustomEvent('attach-file-to-chat', { detail: { path } }));
@@ -56,10 +58,10 @@ export function RightPanel({ width }: RightPanelProps) {
onClick={() => setPanelOpen(true)}
>
<HugeiconsIcon icon={StructureFolderIcon} className="h-4 w-4" />
<span className="sr-only">Open panel</span>
<span className="sr-only">{t('panel.openPanel')}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="left">Open panel</TooltipContent>
<TooltipContent side="left">{t('panel.openPanel')}</TooltipContent>
</Tooltip>
</div>
);
@@ -69,7 +71,7 @@ export function RightPanel({ width }: RightPanelProps) {
<aside className="hidden h-full shrink-0 flex-col overflow-hidden bg-background lg:flex" style={{ width: width ?? 288 }}>
{/* Header */}
<div className="flex h-12 mt-5 shrink-0 items-center justify-between px-4">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Files</span>
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('panel.files')}</span>
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -78,10 +80,10 @@ export function RightPanel({ width }: RightPanelProps) {
onClick={() => setPanelOpen(false)}
>
<HugeiconsIcon icon={PanelRightCloseIcon} className="h-4 w-4" />
<span className="sr-only">Close panel</span>
<span className="sr-only">{t('panel.closePanel')}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="left">Close panel</TooltipContent>
<TooltipContent side="left">{t('panel.closePanel')}</TooltipContent>
</Tooltip>
</div>
+3 -1
View File
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { useTranslation } from '@/hooks/useTranslation';
interface ConfigEditorProps {
value: string;
@@ -12,6 +13,7 @@ interface ConfigEditorProps {
}
export function ConfigEditor({ value, onSave, label }: ConfigEditorProps) {
const { t } = useTranslation();
const [text, setText] = useState(value);
const [error, setError] = useState<string | null>(null);
@@ -52,7 +54,7 @@ export function ConfigEditor({ value, onSave, label }: ConfigEditorProps) {
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex gap-2">
<Button size="sm" onClick={handleSave}>
Save
{t('common.save')}
</Button>
<Button size="sm" variant="outline" onClick={handleFormat}>
Format
+8 -6
View File
@@ -8,9 +8,11 @@ import { PlusSignIcon, ListViewIcon, CodeIcon, Loading02Icon } from "@hugeicons/
import { McpServerList } from "@/components/plugins/McpServerList";
import { McpServerEditor } from "@/components/plugins/McpServerEditor";
import { ConfigEditor } from "@/components/plugins/ConfigEditor";
import { useTranslation } from "@/hooks/useTranslation";
import type { MCPServer } from "@/types";
export function McpManager() {
const { t } = useTranslation();
const [servers, setServers] = useState<Record<string, MCPServer>>({});
const [loading, setLoading] = useState(true);
const [editorOpen, setEditorOpen] = useState(false);
@@ -140,7 +142,7 @@ export function McpManager() {
<div className="flex items-center justify-between mb-4">
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">MCP Servers</h3>
<h3 className="text-lg font-semibold">{t('extensions.mcpServers')}</h3>
{serverCount > 0 && (
<span className="text-sm text-muted-foreground">
({serverCount})
@@ -153,7 +155,7 @@ export function McpManager() {
</div>
<Button size="sm" className="gap-1" onClick={handleAdd}>
<HugeiconsIcon icon={PlusSignIcon} className="h-3.5 w-3.5" />
Add Server
{t('mcp.addServer')}
</Button>
</div>
@@ -167,11 +169,11 @@ export function McpManager() {
<TabsList>
<TabsTrigger value="list" className="gap-1.5">
<HugeiconsIcon icon={ListViewIcon} className="h-3.5 w-3.5" />
Servers
{t('mcp.listTab')}
</TabsTrigger>
<TabsTrigger value="json" className="gap-1.5">
<HugeiconsIcon icon={CodeIcon} className="h-3.5 w-3.5" />
JSON Config
{t('mcp.jsonTab')}
</TabsTrigger>
</TabsList>
@@ -179,7 +181,7 @@ export function McpManager() {
{loading ? (
<div className="flex items-center justify-center gap-2 py-12 text-muted-foreground">
<HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin" />
<p className="text-sm">Loading MCP servers...</p>
<p className="text-sm">{t('mcp.loadingServers')}</p>
</div>
) : (
<McpServerList
@@ -194,7 +196,7 @@ export function McpManager() {
<ConfigEditor
value={JSON.stringify(servers, null, 2)}
onSave={handleJsonSave}
label="MCP Server Configuration"
label={t('mcp.serverConfig')}
/>
</TabsContent>
</Tabs>
+14 -12
View File
@@ -15,6 +15,7 @@ import {
} from '@/components/ui/dialog';
import { HugeiconsIcon } from "@hugeicons/react";
import { ServerStack01Icon, Wifi01Icon, GlobeIcon, CodeIcon } from "@hugeicons/core-free-icons";
import { useTranslation } from '@/hooks/useTranslation';
import type { MCPServer } from '@/types';
type ServerType = 'stdio' | 'sse' | 'http';
@@ -35,6 +36,7 @@ export function McpServerEditor({
onSave,
}: McpServerEditorProps) {
const isEditing = !!initialName;
const { t } = useTranslation();
const [name, setName] = useState(initialName || '');
const [serverType, setServerType] = useState<ServerType>(
initialServer?.type || 'stdio'
@@ -177,13 +179,13 @@ export function McpServerEditor({
<DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{isEditing ? `Edit MCP Server: ${initialName}` : 'Add MCP Server'}
{isEditing ? `${t('mcp.editServer')}: ${initialName}` : t('mcp.addServer')}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="server-name">Server Name</Label>
<Label htmlFor="server-name">{t('mcp.serverName')}</Label>
<Input
id="server-name"
value={name}
@@ -206,7 +208,7 @@ export function McpServerEditor({
setError(null);
}}
>
Form
{t('mcp.formTab')}
</Button>
<Button
variant={jsonMode ? 'default' : 'outline'}
@@ -237,7 +239,7 @@ export function McpServerEditor({
}}
>
<HugeiconsIcon icon={CodeIcon} className="h-3.5 w-3.5" />
JSON
{t('mcp.jsonEditTab')}
</Button>
</div>
@@ -257,7 +259,7 @@ export function McpServerEditor({
) : (
<>
<div className="space-y-2">
<Label>Server Type</Label>
<Label>{t('mcp.serverType')}</Label>
<Tabs
value={serverType}
onValueChange={(v) => {
@@ -285,7 +287,7 @@ export function McpServerEditor({
{serverType === 'stdio' ? (
<>
<div className="space-y-2">
<Label htmlFor="server-command">Command</Label>
<Label htmlFor="server-command">{t('mcp.command')}</Label>
<Input
id="server-command"
value={command}
@@ -299,7 +301,7 @@ export function McpServerEditor({
</div>
<div className="space-y-2">
<Label htmlFor="server-args">Arguments (one per line)</Label>
<Label htmlFor="server-args">{t('mcp.argsLabel')}</Label>
<Textarea
id="server-args"
value={args}
@@ -312,7 +314,7 @@ export function McpServerEditor({
) : (
<>
<div className="space-y-2">
<Label htmlFor="server-url">URL</Label>
<Label htmlFor="server-url">{t('mcp.url')}</Label>
<Input
id="server-url"
value={url}
@@ -330,7 +332,7 @@ export function McpServerEditor({
</div>
<div className="space-y-2">
<Label htmlFor="server-headers">Headers (JSON)</Label>
<Label htmlFor="server-headers">{t('mcp.headers')}</Label>
<Textarea
id="server-headers"
value={headersText}
@@ -346,7 +348,7 @@ export function McpServerEditor({
)}
<div className="space-y-2">
<Label htmlFor="server-env">Environment Variables (JSON)</Label>
<Label htmlFor="server-env">{t('mcp.envVars')}</Label>
<Textarea
id="server-env"
value={envText}
@@ -366,10 +368,10 @@ export function McpServerEditor({
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
{t('common.cancel')}
</Button>
<Button onClick={handleSave}>
{isEditing ? 'Save Changes' : 'Add Server'}
{isEditing ? t('mcp.saveChanges') : t('mcp.addServer')}
</Button>
</DialogFooter>
</DialogContent>
+7 -5
View File
@@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge';
import { HugeiconsIcon } from "@hugeicons/react";
import type { IconSvgElement } from "@hugeicons/react";
import { Delete02Icon, PencilIcon, ServerStack01Icon, Wifi01Icon, GlobeIcon } from "@hugeicons/core-free-icons";
import { useTranslation } from '@/hooks/useTranslation';
import type { MCPServer } from '@/types';
interface McpServerListProps {
@@ -27,15 +28,16 @@ function getServerTypeInfo(server: MCPServer) {
}
export function McpServerList({ servers, onEdit, onDelete }: McpServerListProps) {
const { t } = useTranslation();
const entries = Object.entries(servers);
if (entries.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<HugeiconsIcon icon={ServerStack01Icon} className="h-10 w-10 mb-3 opacity-50" />
<p className="text-sm">No MCP servers configured</p>
<p className="text-sm">{t('mcp.noServers')}</p>
<p className="text-xs mt-1">
Add an MCP server to extend Claude&apos;s capabilities
{t('mcp.noServersDesc')}
</p>
</div>
);
@@ -56,7 +58,7 @@ export function McpServerList({ servers, onEdit, onDelete }: McpServerListProps)
{typeInfo.label}
</Badge>
<Badge variant="secondary" className="text-xs shrink-0">
Configured
{t('provider.configured')}
</Badge>
</div>
<CardDescription className="text-xs mt-1 font-mono">
@@ -89,7 +91,7 @@ export function McpServerList({ servers, onEdit, onDelete }: McpServerListProps)
<CardContent className="pt-0">
{server.args && server.args.length > 0 && (
<div className="mb-2">
<p className="text-xs text-muted-foreground mb-1">Arguments:</p>
<p className="text-xs text-muted-foreground mb-1">{t('mcp.arguments')}</p>
<div className="flex gap-1 flex-wrap">
{server.args.map((arg, i) => (
<Badge key={i} variant="outline" className="text-xs font-mono">
@@ -101,7 +103,7 @@ export function McpServerList({ servers, onEdit, onDelete }: McpServerListProps)
)}
{server.env && Object.keys(server.env).length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-1">Environment:</p>
<p className="text-xs text-muted-foreground mb-1">{t('mcp.environment')}</p>
<div className="flex gap-1 flex-wrap">
{Object.keys(server.env).map((key) => (
<Badge key={key} variant="outline" className="text-xs font-mono">
+8 -6
View File
@@ -9,6 +9,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
import { usePanel } from "@/hooks/usePanel";
import { useTranslation } from "@/hooks/useTranslation";
import type { FilePreview as FilePreviewType } from "@/types";
interface FilePreviewProps {
@@ -18,6 +19,7 @@ interface FilePreviewProps {
export function FilePreview({ filePath, onBack }: FilePreviewProps) {
const { workingDirectory } = usePanel();
const { t } = useTranslation();
const [preview, setPreview] = useState<FilePreviewType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -33,12 +35,12 @@ export function FilePreview({ filePath, onBack }: FilePreviewProps) {
);
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to load file");
throw new Error(data.error || t('filePreview.failedToLoad'));
}
const data = await res.json();
setPreview(data.preview);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load file");
setError(err instanceof Error ? err.message : t('filePreview.failedToLoad'));
} finally {
setLoading(false);
}
@@ -66,7 +68,7 @@ export function FilePreview({ filePath, onBack }: FilePreviewProps) {
<div className="flex items-center gap-2 pb-2">
<Button variant="ghost" size="icon-sm" onClick={onBack}>
<HugeiconsIcon icon={ArrowLeft01Icon} className="h-3.5 w-3.5" />
<span className="sr-only">Back to file tree</span>
<span className="sr-only">{t('filePreview.backToTree')}</span>
</Button>
<div className="min-w-0 flex-1">
<p className="truncate text-xs text-muted-foreground">
@@ -79,7 +81,7 @@ export function FilePreview({ filePath, onBack }: FilePreviewProps) {
) : (
<HugeiconsIcon icon={Copy01Icon} className="h-3.5 w-3.5" />
)}
<span className="sr-only">Copy path</span>
<span className="sr-only">{t('filePreview.copyPath')}</span>
</Button>
</div>
@@ -90,7 +92,7 @@ export function FilePreview({ filePath, onBack }: FilePreviewProps) {
{preview.language}
</Badge>
<span className="text-[10px] text-muted-foreground">
{preview.line_count} lines
{t('filePreview.lines', { count: preview.line_count })}
</span>
</div>
)}
@@ -110,7 +112,7 @@ export function FilePreview({ filePath, onBack }: FilePreviewProps) {
onClick={onBack}
className="mt-2 text-xs"
>
Back to file tree
{t('filePreview.backToTree')}
</Button>
</div>
) : preview ? (
+5 -3
View File
@@ -12,6 +12,7 @@ import {
FileTreeFolder,
FileTreeFile,
} from "@/components/ai-elements/file-tree";
import { useTranslation } from "@/hooks/useTranslation";
import type { ReactNode } from "react";
interface FileTreeProps {
@@ -109,6 +110,7 @@ export function FileTree({ workingDirectory, onFileSelect, onFileAdd }: FileTree
const [tree, setTree] = useState<FileTreeNode[]>([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const { t } = useTranslation();
const fetchTree = useCallback(async () => {
if (!workingDirectory) {
@@ -154,7 +156,7 @@ export function FileTree({ workingDirectory, onFileSelect, onFileAdd }: FileTree
<div className="relative flex-1 min-w-0">
<HugeiconsIcon icon={Search01Icon} className="absolute left-2.5 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground pointer-events-none" />
<Input
placeholder="Filter files..."
placeholder={t('fileTree.filterFiles')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-7 pl-7 text-xs"
@@ -168,7 +170,7 @@ export function FileTree({ workingDirectory, onFileSelect, onFileAdd }: FileTree
className="h-7 w-7 shrink-0"
>
<HugeiconsIcon icon={RefreshIcon} className={cn("h-3 w-3", loading && "animate-spin")} />
<span className="sr-only">Refresh</span>
<span className="sr-only">{t('fileTree.refresh')}</span>
</Button>
</div>
@@ -180,7 +182,7 @@ export function FileTree({ workingDirectory, onFileSelect, onFileAdd }: FileTree
</div>
) : tree.length === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">
{workingDirectory ? 'No files found' : 'Select a project folder to view files'}
{workingDirectory ? t('fileTree.noFiles') : t('fileTree.selectFolder')}
</p>
) : (
<AIFileTree
+9 -7
View File
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { TaskCard } from "./TaskCard";
import { useTranslation } from "@/hooks/useTranslation";
import type { TaskItem, TaskStatus } from "@/types";
interface TaskListProps {
@@ -17,6 +18,7 @@ interface TaskListProps {
type FilterTab = "all" | "in_progress" | "completed";
export function TaskList({ sessionId }: TaskListProps) {
const { t } = useTranslation();
const [tasks, setTasks] = useState<TaskItem[]>([]);
const [loading, setLoading] = useState(false);
const [newTitle, setNewTitle] = useState("");
@@ -101,9 +103,9 @@ export function TaskList({ sessionId }: TaskListProps) {
});
const filterTabs: { key: FilterTab; label: string }[] = [
{ key: "all", label: "All" },
{ key: "in_progress", label: "Active" },
{ key: "completed", label: "Done" },
{ key: "all", label: t('tasks.all') },
{ key: "in_progress", label: t('tasks.active') },
{ key: "completed", label: t('tasks.done') },
];
return (
@@ -129,7 +131,7 @@ export function TaskList({ sessionId }: TaskListProps) {
{/* Add task input */}
<div className="flex items-center gap-1 pb-2">
<Input
placeholder="Add a task..."
placeholder={t('tasks.addPlaceholder')}
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => {
@@ -144,7 +146,7 @@ export function TaskList({ sessionId }: TaskListProps) {
disabled={!newTitle.trim()}
>
<HugeiconsIcon icon={PlusSignIcon} className="h-3.5 w-3.5" />
<span className="sr-only">Add task</span>
<span className="sr-only">{t('tasks.addTask')}</span>
</Button>
</div>
@@ -152,11 +154,11 @@ export function TaskList({ sessionId }: TaskListProps) {
<ScrollArea className="flex-1">
{loading && tasks.length === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">
Loading tasks...
{t('tasks.loading')}
</p>
) : filtered.length === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">
{tasks.length === 0 ? "No tasks yet" : "No matching tasks"}
{tasks.length === 0 ? t('tasks.noTasks') : t('tasks.noMatching')}
</p>
) : (
<div className="flex flex-col gap-1.5 pb-4">
+34 -19
View File
@@ -25,6 +25,8 @@ import {
SlidersHorizontalIcon,
Loading02Icon,
} from "@hugeicons/core-free-icons";
import { useTranslation } from "@/hooks/useTranslation";
import type { TranslationKey } from "@/i18n";
interface SettingsData {
[key: string]: unknown;
@@ -55,6 +57,19 @@ export function CliSettingsSection() {
const [saveSuccess, setSaveSuccess] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [pendingSaveAction, setPendingSaveAction] = useState<"form" | "json" | null>(null);
const { t } = useTranslation();
const knownFieldKeys: Record<string, { label: TranslationKey; description: TranslationKey }> = {
permissions: { label: 'cli.permissions', description: 'cli.permissionsDesc' },
env: { label: 'cli.envVars', description: 'cli.envVarsDesc' },
};
// Map dynamic CLI settings keys to translation keys (for fields not in KNOWN_FIELDS)
const dynamicFieldLabels: Record<string, TranslationKey> = {
skipDangerousModePermissionPrompt: 'cli.field.skipDangerousModePermissionPrompt',
verbose: 'cli.field.verbose',
theme: 'cli.field.theme',
};
const fetchSettings = useCallback(async () => {
try {
@@ -132,7 +147,7 @@ export function CliSettingsSection() {
setJsonText(JSON.stringify(parsed, null, 2));
setJsonError("");
} catch {
setJsonError("Cannot format: invalid JSON");
setJsonError(t('cli.formatError'));
}
};
@@ -149,7 +164,7 @@ export function CliSettingsSection() {
return (
<div className="flex items-center justify-center py-12">
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">Loading settings...</span>
<span className="ml-2 text-sm text-muted-foreground">{t('cli.loadingSettings')}</span>
</div>
);
}
@@ -160,11 +175,11 @@ export function CliSettingsSection() {
<TabsList className="mb-4">
<TabsTrigger value="form" className="gap-2">
<HugeiconsIcon icon={SlidersHorizontalIcon} className="h-4 w-4" />
Visual Editor
{t('cli.form')}
</TabsTrigger>
<TabsTrigger value="json" className="gap-2">
<HugeiconsIcon icon={CodeIcon} className="h-4 w-4" />
JSON Editor
{t('cli.json')}
</TabsTrigger>
</TabsList>
@@ -175,8 +190,8 @@ export function CliSettingsSection() {
key={field.key}
className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm"
>
<Label className="text-sm font-medium">{field.label}</Label>
<p className="mb-2 text-xs text-muted-foreground">{field.description}</p>
<Label className="text-sm font-medium">{t(knownFieldKeys[field.key]?.label ?? field.label as TranslationKey)}</Label>
<p className="mb-2 text-xs text-muted-foreground">{t(knownFieldKeys[field.key]?.description ?? field.description as TranslationKey)}</p>
<Textarea
value={
typeof settings[field.key] === "object"
@@ -204,7 +219,7 @@ export function CliSettingsSection() {
key={key}
className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm"
>
<Label className="text-sm font-medium">{key}</Label>
<Label className="text-sm font-medium">{dynamicFieldLabels[key] ? t(dynamicFieldLabels[key]) : key}</Label>
{typeof value === "boolean" ? (
<div className="mt-2 flex items-center gap-2">
<Switch
@@ -212,7 +227,7 @@ export function CliSettingsSection() {
onCheckedChange={(checked) => updateField(key, checked)}
/>
<span className="text-sm text-muted-foreground">
{value ? "Enabled" : "Disabled"}
{value ? t('common.enabled') : t('common.disabled')}
</span>
</div>
) : typeof value === "string" ? (
@@ -245,15 +260,15 @@ export function CliSettingsSection() {
) : (
<HugeiconsIcon icon={FloppyDiskIcon} className="h-4 w-4" />
)}
{saving ? "Saving..." : "Save Changes"}
{saving ? t('provider.saving') : t('cli.save')}
</Button>
<Button variant="outline" onClick={handleReset} disabled={!hasChanges} className="gap-2">
<HugeiconsIcon icon={ReloadIcon} className="h-4 w-4" />
Reset
{t('cli.reset')}
</Button>
{saveSuccess && (
<span className="text-sm text-green-600 dark:text-green-400">
Settings saved successfully
{t('cli.settingsSaved')}
</span>
)}
</div>
@@ -280,19 +295,19 @@ export function CliSettingsSection() {
) : (
<HugeiconsIcon icon={FloppyDiskIcon} className="h-4 w-4" />
)}
{saving ? "Saving..." : "Save JSON"}
{saving ? t('provider.saving') : t('cli.save')}
</Button>
<Button variant="outline" onClick={handleFormatJson} className="gap-2">
<HugeiconsIcon icon={CodeIcon} className="h-4 w-4" />
Format
{t('cli.format')}
</Button>
<Button variant="outline" onClick={handleReset} className="gap-2">
<HugeiconsIcon icon={ReloadIcon} className="h-4 w-4" />
Reset
{t('cli.reset')}
</Button>
{saveSuccess && (
<span className="text-sm text-green-600 dark:text-green-400">
Settings saved successfully
{t('cli.settingsSaved')}
</span>
)}
</div>
@@ -304,15 +319,15 @@ export function CliSettingsSection() {
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Save</AlertDialogTitle>
<AlertDialogTitle>{t('cli.confirmSaveTitle')}</AlertDialogTitle>
<AlertDialogDescription>
This will overwrite your current ~/.claude/settings.json file. Are you sure you want to continue?
{t('cli.confirmSaveDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => pendingSaveAction && handleSave(pendingSaveAction)}>
Save
{t('common.save')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+42 -21
View File
@@ -16,17 +16,21 @@ import {
import { HugeiconsIcon } from "@hugeicons/react";
import { ReloadIcon, Loading02Icon } from "@hugeicons/core-free-icons";
import { useUpdate } from "@/hooks/useUpdate";
import { useTranslation } from "@/hooks/useTranslation";
import { SUPPORTED_LOCALES, type Locale } from "@/i18n";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
function UpdateCard() {
const { updateInfo, checking, checkForUpdates } = useUpdate();
const { t } = useTranslation();
const currentVersion = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
return (
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">CodePilot</h2>
<p className="text-xs text-muted-foreground">Version {currentVersion}</p>
<h2 className="text-sm font-medium">{t('settings.codepilot')}</h2>
<p className="text-xs text-muted-foreground">{t('settings.version', { version: currentVersion })}</p>
</div>
<Button
variant="outline"
@@ -40,7 +44,7 @@ function UpdateCard() {
) : (
<HugeiconsIcon icon={ReloadIcon} className="h-3.5 w-3.5" />
)}
{checking ? "Checking..." : "Check for Updates"}
{checking ? t('settings.checking') : t('settings.checkForUpdates')}
</Button>
</div>
@@ -50,7 +54,7 @@ function UpdateCard() {
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-blue-500" />
<span className="text-sm">
Update available: <span className="font-medium">v{updateInfo.latestVersion}</span>
{t('settings.updateAvailable', { version: updateInfo.latestVersion })}
</span>
<Button
variant="link"
@@ -58,11 +62,11 @@ function UpdateCard() {
className="h-auto p-0 text-sm"
onClick={() => window.open(updateInfo.releaseUrl, "_blank")}
>
View Release
{t('settings.viewRelease')}
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground">You&apos;re on the latest version.</p>
<p className="text-sm text-muted-foreground">{t('settings.latestVersion')}</p>
)}
</div>
)}
@@ -74,6 +78,7 @@ export function GeneralSection() {
const [skipPermissions, setSkipPermissions] = useState(false);
const [showSkipPermWarning, setShowSkipPermWarning] = useState(false);
const [skipPermSaving, setSkipPermSaving] = useState(false);
const { t, locale, setLocale } = useTranslation();
const fetchAppSettings = useCallback(async () => {
try {
@@ -129,10 +134,9 @@ export function GeneralSection() {
<div className={`rounded-lg border p-4 transition-shadow hover:shadow-sm ${skipPermissions ? "border-orange-500/50 bg-orange-500/5" : "border-border/50"}`}>
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">Auto-approve All Actions</h2>
<h2 className="text-sm font-medium">{t('settings.autoApproveTitle')}</h2>
<p className="text-xs text-muted-foreground">
Skip all permission checks and auto-approve every tool action.
This is dangerous and should only be used for trusted tasks.
{t('settings.autoApproveDesc')}
</p>
</div>
<Switch
@@ -144,42 +148,59 @@ export function GeneralSection() {
{skipPermissions && (
<div className="mt-3 flex items-center gap-2 rounded-md bg-orange-500/10 px-3 py-2 text-xs text-orange-600 dark:text-orange-400">
<span className="h-2 w-2 shrink-0 rounded-full bg-orange-500" />
All tool actions will be auto-approved without confirmation. Use with caution.
{t('settings.autoApproveWarning')}
</div>
)}
</div>
{/* Language picker */}
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">{t('settings.language')}</h2>
<p className="text-xs text-muted-foreground">{t('settings.languageDesc')}</p>
</div>
<Select value={locale} onValueChange={(v) => setLocale(v as Locale)}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUPPORTED_LOCALES.map((l) => (
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Skip-permissions warning dialog */}
<AlertDialog open={showSkipPermWarning} onOpenChange={setShowSkipPermWarning}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Enable Auto-approve All Actions?</AlertDialogTitle>
<AlertDialogTitle>{t('settings.autoApproveDialogTitle')}</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>
This will bypass all permission checks. Claude will be able to
execute any tool action without asking for your confirmation,
including:
{t('settings.autoApproveDialogDesc')}
</p>
<ul className="list-disc pl-5 space-y-1">
<li>Running arbitrary shell commands</li>
<li>Reading, writing, and deleting files</li>
<li>Making network requests</li>
<li>{t('settings.autoApproveShellCommands')}</li>
<li>{t('settings.autoApproveFileOps')}</li>
<li>{t('settings.autoApproveNetwork')}</li>
</ul>
<p className="font-medium text-orange-600 dark:text-orange-400">
Only enable this if you fully trust the task at hand. This
setting applies to all new chat sessions.
{t('settings.autoApproveTrustWarning')}
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t('settings.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => saveSkipPermissions(true)}
className="bg-orange-600 hover:bg-orange-700 text-white"
>
Enable Auto-approve
{t('settings.enableAutoApprove')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+13 -11
View File
@@ -23,6 +23,7 @@ import {
import { HugeiconsIcon } from "@hugeicons/react";
import { Loading02Icon, ArrowDown01Icon, ArrowUp01Icon } from "@hugeicons/core-free-icons";
import type { ApiProvider } from "@/types";
import { useTranslation } from "@/hooks/useTranslation";
const PROVIDER_PRESETS: Record<string, { base_url: string; extra_env: string }> = {
anthropic: { base_url: "https://api.anthropic.com", extra_env: "{}" },
@@ -75,6 +76,7 @@ export function ProviderForm({
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const { t } = useTranslation();
// Reset form when dialog opens
useEffect(() => {
@@ -178,7 +180,7 @@ export function ProviderForm({
<DialogContent className="max-w-[28rem] overflow-hidden">
<DialogHeader>
<DialogTitle>
{mode === "edit" ? "Edit Provider" : "Add Provider"}
{mode === "edit" ? t('provider.editProvider') : t('provider.addProvider')}
</DialogTitle>
<DialogDescription>
{mode === "edit"
@@ -190,7 +192,7 @@ export function ProviderForm({
<form onSubmit={handleSubmit} className="space-y-4 min-w-0">
<div className="space-y-2">
<Label htmlFor="provider-name" className="text-xs text-muted-foreground">
Name
{t('provider.name')}
</Label>
<Input
id="provider-name"
@@ -203,7 +205,7 @@ export function ProviderForm({
<div className="space-y-2">
<Label htmlFor="provider-type" className="text-xs text-muted-foreground">
Provider Type
{t('provider.providerType')}
</Label>
<Select value={providerType} onValueChange={handleTypeChange}>
<SelectTrigger className="w-full text-sm">
@@ -221,7 +223,7 @@ export function ProviderForm({
<div className="space-y-2">
<Label htmlFor="provider-base-url" className="text-xs text-muted-foreground">
API Base URL
{t('provider.baseUrl')}
</Label>
<Input
id="provider-base-url"
@@ -234,7 +236,7 @@ export function ProviderForm({
<div className="space-y-2">
<Label htmlFor="provider-api-key" className="text-xs text-muted-foreground">
API Key
{t('provider.apiKey')}
</Label>
<Input
id="provider-api-key"
@@ -256,14 +258,14 @@ export function ProviderForm({
icon={showAdvanced ? ArrowUp01Icon : ArrowDown01Icon}
className="h-3 w-3"
/>
Advanced Options
{t('provider.advancedOptions')}
</button>
{showAdvanced && (
<div className="space-y-4 border-t border-border/50 pt-4">
<div className="space-y-2">
<Label htmlFor="provider-extra-env" className="text-xs text-muted-foreground">
Extra Environment Variables (JSON)
{t('provider.extraEnvVars')} (JSON)
</Label>
<Textarea
id="provider-extra-env"
@@ -277,11 +279,11 @@ export function ProviderForm({
<div className="space-y-2">
<Label htmlFor="provider-notes" className="text-xs text-muted-foreground">
Notes
{t('provider.notes')}
</Label>
<Textarea
id="provider-notes"
placeholder="Optional notes about this provider..."
placeholder={t('provider.notesPlaceholder')}
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="text-sm"
@@ -302,13 +304,13 @@ export function ProviderForm({
onClick={() => onOpenChange(false)}
disabled={saving}
>
Cancel
{t('common.cancel')}
</Button>
<Button type="submit" disabled={saving} className="gap-2">
{saving && (
<HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin" />
)}
{saving ? "Saving..." : mode === "edit" ? "Update" : "Add Provider"}
{saving ? t('provider.saving') : mode === "edit" ? t('provider.update') : t('provider.addProvider')}
</Button>
</DialogFooter>
</form>
+11 -9
View File
@@ -26,6 +26,7 @@ import {
import { ProviderForm } from "./ProviderForm";
import type { ProviderFormData } from "./ProviderForm";
import type { ApiProvider } from "@/types";
import { useTranslation } from "@/hooks/useTranslation";
const QUICK_PRESETS = [
{ name: "Anthropic", provider_type: "anthropic", base_url: "https://api.anthropic.com" },
@@ -46,6 +47,7 @@ export function ProviderManager() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [envDetected, setEnvDetected] = useState<Record<string, string>>({});
const { t } = useTranslation();
// Form dialog state
const [formOpen, setFormOpen] = useState(false);
@@ -215,7 +217,7 @@ export function ProviderManager() {
</div>
<Button size="sm" className="gap-1" onClick={handleAdd}>
<HugeiconsIcon icon={PlusSignIcon} className="h-3.5 w-3.5" />
Add Provider
{t('provider.addProvider')}
</Button>
</div>
@@ -241,7 +243,7 @@ export function ProviderManager() {
? "text-muted-foreground"
: "text-green-700 dark:text-green-400"
}`}>
Environment variables detected
{t('provider.envDetected')}
</p>
{hasActiveProvider ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 text-muted-foreground">
@@ -284,7 +286,7 @@ export function ProviderManager() {
<div className="flex flex-col items-center gap-3 py-8 text-muted-foreground">
<HugeiconsIcon icon={ServerStack01Icon} className="h-10 w-10 opacity-30" />
<div className="text-center">
<p className="text-sm font-medium">No providers configured</p>
<p className="text-sm font-medium">{t('provider.noProviders')}</p>
<p className="text-xs mt-0.5">
{Object.keys(envDetected).length > 0
? "Using environment variables. Add a provider below to override."
@@ -336,7 +338,7 @@ export function ProviderManager() {
</Badge>
{isActive && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-green-600 dark:text-green-400 border-green-500/30">
Active
{t('provider.active')}
</Badge>
)}
</div>
@@ -407,7 +409,7 @@ export function ProviderManager() {
{/* Quick presets row (when providers exist) */}
{!loading && providers.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs text-muted-foreground mr-1">Quick add:</span>
<span className="text-xs text-muted-foreground mr-1">{t('provider.quickPresets')}:</span>
{QUICK_PRESETS.map((preset) => (
<Button
key={preset.name}
@@ -437,19 +439,19 @@ export function ProviderManager() {
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Provider</AlertDialogTitle>
<AlertDialogTitle>{t('provider.deleteProvider')}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{deleteTarget?.name}&quot;? This action cannot be undone.
{t('provider.deleteConfirm', { name: deleteTarget?.name ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
<AlertDialogCancel disabled={deleting}>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={deleting}
className="bg-destructive text-white hover:bg-destructive/90"
>
{deleting ? "Deleting..." : "Delete"}
{deleting ? t('provider.deleting') : t('common.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+12 -3
View File
@@ -12,6 +12,8 @@ import { cn } from "@/lib/utils";
import { GeneralSection } from "./GeneralSection";
import { ProviderManager } from "./ProviderManager";
import { CliSettingsSection } from "./CliSettingsSection";
import { useTranslation } from "@/hooks/useTranslation";
import type { TranslationKey } from "@/i18n";
type Section = "general" | "providers" | "cli";
@@ -39,6 +41,13 @@ function getInitialSection(): Section {
export function SettingsLayout() {
const [activeSection, setActiveSection] = useState<Section>(getInitialSection);
const { t } = useTranslation();
const settingsLabelKeys: Record<string, TranslationKey> = {
'General': 'settings.general',
'Providers': 'settings.providers',
'Claude CLI': 'settings.claudeCli',
};
// Sync hash on mount and on popstate
useEffect(() => {
@@ -60,9 +69,9 @@ export function SettingsLayout() {
return (
<div className="flex h-full flex-col">
<div className="border-b border-border/50 px-6 pt-4 pb-4">
<h1 className="text-xl font-semibold">Settings</h1>
<h1 className="text-xl font-semibold">{t('settings.title')}</h1>
<p className="text-sm text-muted-foreground">
Manage CodePilot and Claude CLI settings
{t('settings.description')}
</p>
</div>
@@ -81,7 +90,7 @@ export function SettingsLayout() {
)}
>
<HugeiconsIcon icon={item.icon} className="h-4 w-4 shrink-0" />
{item.label}
{t(settingsLabelKeys[item.label])}
</button>
))}
</nav>
+15 -13
View File
@@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { HugeiconsIcon } from "@hugeicons/react";
import { Loading02Icon, GlobeIcon, FolderOpenIcon } from "@hugeicons/core-free-icons";
import { useTranslation } from "@/hooks/useTranslation";
import { cn } from "@/lib/utils";
interface CreateSkillDialogProps {
@@ -58,6 +59,7 @@ export function CreateSkillDialog({
onOpenChange,
onCreate,
}: CreateSkillDialogProps) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [scope, setScope] = useState<"global" | "project">("project");
const [templateIdx, setTemplateIdx] = useState(0);
@@ -67,11 +69,11 @@ export function CreateSkillDialog({
const handleCreate = async () => {
const trimmed = name.trim();
if (!trimmed) {
setError("Name is required");
setError(t('skills.nameRequired'));
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
setError("Name can only contain letters, numbers, hyphens, and underscores");
setError(t('skills.nameInvalid'));
return;
}
@@ -95,7 +97,7 @@ export function CreateSkillDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create New Skill</DialogTitle>
<DialogTitle>{t('skills.createSkill')}</DialogTitle>
<DialogDescription>
Create a new slash command skill. It will be saved as a .md file.
</DialogDescription>
@@ -104,7 +106,7 @@ export function CreateSkillDialog({
<div className="space-y-4 py-2">
{/* Name input */}
<div className="space-y-2">
<Label htmlFor="skill-name">Name</Label>
<Label htmlFor="skill-name">{t('skills.skillName')}</Label>
<div className="flex items-center gap-1">
<span className="text-sm text-muted-foreground">/</span>
<Input
@@ -124,7 +126,7 @@ export function CreateSkillDialog({
{/* Scope selection */}
<div className="space-y-2">
<Label>Scope</Label>
<Label>{t('skills.scope')}</Label>
<div className="flex gap-2">
<button
type="button"
@@ -137,7 +139,7 @@ export function CreateSkillDialog({
)}
>
<HugeiconsIcon icon={FolderOpenIcon} className="h-4 w-4" />
Project
{t('skills.project')}
</button>
<button
type="button"
@@ -150,7 +152,7 @@ export function CreateSkillDialog({
)}
>
<HugeiconsIcon icon={GlobeIcon} className="h-4 w-4" />
Global
{t('skills.global')}
</button>
</div>
<p className="text-xs text-muted-foreground">
@@ -162,11 +164,11 @@ export function CreateSkillDialog({
{/* Template selection */}
<div className="space-y-2">
<Label>Template</Label>
<Label>{t('skills.template')}</Label>
<div className="flex gap-2 flex-wrap">
{TEMPLATES.map((t, i) => (
{TEMPLATES.map((tpl, i) => (
<button
key={t.label}
key={tpl.label}
type="button"
onClick={() => setTemplateIdx(i)}
className={cn(
@@ -176,7 +178,7 @@ export function CreateSkillDialog({
: "border-border hover:bg-accent"
)}
>
{t.label}
{tpl.label}
</button>
))}
</div>
@@ -191,11 +193,11 @@ export function CreateSkillDialog({
onClick={() => onOpenChange(false)}
disabled={creating}
>
Cancel
{t('common.cancel')}
</Button>
<Button onClick={handleCreate} disabled={creating} className="gap-2">
{creating && <HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin" />}
Create Skill
{t('skills.createSkill')}
</Button>
</DialogFooter>
</DialogContent>
+8 -6
View File
@@ -23,6 +23,7 @@ import {
import { cn } from "@/lib/utils";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { useTranslation } from "@/hooks/useTranslation";
import type { SkillItem } from "./SkillListItem";
type ViewMode = "edit" | "preview" | "split";
@@ -34,6 +35,7 @@ interface SkillEditorProps {
}
export function SkillEditor({ skill, onSave, onDelete }: SkillEditorProps) {
const { t } = useTranslation();
const [content, setContent] = useState(skill.content);
const [viewMode, setViewMode] = useState<ViewMode>("edit");
const [saving, setSaving] = useState(false);
@@ -153,7 +155,7 @@ export function SkillEditor({ skill, onSave, onDelete }: SkillEditorProps) {
<HugeiconsIcon icon={Edit02Icon} className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
<TooltipContent>{t('skills.edit')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@@ -165,7 +167,7 @@ export function SkillEditor({ skill, onSave, onDelete }: SkillEditorProps) {
<HugeiconsIcon icon={EyeIcon} className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Preview</TooltipContent>
<TooltipContent>{t('skills.preview')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@@ -177,7 +179,7 @@ export function SkillEditor({ skill, onSave, onDelete }: SkillEditorProps) {
<HugeiconsIcon icon={LayoutTwoColumnIcon} className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Split</TooltipContent>
<TooltipContent>{t('skills.splitView')}</TooltipContent>
</Tooltip>
<div className="w-px h-4 bg-border mx-1" />
@@ -194,7 +196,7 @@ export function SkillEditor({ skill, onSave, onDelete }: SkillEditorProps) {
) : (
<HugeiconsIcon icon={FloppyDiskIcon} className="h-3 w-3" />
)}
{saving ? "Saving" : saved ? "Saved" : "Save"}
{saving ? "Saving" : saved ? t('skills.saved') : t('skills.save')}
</Button>
{/* Delete */}
@@ -217,7 +219,7 @@ export function SkillEditor({ skill, onSave, onDelete }: SkillEditorProps) {
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
className="h-full w-full resize-none rounded-none border-0 font-mono text-sm focus-visible:ring-0 focus-visible:ring-offset-0 min-h-[400px]"
placeholder="Write your skill prompt in Markdown..."
placeholder={t('skills.placeholder')}
/>
)}
{viewMode === "preview" && (
@@ -231,7 +233,7 @@ export function SkillEditor({ skill, onSave, onDelete }: SkillEditorProps) {
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
className="h-full w-full resize-none rounded-none border-0 font-mono text-sm focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="Write your skill prompt in Markdown..."
placeholder={t('skills.placeholder')}
/>
</div>
<div className="flex-1 min-w-0 overflow-auto">
+3 -1
View File
@@ -11,6 +11,7 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { useTranslation } from "@/hooks/useTranslation";
export interface SkillItem {
name: string;
@@ -34,6 +35,7 @@ export function SkillListItem({
onSelect,
onDelete,
}: SkillListItemProps) {
const { t } = useTranslation();
const [hovered, setHovered] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
@@ -108,7 +110,7 @@ export function SkillListItem({
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{confirmDelete ? "Click again to confirm" : "Delete"}
{confirmDelete ? t('skills.deleteConfirm') : t('common.delete')}
</TooltipContent>
</Tooltip>
)}
+10 -8
View File
@@ -10,10 +10,12 @@ import { PlusSignIcon, Search01Icon, ZapIcon, Loading02Icon } from "@hugeicons/c
import { SkillListItem } from "./SkillListItem";
import { SkillEditor } from "./SkillEditor";
import { CreateSkillDialog } from "./CreateSkillDialog";
import { useTranslation } from "@/hooks/useTranslation";
import type { SkillItem } from "./SkillListItem";
export function SkillsManager() {
const { workingDirectory } = usePanel();
const { t } = useTranslation();
const [skills, setSkills] = useState<SkillItem[]>([]);
const [selected, setSelected] = useState<SkillItem | null>(null);
const [loading, setLoading] = useState(true);
@@ -138,7 +140,7 @@ export function SkillsManager() {
<div className="flex h-64 items-center justify-center">
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
Loading skills...
{t('skills.loadingSkills')}
</span>
</div>
);
@@ -148,10 +150,10 @@ export function SkillsManager() {
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center gap-3 mb-4">
<h3 className="text-lg font-semibold flex-1">Skills</h3>
<h3 className="text-lg font-semibold flex-1">{t('extensions.skills')}</h3>
<Button size="sm" onClick={() => setShowCreate(true)} className="gap-1">
<HugeiconsIcon icon={PlusSignIcon} className="h-3.5 w-3.5" />
New Skill
{t('skills.newSkill')}
</Button>
</div>
@@ -163,7 +165,7 @@ export function SkillsManager() {
<div className="relative">
<HugeiconsIcon icon={Search01Icon} className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search skills..."
placeholder={t('skills.searchSkills')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-7 h-8 text-sm"
@@ -236,7 +238,7 @@ export function SkillsManager() {
<div className="flex flex-col items-center gap-2 py-8 text-muted-foreground">
<HugeiconsIcon icon={ZapIcon} className="h-8 w-8 opacity-40" />
<p className="text-xs">
{search ? "No skills match your search" : "No skills yet"}
{search ? t('skills.noSkillsFound') : t('skills.noSkillsFound')}
</p>
{!search && (
<Button
@@ -268,9 +270,9 @@ export function SkillsManager() {
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
<HugeiconsIcon icon={ZapIcon} className="h-12 w-12 opacity-30" />
<div className="text-center">
<p className="text-sm font-medium">No skill selected</p>
<p className="text-sm font-medium">{t('skills.noSelected')}</p>
<p className="text-xs">
Select a skill from the list or create a new one
{t('skills.selectOrCreate')}
</p>
</div>
<Button
@@ -280,7 +282,7 @@ export function SkillsManager() {
className="gap-1"
>
<HugeiconsIcon icon={PlusSignIcon} className="h-3.5 w-3.5" />
New Skill
{t('skills.newSkill')}
</Button>
</div>
)}
+8
View File
@@ -0,0 +1,8 @@
'use client';
import { useContext } from 'react';
import { I18nContext } from '@/components/layout/I18nProvider';
export function useTranslation() {
return useContext(I18nContext);
}
+274
View File
@@ -0,0 +1,274 @@
/**
* English translations — source of truth.
* Every key defined here becomes part of the `TranslationKey` type.
* Other locale files must implement `Record<TranslationKey, string>`.
*/
const en = {
// ── Navigation ──────────────────────────────────────────────
'nav.chats': 'Chats',
'nav.extensions': 'Extensions',
'nav.settings': 'Settings',
'nav.autoApproveOn': 'Auto-approve is ON',
'nav.lightMode': 'Light mode',
'nav.darkMode': 'Dark mode',
'nav.toggleTheme': 'Toggle theme',
// ── Chat list panel ─────────────────────────────────────────
'chatList.justNow': 'just now',
'chatList.minutesAgo': '{n}m',
'chatList.hoursAgo': '{n}h',
'chatList.daysAgo': '{n}d',
'chatList.newConversation': 'New Conversation',
'chatList.delete': 'Delete',
'chatList.searchSessions': 'Search sessions...',
'chatList.noSessions': 'No sessions yet',
'chatList.importFromCli': 'Import from Claude CLI',
'chatList.addProjectFolder': 'Add project folder',
// ── Message list ────────────────────────────────────────────
'messageList.claudeChat': 'Claude Chat',
'messageList.emptyDescription': 'Start a conversation with Claude. Ask questions, get help with code, or explore ideas.',
'messageList.loadEarlier': 'Load earlier messages',
'messageList.loading': 'Loading...',
// ── Message input ───────────────────────────────────────────
'messageInput.attachFiles': 'Attach files',
'messageInput.helpDesc': 'Show available commands and tips',
'messageInput.clearDesc': 'Clear conversation history',
'messageInput.costDesc': 'Show token usage statistics',
'messageInput.compactDesc': 'Compress conversation context',
'messageInput.doctorDesc': 'Diagnose project health',
'messageInput.initDesc': 'Initialize CLAUDE.md for project',
'messageInput.reviewDesc': 'Review code quality',
'messageInput.terminalSetupDesc': 'Configure terminal settings',
'messageInput.memoryDesc': 'Edit project memory file',
'messageInput.modeCode': 'Code',
'messageInput.modePlan': 'Plan',
// ── Streaming message ───────────────────────────────────────
'streaming.thinking': 'Thinking...',
'streaming.allowForSession': 'Allow for Session',
'streaming.allowed': 'Allowed',
'streaming.denied': 'Denied',
// ── Chat view / session page ────────────────────────────────
'chat.newConversation': 'New Conversation',
// ── Settings: General ───────────────────────────────────────
'settings.title': 'Settings',
'settings.description': 'Manage CodePilot and Claude CLI settings',
'settings.general': 'General',
'settings.providers': 'Providers',
'settings.claudeCli': 'Claude CLI',
'settings.codepilot': 'CodePilot',
'settings.version': 'Version {version}',
'settings.checkForUpdates': 'Check for Updates',
'settings.checking': 'Checking...',
'settings.updateAvailable': 'Update available: v{version}',
'settings.viewRelease': 'View Release',
'settings.latestVersion': "You're on the latest version.",
'settings.autoApproveTitle': 'Auto-approve All Actions',
'settings.autoApproveDesc': 'Skip all permission checks and auto-approve every tool action. This is dangerous and should only be used for trusted tasks.',
'settings.autoApproveWarning': 'All tool actions will be auto-approved without confirmation. Use with caution.',
'settings.autoApproveDialogTitle': 'Enable Auto-approve All Actions?',
'settings.autoApproveDialogDesc': 'This will bypass all permission checks. Claude will be able to execute any tool action without asking for your confirmation, including:',
'settings.autoApproveShellCommands': 'Running arbitrary shell commands',
'settings.autoApproveFileOps': 'Reading, writing, and deleting files',
'settings.autoApproveNetwork': 'Making network requests',
'settings.autoApproveTrustWarning': 'Only enable this if you fully trust the task at hand. This setting applies to all new chat sessions.',
'settings.cancel': 'Cancel',
'settings.enableAutoApprove': 'Enable Auto-approve',
'settings.language': 'Language',
'settings.languageDesc': 'Choose the display language for the interface',
// ── Settings: CLI ───────────────────────────────────────────
'cli.permissions': 'Permissions',
'cli.permissionsDesc': 'Configure permission settings for Claude CLI',
'cli.envVars': 'Environment Variables',
'cli.envVarsDesc': 'Environment variables passed to Claude',
'cli.form': 'Form',
'cli.json': 'JSON',
'cli.save': 'Save',
'cli.format': 'Format',
'cli.reset': 'Reset',
'cli.settingsSaved': 'Settings saved successfully',
'cli.confirmSaveTitle': 'Confirm Save',
'cli.confirmSaveDesc': 'This will overwrite your current ~/.claude/settings.json file. Are you sure you want to continue?',
// ── Settings: Providers ─────────────────────────────────────
'provider.addProvider': 'Add Provider',
'provider.editProvider': 'Edit Provider',
'provider.deleteProvider': 'Delete Provider',
'provider.deleteConfirm': 'Are you sure you want to delete "{name}"? This action cannot be undone.',
'provider.deleting': 'Deleting...',
'provider.delete': 'Delete',
'provider.noProviders': 'No providers configured',
'provider.addToStart': 'Add a provider to get started',
'provider.quickPresets': 'Quick Presets',
'provider.customProviders': 'Custom Providers',
'provider.active': 'Active',
'provider.configured': 'Configured',
'provider.name': 'Name',
'provider.providerType': 'Provider Type',
'provider.apiKey': 'API Key',
'provider.baseUrl': 'Base URL',
'provider.advancedOptions': 'Advanced Options',
'provider.extraEnvVars': 'Extra Environment Variables',
'provider.notes': 'Notes',
'provider.notesPlaceholder': 'Optional notes about this provider...',
'provider.saving': 'Saving...',
'provider.update': 'Update',
'provider.envDetected': 'Detected from environment',
// ── Right panel / Files ─────────────────────────────────────
'panel.files': 'Files',
'panel.openPanel': 'Open panel',
'panel.closePanel': 'Close panel',
// ── File tree ───────────────────────────────────────────────
'fileTree.filterFiles': 'Filter files...',
'fileTree.refresh': 'Refresh',
'fileTree.noFiles': 'No files found',
'fileTree.selectFolder': 'Select a project folder to view files',
// ── File preview ────────────────────────────────────────────
'filePreview.backToTree': 'Back to file tree',
'filePreview.lines': '{count} lines',
'filePreview.copyPath': 'Copy path',
'filePreview.failedToLoad': 'Failed to load file',
// ── Doc preview ─────────────────────────────────────────────
'docPreview.htmlPreview': 'HTML Preview',
// ── Extensions page ─────────────────────────────────────────
'extensions.title': 'Extensions',
'extensions.skills': 'Skills',
'extensions.mcpServers': 'MCP Servers',
// ── Skills ──────────────────────────────────────────────────
'skills.noSelected': 'No skill selected',
'skills.selectOrCreate': 'Select a skill from the list or create a new one',
'skills.newSkill': 'New Skill',
'skills.loadingSkills': 'Loading skills...',
'skills.noSkillsFound': 'No skills found',
'skills.searchSkills': 'Search skills...',
'skills.createSkill': 'Create Skill',
'skills.nameRequired': 'Name is required',
'skills.nameInvalid': 'Name can only contain letters, numbers, hyphens, and underscores',
'skills.skillName': 'Skill Name',
'skills.scope': 'Scope',
'skills.global': 'Global',
'skills.project': 'Project',
'skills.template': 'Template',
'skills.blank': 'Blank',
'skills.commitHelper': 'Commit Helper',
'skills.codeReviewer': 'Code Reviewer',
'skills.saved': 'Saved',
'skills.save': 'Save',
'skills.edit': 'Edit',
'skills.preview': 'Preview',
'skills.splitView': 'Split view',
'skills.deleteConfirm': 'Click again to confirm',
'skills.placeholder': 'Write your skill prompt in Markdown...',
// ── MCP ─────────────────────────────────────────────────────
'mcp.addServer': 'Add Server',
'mcp.loadingServers': 'Loading MCP servers...',
'mcp.serverConfig': 'MCP Server Configuration',
'mcp.noServers': 'No MCP servers configured',
'mcp.noServersDesc': "Add an MCP server to extend Claude's capabilities",
'mcp.arguments': 'Arguments:',
'mcp.environment': 'Environment:',
'mcp.listTab': 'List',
'mcp.jsonTab': 'JSON Config',
'mcp.editServer': 'Edit Server',
'mcp.serverName': 'Server Name',
'mcp.serverType': 'Server Type',
'mcp.command': 'Command',
'mcp.argsLabel': 'Arguments (one per line)',
'mcp.url': 'URL',
'mcp.headers': 'Headers (JSON)',
'mcp.envVars': 'Environment Variables (JSON)',
'mcp.formTab': 'Form',
'mcp.jsonEditTab': 'JSON',
'mcp.saveChanges': 'Save Changes',
// ── Folder picker ───────────────────────────────────────────
'folderPicker.title': 'Select a project folder',
'folderPicker.loading': 'Loading...',
'folderPicker.noSubdirs': 'No subdirectories',
'folderPicker.cancel': 'Cancel',
'folderPicker.select': 'Select This Folder',
// ── Import session dialog ───────────────────────────────────
'import.title': 'Import Session from Claude CLI',
'import.searchSessions': 'Search sessions...',
'import.noSessions': 'No sessions found',
'import.import': 'Import',
'import.importing': 'Importing...',
'import.justNow': 'just now',
'import.minutesAgo': '{n}m ago',
'import.hoursAgo': '{n}h ago',
'import.daysAgo': '{n}d ago',
'import.messages': '{n} msg',
'import.messagesPlural': '{n} msgs',
// ── Connection status ───────────────────────────────────────
'connection.notInstalled': 'Claude Code is not installed',
'connection.installed': 'Claude Code is installed',
'connection.version': 'Version: {version}',
'connection.installPrompt': 'To use Claude Code features, you need to install the Claude Code CLI.',
'connection.runCommand': 'Run the following command in your terminal:',
'connection.installAuto': 'Install Claude Code Automatically',
'connection.refresh': 'Refresh',
'connection.installClaude': 'Install Claude Code',
// ── Install wizard ──────────────────────────────────────────
'install.title': 'Install Claude Code',
'install.checkingPrereqs': 'Checking prerequisites...',
'install.alreadyInstalled': 'Claude Code is already installed',
'install.readyToInstall': 'Ready to install',
'install.installing': 'Installing Claude Code...',
'install.complete': 'Installation complete',
'install.failed': 'Installation failed',
'install.copyLogs': 'Copy Logs',
'install.copied': 'Copied',
'install.install': 'Install',
'install.cancel': 'Cancel',
'install.retry': 'Retry',
'install.done': 'Done',
// ── Task list ───────────────────────────────────────────────
'tasks.all': 'All',
'tasks.active': 'Active',
'tasks.done': 'Done',
'tasks.addPlaceholder': 'Add a task...',
'tasks.addTask': 'Add task',
'tasks.loading': 'Loading tasks...',
'tasks.noTasks': 'No tasks yet',
'tasks.noMatching': 'No matching tasks',
// ── Tool call block ─────────────────────────────────────────
'tool.running': 'running',
'tool.success': 'success',
'tool.error': 'error',
// ── Common ──────────────────────────────────────────────────
'common.cancel': 'Cancel',
'common.save': 'Save',
'common.delete': 'Delete',
'common.loading': 'Loading...',
'common.close': 'Close',
'common.enabled': 'Enabled',
'common.disabled': 'Disabled',
// ── CLI dynamic field labels ──────────────────────────────
'cli.loadingSettings': 'Loading settings...',
'cli.field.skipDangerousModePermissionPrompt': 'Skip Dangerous Mode Permission Prompt',
'cli.field.verbose': 'Verbose',
'cli.field.theme': 'Theme',
'cli.formatError': 'Cannot format: invalid JSON',
} as const;
export type TranslationKey = keyof typeof en;
export default en;
+37
View File
@@ -0,0 +1,37 @@
import en, { type TranslationKey } from './en';
import zh from './zh';
export type { TranslationKey };
export type Locale = 'en' | 'zh';
export const SUPPORTED_LOCALES: { value: Locale; label: string }[] = [
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文' },
];
const dictionaries: Record<Locale, Record<TranslationKey, string>> = {
en,
zh,
};
/**
* Translate a key with optional parameter interpolation.
* Fallback chain: target locale → English → raw key.
*/
export function translate(
locale: Locale,
key: TranslationKey,
params?: Record<string, string | number>,
): string {
const dict = dictionaries[locale] ?? en;
let text = dict[key] ?? en[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v));
}
}
return text;
}
+270
View File
@@ -0,0 +1,270 @@
import type { TranslationKey } from './en';
const zh: Record<TranslationKey, string> = {
// ── Navigation ──────────────────────────────────────────────
'nav.chats': '对话',
'nav.extensions': '扩展',
'nav.settings': '设置',
'nav.autoApproveOn': '自动批准已开启',
'nav.lightMode': '浅色模式',
'nav.darkMode': '深色模式',
'nav.toggleTheme': '切换主题',
// ── Chat list panel ─────────────────────────────────────────
'chatList.justNow': '刚刚',
'chatList.minutesAgo': '{n}分钟',
'chatList.hoursAgo': '{n}小时',
'chatList.daysAgo': '{n}天',
'chatList.newConversation': '新对话',
'chatList.delete': '删除',
'chatList.searchSessions': '搜索会话...',
'chatList.noSessions': '暂无会话',
'chatList.importFromCli': '从 Claude CLI 导入',
'chatList.addProjectFolder': '添加项目文件夹',
// ── Message list ────────────────────────────────────────────
'messageList.claudeChat': 'Claude 对话',
'messageList.emptyDescription': '开始与 Claude 对话。提问、获取代码帮助或探索想法。',
'messageList.loadEarlier': '加载更早的消息',
'messageList.loading': '加载中...',
// ── Message input ───────────────────────────────────────────
'messageInput.attachFiles': '附加文件',
'messageInput.helpDesc': '显示可用命令和提示',
'messageInput.clearDesc': '清除对话历史',
'messageInput.costDesc': '显示 Token 用量统计',
'messageInput.compactDesc': '压缩对话上下文',
'messageInput.doctorDesc': '诊断项目健康状况',
'messageInput.initDesc': '初始化项目 CLAUDE.md',
'messageInput.reviewDesc': '审查代码质量',
'messageInput.terminalSetupDesc': '配置终端设置',
'messageInput.memoryDesc': '编辑项目记忆文件',
'messageInput.modeCode': '代码',
'messageInput.modePlan': '计划',
// ── Streaming message ───────────────────────────────────────
'streaming.thinking': '思考中...',
'streaming.allowForSession': '本次会话允许',
'streaming.allowed': '已允许',
'streaming.denied': '已拒绝',
// ── Chat view / session page ────────────────────────────────
'chat.newConversation': '新对话',
// ── Settings: General ───────────────────────────────────────
'settings.title': '设置',
'settings.description': '管理 CodePilot 和 Claude CLI 设置',
'settings.general': '通用',
'settings.providers': '服务商',
'settings.claudeCli': 'Claude CLI',
'settings.codepilot': 'CodePilot',
'settings.version': '版本 {version}',
'settings.checkForUpdates': '检查更新',
'settings.checking': '检查中...',
'settings.updateAvailable': '有新版本:v{version}',
'settings.viewRelease': '查看发布',
'settings.latestVersion': '已是最新版本。',
'settings.autoApproveTitle': '自动批准所有操作',
'settings.autoApproveDesc': '跳过所有权限检查并自动批准每个工具操作。这很危险,仅应用于受信任的任务。',
'settings.autoApproveWarning': '所有工具操作将在无确认的情况下自动批准。请谨慎使用。',
'settings.autoApproveDialogTitle': '启用自动批准所有操作?',
'settings.autoApproveDialogDesc': '这将绕过所有权限检查。Claude 将能够在不征求您确认的情况下执行任何工具操作,包括:',
'settings.autoApproveShellCommands': '运行任意 Shell 命令',
'settings.autoApproveFileOps': '读取、写入和删除文件',
'settings.autoApproveNetwork': '发起网络请求',
'settings.autoApproveTrustWarning': '仅在您完全信任当前任务时才启用此选项。此设置适用于所有新的聊天会话。',
'settings.cancel': '取消',
'settings.enableAutoApprove': '启用自动批准',
'settings.language': '语言',
'settings.languageDesc': '选择界面显示语言',
// ── Settings: CLI ───────────────────────────────────────────
'cli.permissions': '权限',
'cli.permissionsDesc': '配置 Claude CLI 的权限设置',
'cli.envVars': '环境变量',
'cli.envVarsDesc': '传递给 Claude 的环境变量',
'cli.form': '表单',
'cli.json': 'JSON',
'cli.save': '保存',
'cli.format': '格式化',
'cli.reset': '重置',
'cli.settingsSaved': '设置已保存',
'cli.confirmSaveTitle': '确认保存',
'cli.confirmSaveDesc': '这将覆盖您当前的 ~/.claude/settings.json 文件。确定要继续吗?',
// ── Settings: Providers ─────────────────────────────────────
'provider.addProvider': '添加服务商',
'provider.editProvider': '编辑服务商',
'provider.deleteProvider': '删除服务商',
'provider.deleteConfirm': '确定要删除"{name}"吗?此操作无法撤销。',
'provider.deleting': '删除中...',
'provider.delete': '删除',
'provider.noProviders': '未配置服务商',
'provider.addToStart': '添加服务商以开始使用',
'provider.quickPresets': '快速预设',
'provider.customProviders': '自定义服务商',
'provider.active': '活跃',
'provider.configured': '已配置',
'provider.name': '名称',
'provider.providerType': '服务商类型',
'provider.apiKey': 'API 密钥',
'provider.baseUrl': '基础 URL',
'provider.advancedOptions': '高级选项',
'provider.extraEnvVars': '额外环境变量',
'provider.notes': '备注',
'provider.notesPlaceholder': '关于此服务商的可选备注...',
'provider.saving': '保存中...',
'provider.update': '更新',
'provider.envDetected': '从环境变量检测到',
// ── Right panel / Files ─────────────────────────────────────
'panel.files': '文件',
'panel.openPanel': '打开面板',
'panel.closePanel': '关闭面板',
// ── File tree ───────────────────────────────────────────────
'fileTree.filterFiles': '筛选文件...',
'fileTree.refresh': '刷新',
'fileTree.noFiles': '未找到文件',
'fileTree.selectFolder': '选择项目文件夹以查看文件',
// ── File preview ────────────────────────────────────────────
'filePreview.backToTree': '返回文件树',
'filePreview.lines': '{count} 行',
'filePreview.copyPath': '复制路径',
'filePreview.failedToLoad': '加载文件失败',
// ── Doc preview ─────────────────────────────────────────────
'docPreview.htmlPreview': 'HTML 预览',
// ── Extensions page ─────────────────────────────────────────
'extensions.title': '扩展',
'extensions.skills': '技能',
'extensions.mcpServers': 'MCP 服务器',
// ── Skills ──────────────────────────────────────────────────
'skills.noSelected': '未选择技能',
'skills.selectOrCreate': '从列表中选择一个技能或创建新技能',
'skills.newSkill': '新建技能',
'skills.loadingSkills': '加载技能中...',
'skills.noSkillsFound': '未找到技能',
'skills.searchSkills': '搜索技能...',
'skills.createSkill': '创建技能',
'skills.nameRequired': '名称为必填项',
'skills.nameInvalid': '名称只能包含字母、数字、连字符和下划线',
'skills.skillName': '技能名称',
'skills.scope': '作用域',
'skills.global': '全局',
'skills.project': '项目',
'skills.template': '模板',
'skills.blank': '空白',
'skills.commitHelper': '提交助手',
'skills.codeReviewer': '代码审查',
'skills.saved': '已保存',
'skills.save': '保存',
'skills.edit': '编辑',
'skills.preview': '预览',
'skills.splitView': '分屏视图',
'skills.deleteConfirm': '再次点击以确认',
'skills.placeholder': '用 Markdown 编写技能提示词...',
// ── MCP ─────────────────────────────────────────────────────
'mcp.addServer': '添加服务器',
'mcp.loadingServers': '加载 MCP 服务器中...',
'mcp.serverConfig': 'MCP 服务器配置',
'mcp.noServers': '未配置 MCP 服务器',
'mcp.noServersDesc': '添加 MCP 服务器以扩展 Claude 的能力',
'mcp.arguments': '参数:',
'mcp.environment': '环境变量:',
'mcp.listTab': '列表',
'mcp.jsonTab': 'JSON 配置',
'mcp.editServer': '编辑服务器',
'mcp.serverName': '服务器名称',
'mcp.serverType': '服务器类型',
'mcp.command': '命令',
'mcp.argsLabel': '参数(每行一个)',
'mcp.url': 'URL',
'mcp.headers': '请求头(JSON)',
'mcp.envVars': '环境变量(JSON)',
'mcp.formTab': '表单',
'mcp.jsonEditTab': 'JSON',
'mcp.saveChanges': '保存更改',
// ── Folder picker ───────────────────────────────────────────
'folderPicker.title': '选择项目文件夹',
'folderPicker.loading': '加载中...',
'folderPicker.noSubdirs': '无子目录',
'folderPicker.cancel': '取消',
'folderPicker.select': '选择此文件夹',
// ── Import session dialog ───────────────────────────────────
'import.title': '从 Claude CLI 导入会话',
'import.searchSessions': '搜索会话...',
'import.noSessions': '未找到会话',
'import.import': '导入',
'import.importing': '导入中...',
'import.justNow': '刚刚',
'import.minutesAgo': '{n}分钟前',
'import.hoursAgo': '{n}小时前',
'import.daysAgo': '{n}天前',
'import.messages': '{n} 条消息',
'import.messagesPlural': '{n} 条消息',
// ── Connection status ───────────────────────────────────────
'connection.notInstalled': 'Claude Code 未安装',
'connection.installed': 'Claude Code 已安装',
'connection.version': '版本:{version}',
'connection.installPrompt': '要使用 Claude Code 功能,您需要安装 Claude Code CLI。',
'connection.runCommand': '在终端中运行以下命令:',
'connection.installAuto': '自动安装 Claude Code',
'connection.refresh': '刷新',
'connection.installClaude': '安装 Claude Code',
// ── Install wizard ──────────────────────────────────────────
'install.title': '安装 Claude Code',
'install.checkingPrereqs': '检查前置条件...',
'install.alreadyInstalled': 'Claude Code 已安装',
'install.readyToInstall': '准备安装',
'install.installing': '正在安装 Claude Code...',
'install.complete': '安装完成',
'install.failed': '安装失败',
'install.copyLogs': '复制日志',
'install.copied': '已复制',
'install.install': '安装',
'install.cancel': '取消',
'install.retry': '重试',
'install.done': '完成',
// ── Task list ───────────────────────────────────────────────
'tasks.all': '全部',
'tasks.active': '进行中',
'tasks.done': '已完成',
'tasks.addPlaceholder': '添加任务...',
'tasks.addTask': '添加任务',
'tasks.loading': '加载任务中...',
'tasks.noTasks': '暂无任务',
'tasks.noMatching': '无匹配任务',
// ── Tool call block ─────────────────────────────────────────
'tool.running': '运行中',
'tool.success': '成功',
'tool.error': '错误',
// ── Common ──────────────────────────────────────────────────
'common.cancel': '取消',
'common.save': '保存',
'common.delete': '删除',
'common.loading': '加载中...',
'common.close': '关闭',
'common.enabled': '已启用',
'common.disabled': '已禁用',
// ── CLI dynamic field labels ──────────────────────────────
'cli.loadingSettings': '加载设置中...',
'cli.field.skipDangerousModePermissionPrompt': '跳过危险模式权限提示',
'cli.field.verbose': '详细日志',
'cli.field.theme': '主题',
'cli.formatError': '无法格式化:无效的 JSON',
};
export default zh;