// ========================================
// Settings Page
// ========================================
// Application settings and configuration with CLI tools management
import { useState, useCallback, useEffect } from 'react';
import { useIntl } from 'react-intl';
import {
Settings,
Moon,
Bell,
Cpu,
RefreshCw,
RotateCcw,
Check,
X,
ChevronDown,
ChevronUp,
Languages,
Plus,
MessageSquareText,
Monitor,
Terminal,
AlertTriangle,
Package,
Home,
Folder,
FolderOpen,
Calendar,
File,
ArrowUpCircle,
Save,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import { ThemeSelector } from '@/components/shared/ThemeSelector';
import { useTheme } from '@/hooks';
import { toast } from 'sonner';
import { useConfigStore, selectCliTools, selectDefaultCliTool, selectUserPreferences } from '@/stores/configStore';
import type { CliToolConfig, UserPreferences } from '@/types/store';
import { cn } from '@/lib/utils';
import { LanguageSwitcher } from '@/components/layout/LanguageSwitcher';
import {
useChineseResponseStatus,
useToggleChineseResponse,
useWindowsPlatformStatus,
useToggleWindowsPlatform,
useCodexCliEnhancementStatus,
useToggleCodexCliEnhancement,
useRefreshCodexCliEnhancement,
useCcwInstallStatus,
useCliToolStatus,
useCcwInstallations,
useUpgradeCcwInstallation,
} from '@/hooks/useSystemSettings';
import { RemoteNotificationSection } from '@/components/settings/RemoteNotificationSection';
import { A2UIPreferencesSection } from '@/components/settings/A2UIPreferencesSection';
// ========== File Path Input with Native File Picker ==========
interface FilePathInputProps {
value: string;
onChange: (value: string) => void;
placeholder: string;
}
function FilePathInput({ value, onChange, placeholder }: FilePathInputProps) {
const handleBrowse = async () => {
const { selectFile } = await import('@/lib/nativeDialog');
const initialDir = value ? value.replace(/[/\\][^/\\]*$/, '') : undefined;
const selected = await selectFile(initialDir);
if (selected) {
onChange(selected);
}
};
return (
onChange(e.target.value)}
placeholder={placeholder}
className="flex-1"
/>
);
}
// ========== Tool Config File Helpers ==========
/** Tools that use .env file for environment variables */
const ENV_FILE_TOOLS = new Set(['gemini', 'qwen', 'opencode']);
/** Tools that use --settings for Claude CLI settings file */
const SETTINGS_FILE_TOOLS = new Set(['claude']);
function getConfigFileType(toolId: string): 'envFile' | 'settingsFile' | 'none' {
if (ENV_FILE_TOOLS.has(toolId)) return 'envFile';
if (SETTINGS_FILE_TOOLS.has(toolId)) return 'settingsFile';
return 'none';
}
// ========== CLI Tool Card Component ==========
interface CliToolCardProps {
toolId: string;
config: CliToolConfig;
isDefault: boolean;
isExpanded: boolean;
toolAvailable?: boolean;
isSaving?: boolean;
onToggleExpand: () => void;
onToggleEnabled: () => void;
onSetDefault: () => void;
onUpdateModel: (field: 'primaryModel' | 'secondaryModel', value: string) => void;
onUpdateTags: (tags: string[]) => void;
onUpdateAvailableModels: (models: string[]) => void;
onUpdateEnvFile: (envFile: string | undefined) => void;
onUpdateSettingsFile: (settingsFile: string | undefined) => void;
onUpdateEffort: (effort: string | undefined) => void;
onSaveToBackend: () => void;
}
function CliToolCard({
toolId,
config,
isDefault,
isExpanded,
toolAvailable,
isSaving,
onToggleExpand,
onToggleEnabled,
onSetDefault,
onUpdateModel,
onUpdateTags,
onUpdateAvailableModels,
onUpdateEnvFile,
onUpdateSettingsFile,
onUpdateEffort,
onSaveToBackend,
}: CliToolCardProps) {
const { formatMessage } = useIntl();
// Local state for tag and model input
const [tagInput, setTagInput] = useState('');
const [modelInput, setModelInput] = useState('');
// Handler for adding tags
const handleAddTag = () => {
const newTag = tagInput.trim();
if (newTag && !config.tags.includes(newTag)) {
onUpdateTags([...config.tags, newTag]);
setTagInput('');
}
};
// Handler for removing tags
const handleRemoveTag = (tagToRemove: string) => {
onUpdateTags(config.tags.filter((t) => t !== tagToRemove));
};
// Handler for adding available models
const handleAddModel = () => {
const newModel = modelInput.trim();
const currentModels = config.availableModels || [];
if (newModel && !currentModels.includes(newModel)) {
onUpdateAvailableModels([...currentModels, newModel]);
setModelInput('');
}
};
// Handler for removing available models
const handleRemoveModel = (modelToRemove: string) => {
const currentModels = config.availableModels || [];
onUpdateAvailableModels(currentModels.filter((m) => m !== modelToRemove));
};
// Predefined tags
const predefinedTags = ['分析', 'Debug', 'implementation', 'refactoring', 'testing'];
const configFileType = getConfigFileType(toolId);
return (
{/* Header */}
{toolId}
{isDefault && (
{formatMessage({ id: 'settings.cliTools.default' })}
)}
{config.type}
{toolAvailable !== undefined && (
)}
{config.primaryModel}
{isExpanded ? (
) : (
)}
{/* Tags */}
{config.tags && config.tags.length > 0 && (
{config.tags.map((tag) => (
{tag}
))}
)}
{/* Expanded Content */}
{isExpanded && (
{/* Tags Section */}
{formatMessage({ id: 'apiSettings.cliSettings.tagsDescription' })}
{/* Predefined Tags */}
{formatMessage({ id: 'apiSettings.cliSettings.predefinedTags' })}:
{predefinedTags.map((predefinedTag) => (
))}
{/* Available Models Section */}
{formatMessage({ id: 'apiSettings.cliSettings.availableModelsHint' })}
{/* Env File - for gemini/qwen/opencode */}
{configFileType === 'envFile' && (
onUpdateEnvFile(v || undefined)}
placeholder={formatMessage({ id: 'settings.cliTools.envFilePlaceholder' })}
/>
{formatMessage({ id: 'settings.cliTools.envFileHint' })}
)}
{/* Settings File - for claude only */}
{configFileType === 'settingsFile' && (
onUpdateSettingsFile(v || undefined)}
placeholder={formatMessage({ id: 'apiSettings.cliSettings.settingsFilePlaceholder' })}
/>
{formatMessage({ id: 'apiSettings.cliSettings.settingsFileHint' })}
)}
{/* Effort Level - for claude only */}
{configFileType === 'settingsFile' && (
{(['low', 'medium', 'high'] as const).map((level) => {
const effectiveEffort = config.effort || 'high';
const labelId = `settings.cliTools.effort${level.charAt(0).toUpperCase() + level.slice(1)}` as const;
return (
);
})}
{formatMessage({ id: 'settings.cliTools.effortHint' })}
)}
{/* Action Buttons */}
{!isDefault && config.enabled && (
)}
)}
);
}
// ========== Response Language Section ==========
function ResponseLanguageSection() {
const { formatMessage } = useIntl();
const { data: chineseStatus, isLoading: chineseLoading } = useChineseResponseStatus();
const { toggle: toggleChinese, isPending: chineseToggling } = useToggleChineseResponse();
const { data: windowsStatus, isLoading: windowsLoading } = useWindowsPlatformStatus();
const { toggle: toggleWindows, isPending: windowsToggling } = useToggleWindowsPlatform();
const { data: cliEnhStatus, isLoading: cliEnhLoading } = useCodexCliEnhancementStatus();
const { toggle: toggleCliEnh, isPending: cliEnhToggling } = useToggleCodexCliEnhancement();
const { refresh: refreshCliEnh, isPending: refreshing } = useRefreshCodexCliEnhancement();
return (
{formatMessage({ id: 'settings.sections.responseLanguage' })}
{/* Chinese Response - Claude */}
{formatMessage({ id: 'settings.responseLanguage.chineseClaude' })}
Claude
{formatMessage({ id: 'settings.responseLanguage.chineseClaudeDesc' })}
{/* Chinese Response - Codex */}
{formatMessage({ id: 'settings.responseLanguage.chineseCodex' })}
Codex
{formatMessage({ id: 'settings.responseLanguage.chineseCodexDesc' })}
{chineseStatus?.codexNeedsMigration && (
{formatMessage({ id: 'settings.responseLanguage.migrationWarning' })}
)}
{/* Windows Platform */}
{formatMessage({ id: 'settings.responseLanguage.windowsPlatform' })}
{formatMessage({ id: 'settings.responseLanguage.windowsPlatformDesc' })}
{/* CLI Enhancement - Codex */}
{formatMessage({ id: 'settings.responseLanguage.cliEnhancement' })}
Codex
{cliEnhStatus?.enabled && (
)}
{formatMessage({ id: 'settings.responseLanguage.cliEnhancementDesc' })}
{cliEnhStatus?.enabled && (
{formatMessage({ id: 'settings.responseLanguage.cliEnhancementHint' })}
)}
);
}
// ========== Version Check Section ==========
interface VersionData {
currentVersion: string;
latestVersion: string;
hasUpdate: boolean;
packageName: string;
updateCommand: string;
checkedAt: string;
}
function VersionCheckSection() {
const { formatMessage } = useIntl();
const [versionData, setVersionData] = useState(null);
const [checking, setChecking] = useState(false);
const [error, setError] = useState(null);
const [lastChecked, setLastChecked] = useState(null);
const [autoCheck, setAutoCheck] = useState(() => {
try {
const saved = localStorage.getItem('ccw.autoUpdate');
return saved === null ? true : JSON.parse(saved);
} catch {
return true;
}
});
const checkVersion = async (silent = false) => {
if (!silent) setChecking(true);
setError(null);
try {
const response = await fetch('/api/version-check');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data: VersionData = await response.json();
if (!data.currentVersion) throw new Error('Invalid response');
setVersionData(data);
setLastChecked(new Date());
} catch (err) {
if (!silent) setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setChecking(false);
}
};
useEffect(() => {
// Initial check
checkVersion(true);
if (!autoCheck) return;
const interval = setInterval(() => checkVersion(true), 60 * 60 * 1000);
return () => clearInterval(interval);
}, [autoCheck]);
const toggleAutoCheck = (enabled: boolean) => {
setAutoCheck(enabled);
localStorage.setItem('ccw.autoUpdate', JSON.stringify(enabled));
};
return (
{formatMessage({ id: 'settings.versionCheck.title' })}
{/* Version info */}
{formatMessage({ id: 'settings.versionCheck.currentVersion' })}
{versionData?.currentVersion ?? '...'}
{formatMessage({ id: 'settings.versionCheck.latestVersion' })}
{versionData?.latestVersion ?? '...'}
{/* Status */}
{versionData && (
{versionData.hasUpdate
? formatMessage({ id: 'settings.versionCheck.updateAvailable' })
: formatMessage({ id: 'settings.versionCheck.upToDate' })}
)}
{error && (
{formatMessage({ id: 'settings.versionCheck.checkFailed' })}: {error}
)}
{/* Update action */}
{versionData?.hasUpdate && (
)}
{/* Auto check toggle + last checked */}
);
}
// ========== System Status Section ==========
function SystemStatusSection() {
const { formatMessage } = useIntl();
const { installations, isLoading, refetch } = useCcwInstallations();
const { upgrade, isPending: upgrading } = useUpgradeCcwInstallation();
const { data: ccwInstall } = useCcwInstallStatus();
return (
{/* Header */}
{formatMessage({ id: 'settings.systemStatus.title' })}
{!isLoading && (
{installations.length} {formatMessage({ id: 'settings.systemStatus.installations' })}
)}
{/* Installation cards */}
{isLoading ? (
{formatMessage({ id: 'settings.systemStatus.checking' })}
) : installations.length === 0 ? (
{formatMessage({ id: 'settings.systemStatus.noInstallations' })}
ccw install
) : (
{installations.map((inst) => {
const isGlobal = inst.installation_mode === 'Global';
const installDate = new Date(inst.installation_date).toLocaleDateString();
const version = inst.application_version !== 'unknown' ? inst.application_version : inst.installer_version;
return (
{/* Mode + Version + Upgrade */}
{isGlobal ? : }
{isGlobal
? formatMessage({ id: 'settings.systemStatus.global' })
: formatMessage({ id: 'settings.systemStatus.path' })}
v{version}
{/* Path */}
{inst.installation_path}
{/* Date + Files */}
{installDate}
{inst.files_count} {formatMessage({ id: 'settings.systemStatus.files' })}
);
})}
{/* Missing files warning */}
{ccwInstall && !ccwInstall.installed && ccwInstall.missingFiles.length > 0 && (
{formatMessage({ id: 'settings.systemStatus.incomplete' })} — {ccwInstall.missingFiles.length} {formatMessage({ id: 'settings.systemStatus.missingFiles' }).toLowerCase()}
{ccwInstall.missingFiles.slice(0, 4).map((f) => (
- {f}
))}
{ccwInstall.missingFiles.length > 4 && (
- +{ccwInstall.missingFiles.length - 4} more...
)}
{formatMessage({ id: 'settings.systemStatus.runToFix' })}:
ccw install
)}
)}
);
}
// ========== CLI Tools with Status Enhancement ==========
interface CliToolsWithStatusProps {
cliTools: Record;
defaultCliTool: string;
expandedTools: Set;
savingTools: Set;
onToggleExpand: (toolId: string) => void;
onToggleEnabled: (toolId: string) => void;
onSetDefault: (toolId: string) => void;
onUpdateModel: (toolId: string, field: 'primaryModel' | 'secondaryModel', value: string) => void;
onUpdateTags: (toolId: string, tags: string[]) => void;
onUpdateAvailableModels: (toolId: string, models: string[]) => void;
onUpdateEnvFile: (toolId: string, envFile: string | undefined) => void;
onUpdateSettingsFile: (toolId: string, settingsFile: string | undefined) => void;
onUpdateEffort: (toolId: string, effort: string | undefined) => void;
onSaveToBackend: (toolId: string) => void;
formatMessage: ReturnType['formatMessage'];
}
function CliToolsWithStatus({
cliTools,
defaultCliTool,
expandedTools,
savingTools,
onToggleExpand,
onToggleEnabled,
onSetDefault,
onUpdateModel,
onUpdateTags,
onUpdateAvailableModels,
onUpdateEnvFile,
onUpdateSettingsFile,
onUpdateEffort,
onSaveToBackend,
formatMessage,
}: CliToolsWithStatusProps) {
const { data: toolStatus } = useCliToolStatus();
return (
<>
{formatMessage({ id: 'settings.cliTools.description' })} {defaultCliTool}
{Object.entries(cliTools).map(([toolId, config]) => {
const status = toolStatus?.[toolId];
return (
onToggleExpand(toolId)}
onToggleEnabled={() => onToggleEnabled(toolId)}
onSetDefault={() => onSetDefault(toolId)}
onUpdateModel={(field, value) => onUpdateModel(toolId, field, value)}
onUpdateTags={(tags) => onUpdateTags(toolId, tags)}
onUpdateAvailableModels={(models) => onUpdateAvailableModels(toolId, models)}
onUpdateEnvFile={(envFile) => onUpdateEnvFile(toolId, envFile)}
onUpdateSettingsFile={(settingsFile) => onUpdateSettingsFile(toolId, settingsFile)}
onUpdateEffort={(effort) => onUpdateEffort(toolId, effort)}
onSaveToBackend={() => onSaveToBackend(toolId)}
/>
);
})}
>
);
}
// ========== Main Page Component ==========
export function SettingsPage() {
const { formatMessage } = useIntl();
const { theme, setTheme } = useTheme();
const cliTools = useConfigStore(selectCliTools);
const defaultCliTool = useConfigStore(selectDefaultCliTool);
const userPreferences = useConfigStore(selectUserPreferences);
const { updateCliTool, setDefaultCliTool, setUserPreferences, resetUserPreferences } = useConfigStore();
const [expandedTools, setExpandedTools] = useState>(new Set());
const [savingTools, setSavingTools] = useState>(new Set());
const toggleToolExpand = (toolId: string) => {
setExpandedTools((prev) => {
const next = new Set(prev);
if (next.has(toolId)) {
next.delete(toolId);
} else {
next.add(toolId);
}
return next;
});
};
const handleToggleToolEnabled = (toolId: string) => {
updateCliTool(toolId, { enabled: !cliTools[toolId].enabled });
};
const handleSetDefaultTool = (toolId: string) => {
setDefaultCliTool(toolId);
};
const handleUpdateModel = (toolId: string, field: 'primaryModel' | 'secondaryModel', value: string) => {
updateCliTool(toolId, { [field]: value });
};
const handleUpdateTags = (toolId: string, tags: string[]) => {
updateCliTool(toolId, { tags });
};
const handleUpdateAvailableModels = (toolId: string, availableModels: string[]) => {
updateCliTool(toolId, { availableModels });
};
const handleUpdateEnvFile = (toolId: string, envFile: string | undefined) => {
updateCliTool(toolId, { envFile });
};
const handleUpdateSettingsFile = (toolId: string, settingsFile: string | undefined) => {
updateCliTool(toolId, { settingsFile });
};
const handleUpdateEffort = (toolId: string, effort: string | undefined) => {
updateCliTool(toolId, { effort });
};
// Save tool config to backend (~/.claude/cli-tools.json)
const handleSaveToBackend = useCallback(async (toolId: string) => {
const config = cliTools[toolId];
if (!config) return;
setSavingTools((prev) => new Set(prev).add(toolId));
try {
const body: Record = {
enabled: config.enabled,
primaryModel: config.primaryModel,
secondaryModel: config.secondaryModel,
tags: config.tags,
availableModels: config.availableModels,
};
// Only include the relevant config file field
const configFileType = getConfigFileType(toolId);
if (configFileType === 'envFile') {
body.envFile = config.envFile || null;
} else if (configFileType === 'settingsFile') {
body.settingsFile = config.settingsFile || null;
body.effort = config.effort || null;
}
const res = await fetch(`/api/cli/config/${toolId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
toast.success(formatMessage({ id: 'settings.cliTools.configSaved' }), {
description: toolId,
});
} catch {
toast.error(formatMessage({ id: 'settings.cliTools.configSaveError' }), {
description: toolId,
});
} finally {
setSavingTools((prev) => {
const next = new Set(prev);
next.delete(toolId);
return next;
});
}
}, [cliTools, formatMessage]);
const handlePreferenceChange = (key: keyof UserPreferences, value: unknown) => {
setUserPreferences({ [key]: value });
};
return (
{/* Page Header */}
{formatMessage({ id: 'settings.title' })}
{formatMessage({ id: 'settings.description' })}
{/* Appearance Settings */}
{formatMessage({ id: 'settings.sections.appearance' })}
{/* Multi-Theme Selector */}
{formatMessage({ id: 'settings.appearance.theme' })}
{formatMessage({ id: 'settings.appearance.description' })}
{/* System Theme Toggle (Backward Compatibility) */}
{formatMessage({ id: 'settings.appearance.systemFollow' })}
{formatMessage({ id: 'settings.appearance.systemFollowDesc' })}
{/* Language Settings */}
{formatMessage({ id: 'settings.sections.language' })}
{formatMessage({ id: 'settings.language.displayLanguage' })}
{formatMessage({ id: 'settings.language.chooseLanguage' })}
{/* Response Language Settings */}
{/* A2UI Preferences */}
{/* System Status */}
{/* Version Check */}
{/* CLI Tools Configuration */}
{formatMessage({ id: 'settings.sections.cliTools' })}
{/* Data Refresh Settings */}
{formatMessage({ id: 'settings.dataRefresh.title' })}
{formatMessage({ id: 'settings.dataRefresh.autoRefresh' })}
{formatMessage({ id: 'settings.dataRefresh.autoRefreshDesc' })}
{userPreferences.autoRefresh && (
{formatMessage({ id: 'settings.dataRefresh.refreshInterval' })}
{formatMessage({ id: 'settings.dataRefresh.refreshIntervalDesc' })}
{[15000, 30000, 60000, 120000].map((interval) => (
))}
)}
{/* Notifications */}
{formatMessage({ id: 'settings.notifications.title' })}
{formatMessage({ id: 'settings.notifications.enableNotifications' })}
{formatMessage({ id: 'settings.notifications.enableNotificationsDesc' })}
{formatMessage({ id: 'settings.notifications.soundEffects' })}
{formatMessage({ id: 'settings.notifications.soundEffectsDesc' })}
{/* Remote Notifications */}
{/* Reset Settings */}
{formatMessage({ id: 'common.actions.reset' })}
{formatMessage({ id: 'settings.reset.description' })}
);
}
export default SettingsPage;