cc-haha/desktop/src/pages/Settings.tsx
2026-05-16 09:19:01 +08:00

3059 lines
135 KiB
TypeScript

import { useState, useEffect, useMemo, useRef, type ReactNode } from 'react'
import QRCode from 'qrcode'
import { Copy, Eye, EyeOff, PowerOff, QrCode, RotateCw } from 'lucide-react'
import { useSettingsStore, UI_ZOOM_MIN, UI_ZOOM_MAX, UI_ZOOM_STEP } from '../stores/settingsStore'
import { useProviderStore } from '../stores/providerStore'
import { useTranslation } from '../i18n'
import { Modal } from '../components/shared/Modal'
import { ConfirmDialog } from '../components/shared/ConfirmDialog'
import { Input } from '../components/shared/Input'
import { Button } from '../components/shared/Button'
import { Dropdown } from '../components/shared/Dropdown'
import type { PermissionMode, EffortLevel, ThemeMode, WebSearchMode, AppMode } from '../types/settings'
import type { Locale } from '../i18n'
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat, ProviderAuthStrategy } from '../types/provider'
import type { ProviderPreset } from '../types/providerPreset'
import { AdapterSettings } from './AdapterSettings'
import { useAgentStore } from '../stores/agentStore'
import { useSessionStore } from '../stores/sessionStore'
import type { AgentDefinition, AgentSource } from '../api/agents'
import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer'
import { useSkillStore } from '../stores/skillStore'
import { SkillList } from '../components/skills/SkillList'
import { SkillDetail } from '../components/skills/SkillDetail'
import { usePluginStore } from '../stores/pluginStore'
import { PluginList } from '../components/plugins/PluginList'
import { PluginDetail } from '../components/plugins/PluginDetail'
import { ComputerUseSettings } from './ComputerUseSettings'
import { McpSettings } from './McpSettings'
import { TerminalSettings } from './TerminalSettings'
import { DiagnosticsSettings } from './DiagnosticsSettings'
import { ActivitySettings } from './ActivitySettings'
import { useUIStore, type SettingsTab } from '../stores/uiStore'
import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
import { useUpdateStore } from '../stores/updateStore'
import { formatBytes } from '../lib/formatBytes'
import { isTauriRuntime } from '../lib/desktopRuntime'
import {
getDesktopNotificationPermission,
notifyDesktop,
openDesktopNotificationSettings,
requestDesktopNotificationPermission,
type DesktopNotificationPermission,
} from '../lib/desktopNotifications'
import {
API_KEY_JSON_PLACEHOLDER,
maskSettingsJsonSecrets,
restoreSettingsJsonSecrets,
stripProviderSettingsJsonEnv,
} from '../lib/providerSettingsJson'
import { copyTextToClipboard } from '../components/chat/clipboard'
function buildH5LaunchUrl(baseUrl: string | null, token: string | null): string | null {
if (!baseUrl) return null
try {
const url = new URL(baseUrl)
if (token) {
url.searchParams.set('serverUrl', baseUrl)
url.searchParams.set('h5Token', token)
}
return url.toString().replace(/\/$/, '')
} catch {
return token
? `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}serverUrl=${encodeURIComponent(baseUrl)}&h5Token=${encodeURIComponent(token)}`
: baseUrl
}
}
export function Settings() {
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
const pendingSettingsTab = useUIStore((s) => s.pendingSettingsTab)
const t = useTranslation()
useEffect(() => {
if (!pendingSettingsTab) return
setActiveTab(pendingSettingsTab)
useUIStore.getState().setPendingSettingsTab(null)
}, [pendingSettingsTab])
return (
<div className="flex-1 flex flex-col overflow-hidden bg-[var(--color-surface)]">
<div className="flex-1 flex overflow-hidden">
{/* Tab navigation */}
<div className="w-[180px] border-r border-[var(--color-border)] py-3 flex-shrink-0 flex flex-col">
<div className="flex-1">
<TabButton icon="dns" label={t('settings.tab.providers')} active={activeTab === 'providers'} onClick={() => setActiveTab('providers')} />
<TabButton icon="shield" label={t('settings.tab.permissions')} active={activeTab === 'permissions'} onClick={() => setActiveTab('permissions')} />
<TabButton icon="tune" label={t('settings.tab.general')} active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
<TabButton icon="qr_code_2" label={t('settings.tab.h5Access')} active={activeTab === 'h5Access'} onClick={() => setActiveTab('h5Access')} />
<TabButton icon="chat" label={t('settings.tab.adapters')} active={activeTab === 'adapters'} onClick={() => setActiveTab('adapters')} />
<TabButton icon="terminal" label={t('settings.tab.terminal')} active={activeTab === 'terminal'} onClick={() => setActiveTab('terminal')} />
<TabButton icon="dns" label={t('settings.tab.mcp')} active={activeTab === 'mcp'} onClick={() => setActiveTab('mcp')} />
<TabButton icon="smart_toy" label={t('settings.tab.agents')} active={activeTab === 'agents'} onClick={() => setActiveTab('agents')} />
<TabButton icon="auto_awesome" label={t('settings.tab.skills')} active={activeTab === 'skills'} onClick={() => setActiveTab('skills')} />
<TabButton icon="extension" label={t('settings.tab.plugins')} active={activeTab === 'plugins'} onClick={() => setActiveTab('plugins')} />
<TabButton icon="mouse" label={t('settings.tab.computerUse')} active={activeTab === 'computerUse'} onClick={() => setActiveTab('computerUse')} />
<TabButton icon="monitoring" label={t('settings.tab.activity')} active={activeTab === 'activity'} onClick={() => setActiveTab('activity')} />
<TabButton icon="monitor_heart" label={t('settings.tab.diagnostics')} active={activeTab === 'diagnostics'} onClick={() => setActiveTab('diagnostics')} />
</div>
<div className="border-t border-[var(--color-border)]/40 pt-1">
<TabButton icon="info" label={t('settings.tab.about')} active={activeTab === 'about'} onClick={() => setActiveTab('about')} />
</div>
</div>
{/* Tab content */}
<div className="flex-1 overflow-y-auto px-8 py-6">
{activeTab === 'providers' && <ProviderSettings />}
{activeTab === 'permissions' && <PermissionSettings />}
{activeTab === 'activity' && <ActivitySettings />}
{activeTab === 'general' && <GeneralSettings />}
{activeTab === 'h5Access' && <H5AccessSettings />}
{activeTab === 'adapters' && <AdapterSettings />}
{activeTab === 'terminal' && <TerminalSettings />}
{activeTab === 'mcp' && <McpSettings />}
{activeTab === 'agents' && <AgentsSettings />}
{activeTab === 'skills' && <SkillSettings />}
{activeTab === 'plugins' && <PluginSettings />}
{activeTab === 'computerUse' && <ComputerUseSettings />}
{activeTab === 'diagnostics' && <DiagnosticsSettings />}
{activeTab === 'about' && <AboutSettings />}
</div>
</div>
</div>
)
}
function TabButton({ icon, label, active, onClick }: { icon: string; label: string; active: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
className={`w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-left transition-colors ${
active
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] font-medium'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className="material-symbols-outlined text-[18px]">{icon}</span>
{label}
</button>
)
}
// ─── Provider Settings ──────────────────────────────────────
function ProviderSettings() {
const {
providers,
activeId,
hasLoadedProviders,
presets,
isLoading,
isPresetsLoading,
fetchProviders,
fetchPresets,
deleteProvider,
activateProvider,
activateOfficial,
testProvider,
} = useProviderStore()
const fetchSettings = useSettingsStore((s) => s.fetchAll)
const t = useTranslation()
const [editingProvider, setEditingProvider] = useState<SavedProvider | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
const [pendingDeleteProvider, setPendingDeleteProvider] = useState<SavedProvider | null>(null)
const [isDeletingProvider, setIsDeletingProvider] = useState(false)
const [testResults, setTestResults] = useState<Record<string, { loading: boolean; result?: ProviderTestResult }>>({})
useEffect(() => {
void fetchProviders()
void fetchPresets()
}, [fetchPresets, fetchProviders])
const presetMap = useMemo(
() => new Map(presets.map((preset) => [preset.id, preset])),
[presets],
)
const handleDelete = async (provider: SavedProvider) => {
if (activeId === provider.id) return
setPendingDeleteProvider(provider)
}
const confirmDelete = async () => {
if (!pendingDeleteProvider) return
setIsDeletingProvider(true)
try {
await deleteProvider(pendingDeleteProvider.id)
setPendingDeleteProvider(null)
} catch (error) {
console.error(error)
} finally {
setIsDeletingProvider(false)
}
}
const handleTest = async (provider: SavedProvider) => {
setTestResults((r) => ({ ...r, [provider.id]: { loading: true } }))
try {
const result = await testProvider(provider.id)
setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result } }))
} catch {
setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result: { connectivity: { success: false, latencyMs: 0, error: t('settings.providers.requestFailed') } } } }))
}
}
const handleActivate = async (id: string) => {
await activateProvider(id)
await fetchSettings()
}
const handleActivateOfficial = async () => {
await activateOfficial()
await fetchSettings()
}
const isOfficialActive = hasLoadedProviders && activeId === null
return (
<div className="max-w-2xl">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">{t('settings.providers.title')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mt-0.5">{t('settings.providers.description')}</p>
</div>
<Button size="sm" onClick={() => setShowCreateModal(true)} disabled={isPresetsLoading || presets.length === 0}>
<span className="material-symbols-outlined text-[16px]">add</span>
{t('settings.providers.addProvider')}
</Button>
</div>
{/* Official provider — always visible at top */}
<div
className={`relative flex flex-col rounded-xl border transition-all mb-2 ${
isOfficialActive
? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] cursor-pointer'
}`}
>
<div
className="flex items-center gap-4 px-4 py-3.5"
onClick={() => !isOfficialActive && handleActivateOfficial()}
>
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isOfficialActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.providers.officialName')}</span>
{isOfficialActive && (
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/14 text-[var(--color-brand)] leading-none">{t('settings.providers.default')}</span>
)}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{t('settings.providers.officialDesc')}</div>
</div>
</div>
{isOfficialActive && (
<div className="px-4 pb-4 pt-3 border-t border-[var(--color-border-separator)]">
<ClaudeOfficialLogin />
</div>
)}
</div>
{/* Saved providers */}
{isLoading && providers.length === 0 ? (
<div className="flex justify-center py-8">
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
</div>
) : (
<div className="flex flex-col gap-2">
{providers.map((provider) => {
const isActive = activeId === provider.id
const test = testResults[provider.id]
const preset = presetMap.get(provider.presetId)
return (
<div
key={provider.id}
className={`relative flex items-center gap-4 px-4 py-3.5 rounded-xl border transition-all group ${
isActive
? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)]'
}`}
>
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)] truncate">{provider.name}</span>
{preset && preset.id !== 'custom' && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)] leading-none">{preset.name}</span>
)}
{provider.apiFormat && provider.apiFormat !== 'anthropic' && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[var(--color-surface-container-high)] text-[var(--color-warning)] leading-none">
{provider.apiFormat === 'openai_chat' ? 'OpenAI Chat' : 'OpenAI Responses'}
</span>
)}
{isActive && (
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/14 text-[var(--color-brand)] leading-none">{t('settings.providers.default')}</span>
)}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">
{provider.baseUrl} &middot; {provider.models.main}
</div>
{test && !test.loading && test.result && (
<div className="text-xs mt-1 flex flex-col gap-0.5">
<span className={test.result.connectivity.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}>
{test.result.connectivity.success
? t('settings.providers.connectivityOk', { latency: String(test.result.connectivity.latencyMs) })
: t('settings.providers.connectivityFailed', { error: test.result.connectivity.error || '' })}
</span>
{test.result.proxy && (
<span className={test.result.proxy.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}>
{test.result.proxy.success
? t('settings.providers.proxyOk', { latency: String(test.result.proxy.latencyMs) })
: t('settings.providers.proxyFailed', { error: test.result.proxy.error || '' })}
</span>
)}
</div>
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
{!isActive && (
<Button variant="ghost" size="sm" onClick={() => handleActivate(provider.id)}>{t('settings.providers.setDefault')}</Button>
)}
<Button variant="ghost" size="sm" onClick={() => handleTest(provider)} loading={test?.loading}>{t('settings.providers.test')}</Button>
<Button variant="ghost" size="sm" onClick={() => setEditingProvider(provider)}>{t('settings.providers.edit')}</Button>
{!isActive && (
<Button variant="ghost" size="sm" onClick={() => handleDelete(provider)} className="text-[var(--color-error)] hover:text-[var(--color-error)]">{t('common.delete')}</Button>
)}
</div>
</div>
)
})}
</div>
)}
{/* Create Modal — conditionally rendered so state resets on close */}
{showCreateModal && (
<ProviderFormModal open={true} onClose={() => setShowCreateModal(false)} mode="create" presets={presets} />
)}
{/* Edit Modal */}
{editingProvider && (
<ProviderFormModal key={editingProvider.id} open={true} onClose={() => setEditingProvider(null)} mode="edit" provider={editingProvider} presets={presets} />
)}
<ConfirmDialog
open={pendingDeleteProvider !== null}
onClose={() => {
if (isDeletingProvider) return
setPendingDeleteProvider(null)
}}
onConfirm={confirmDelete}
title={t('common.delete')}
body={pendingDeleteProvider ? t('settings.providers.confirmDelete', { name: pendingDeleteProvider.name }) : ''}
confirmLabel={t('common.delete')}
cancelLabel={t('common.cancel')}
confirmVariant="danger"
loading={isDeletingProvider}
/>
</div>
)
}
// ─── Provider Form Modal ──────────────────────────────────────
type ProviderFormProps = {
open: boolean
onClose: () => void
mode: 'create' | 'edit'
provider?: SavedProvider
presets: ProviderPreset[]
}
function requirePreset(preset: ProviderPreset | undefined): ProviderPreset {
if (!preset) {
throw new Error('Provider presets are not configured')
}
return preset
}
const AUTO_COMPACT_WINDOW_ENV_KEY = 'CLAUDE_CODE_AUTO_COMPACT_WINDOW'
const MODEL_CONTEXT_WINDOWS_ENV_KEY = 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS'
const MODEL_CONTEXT_WINDOW_MIN = 16000
const MODEL_CONTEXT_WINDOW_MAX = 10000000
const MODEL_SLOTS = ['main', 'haiku', 'sonnet', 'opus'] as const
const DEFAULT_PROVIDER_AUTH_STRATEGY: ProviderAuthStrategy = 'auth_token'
const AUTH_ENV_KEYS = new Set(['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'])
type ModelSlot = typeof MODEL_SLOTS[number]
type ModelContextInputs = Record<ModelSlot, string>
function formatContextWindow(value: number): string {
return value.toLocaleString('en-US')
}
function getPresetAutoCompactWindow(preset: ProviderPreset): string {
return preset.defaultEnv?.[AUTO_COMPACT_WINDOW_ENV_KEY] ?? ''
}
function getPresetAuthStrategy(preset: ProviderPreset): ProviderAuthStrategy {
return preset.authStrategy ?? DEFAULT_PROVIDER_AUTH_STRATEGY
}
function omitAuthEnv(env: Record<string, string> | undefined): Record<string, string> {
if (!env) return {}
return Object.fromEntries(
Object.entries(env).filter(([key]) => !AUTH_ENV_KEYS.has(key.toUpperCase())),
)
}
function getProviderAuthValue(apiKey: string, preset: ProviderPreset): string {
return apiKey || preset.defaultEnv?.ANTHROPIC_AUTH_TOKEN || preset.defaultEnv?.ANTHROPIC_API_KEY || (preset.needsApiKey ? '(your API key)' : '')
}
function buildSettingsJsonAuthEnv(
apiFormat: ApiFormat,
authStrategy: ProviderAuthStrategy,
apiKey: string,
preset: ProviderPreset,
): Record<string, string> {
if (apiFormat !== 'anthropic') {
return { ANTHROPIC_API_KEY: 'proxy-managed' }
}
const value = getProviderAuthValue(apiKey, preset)
switch (authStrategy) {
case 'api_key':
return value ? { ANTHROPIC_API_KEY: value } : {}
case 'auth_token':
return value ? { ANTHROPIC_AUTH_TOKEN: value } : {}
case 'auth_token_empty_api_key':
return {
ANTHROPIC_API_KEY: '',
...(value ? { ANTHROPIC_AUTH_TOKEN: value } : {}),
}
case 'dual_same_token':
return value ? { ANTHROPIC_API_KEY: value, ANTHROPIC_AUTH_TOKEN: value } : {}
case 'dual_dummy':
return { ANTHROPIC_API_KEY: 'dummy', ANTHROPIC_AUTH_TOKEN: 'dummy' }
}
}
function inferAuthStrategyFromEnv(env: Record<string, string>): ProviderAuthStrategy | null {
if (env.ANTHROPIC_API_KEY === 'dummy' && env.ANTHROPIC_AUTH_TOKEN === 'dummy') return 'dual_dummy'
if (env.ANTHROPIC_API_KEY === '' && env.ANTHROPIC_AUTH_TOKEN) return 'auth_token_empty_api_key'
if (env.ANTHROPIC_API_KEY && env.ANTHROPIC_AUTH_TOKEN && env.ANTHROPIC_API_KEY === env.ANTHROPIC_AUTH_TOKEN) return 'dual_same_token'
if (env.ANTHROPIC_AUTH_TOKEN) return 'auth_token'
if (env.ANTHROPIC_API_KEY) return 'api_key'
return null
}
function parseAutoCompactWindowInput(value: string): number | undefined {
const trimmed = value.trim()
if (!trimmed) return undefined
const parsed = Number(trimmed)
if (!Number.isInteger(parsed)) return undefined
if (parsed < MODEL_CONTEXT_WINDOW_MIN || parsed > MODEL_CONTEXT_WINDOW_MAX) return undefined
return parsed
}
function getAutoCompactWindowErrorKey(value: string): 'number' | 'range' | null {
const trimmed = value.trim()
if (!trimmed) return null
const parsed = Number(trimmed)
if (!Number.isInteger(parsed)) return 'number'
if (parsed < MODEL_CONTEXT_WINDOW_MIN || parsed > MODEL_CONTEXT_WINDOW_MAX) return 'range'
return null
}
function parseModelContextWindowsInput(value: string): number | undefined {
return parseAutoCompactWindowInput(value)
}
function getModelContextWindowErrorKey(value: string): 'number' | 'range' | null {
return getAutoCompactWindowErrorKey(value)
}
function getModelContextInputValue(
model: string | undefined,
preset: ProviderPreset,
provider?: SavedProvider,
): string {
const trimmedModel = model?.trim()
if (!trimmedModel) return ''
const value = provider?.modelContextWindows?.[trimmedModel] ?? preset.modelContextWindows?.[trimmedModel]
return value !== undefined ? String(value) : ''
}
function getModelContextInputs(
models: ModelMapping,
preset: ProviderPreset,
provider?: SavedProvider,
): ModelContextInputs {
const inputs = {} as ModelContextInputs
for (const slot of MODEL_SLOTS) {
inputs[slot] = getModelContextInputValue(models[slot], preset, provider)
}
return inputs
}
function buildModelContextWindows(
models: ModelMapping,
inputs: ModelContextInputs,
): Record<string, number> {
const windows: Record<string, number> = {}
for (const slot of MODEL_SLOTS) {
const model = models[slot]?.trim()
const parsed = parseModelContextWindowsInput(inputs[slot])
if (model && parsed !== undefined) {
windows[model] = parsed
}
}
return windows
}
function updateSettingsJsonAutoCompactWindow(raw: string, value: string): string {
try {
const parsed = JSON.parse(raw || '{}') as { env?: Record<string, unknown> }
const existingEnv = parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env)
? parsed.env
: {}
const env = { ...existingEnv }
const trimmed = value.trim()
if (trimmed) {
env[AUTO_COMPACT_WINDOW_ENV_KEY] = trimmed
} else {
delete env[AUTO_COMPACT_WINDOW_ENV_KEY]
}
parsed.env = env
return JSON.stringify(parsed, null, 2)
} catch {
return raw
}
}
function updateSettingsJsonModelContextWindows(
raw: string,
modelContextWindows: Record<string, number>,
): string {
try {
const parsed = JSON.parse(raw || '{}') as { env?: Record<string, unknown> }
const existingEnv = parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env)
? parsed.env
: {}
const env = { ...existingEnv }
if (Object.keys(modelContextWindows).length > 0) {
env[MODEL_CONTEXT_WINDOWS_ENV_KEY] = JSON.stringify(modelContextWindows)
} else {
delete env[MODEL_CONTEXT_WINDOWS_ENV_KEY]
}
parsed.env = env
return JSON.stringify(parsed, null, 2)
} catch {
return raw
}
}
function updateSettingsJsonModels(raw: string, models: ModelMapping): string {
try {
const parsed = JSON.parse(raw || '{}') as { env?: Record<string, unknown> }
const existingEnv = parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env)
? parsed.env
: {}
parsed.env = {
...existingEnv,
ANTHROPIC_MODEL: models.main,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
}
return JSON.stringify(parsed, null, 2)
} catch {
return raw
}
}
function updateSettingsJsonProviderConnection(
raw: string,
apiFormat: ApiFormat,
authStrategy: ProviderAuthStrategy,
apiKey: string,
preset: ProviderPreset,
baseUrl: string,
): string {
try {
const parsed = JSON.parse(raw || '{}') as { env?: Record<string, unknown> }
const existingEnv = parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env)
? parsed.env
: {}
const env = { ...existingEnv }
delete env.ANTHROPIC_API_KEY
delete env.ANTHROPIC_AUTH_TOKEN
env.ANTHROPIC_BASE_URL = apiFormat !== 'anthropic' ? 'http://127.0.0.1:3456/proxy' : baseUrl
Object.assign(env, buildSettingsJsonAuthEnv(apiFormat, authStrategy, apiKey, preset))
parsed.env = env
return JSON.stringify(parsed, null, 2)
} catch {
return raw
}
}
function buildFallbackPreset(provider?: SavedProvider): ProviderPreset {
return {
id: provider?.presetId ?? 'custom',
name: provider?.name ?? 'Custom',
baseUrl: provider?.baseUrl ?? '',
apiFormat: provider?.apiFormat ?? 'anthropic',
authStrategy: provider?.authStrategy,
defaultModels: provider?.models ?? { main: '', haiku: '', sonnet: '', opus: '' },
modelContextWindows: provider?.modelContextWindows,
defaultEnv: provider?.autoCompactWindow !== undefined
? { [AUTO_COMPACT_WINDOW_ENV_KEY]: String(provider.autoCompactWindow) }
: undefined,
needsApiKey: true,
websiteUrl: '',
}
}
function openExternalUrl(url: string) {
if (!isTauriRuntime()) {
window.open(url, '_blank', 'noopener,noreferrer')
return
}
void import('@tauri-apps/plugin-shell')
.then((mod) => mod.open(url))
.catch(() => window.open(url, '_blank', 'noopener,noreferrer'))
}
function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderFormProps) {
const { createProvider, updateProvider, testConfig } = useProviderStore()
const fetchSettings = useSettingsStore((s) => s.fetchAll)
const t = useTranslation()
const availablePresets = presets.filter((p) => p.id !== 'official')
const regularPresets = availablePresets.filter((p) => !p.featured)
const featuredPresets = availablePresets.filter((p) => p.featured)
const presetDefaultEnvKeys = useMemo(
() => presets.flatMap((preset) => Object.keys(preset.defaultEnv ?? {})),
[presets],
)
const fallbackPreset = provider
? buildFallbackPreset(provider)
: requirePreset(availablePresets[availablePresets.length - 1])
const initialPreset = requirePreset(
provider
? availablePresets.find((p) => p.id === provider.presetId) ?? fallbackPreset
: availablePresets[0] ?? fallbackPreset,
)
const [selectedPreset, setSelectedPreset] = useState<ProviderPreset>(initialPreset)
const [name, setName] = useState(provider?.name ?? initialPreset.name)
const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl)
const [apiFormat, setApiFormat] = useState<ApiFormat>(provider?.apiFormat ?? initialPreset.apiFormat ?? 'anthropic')
const [authStrategy, setAuthStrategy] = useState<ProviderAuthStrategy>(provider?.authStrategy ?? getPresetAuthStrategy(initialPreset))
const [apiKey, setApiKey] = useState(provider?.apiKey ?? '')
const [showApiKey, setShowApiKey] = useState(false)
const [notes, setNotes] = useState(provider?.notes ?? '')
const [models, setModels] = useState<ModelMapping>(provider?.models ?? { ...initialPreset.defaultModels })
const [modelContextInputs, setModelContextInputs] = useState<ModelContextInputs>(
getModelContextInputs(provider?.models ?? initialPreset.defaultModels, initialPreset, provider),
)
const [autoCompactWindow, setAutoCompactWindow] = useState(
provider?.autoCompactWindow !== undefined
? String(provider.autoCompactWindow)
: getPresetAutoCompactWindow(initialPreset),
)
const [showContextSettings, setShowContextSettings] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [testResult, setTestResult] = useState<ProviderTestResult | null>(null)
const [isTesting, setIsTesting] = useState(false)
const [settingsJson, setSettingsJson] = useState('')
const [settingsJsonError, setSettingsJsonError] = useState<string | null>(null)
const jsonPastedRef = useRef(false)
// Load current settings.json and merge provider env vars
useEffect(() => {
// Skip if JSON was just populated by user paste
if (jsonPastedRef.current) {
jsonPastedRef.current = false
return
}
import('../api/providers').then(({ providersApi }) => {
providersApi.getSettings().then((settings) => {
const needsProxy = apiFormat !== 'anthropic'
const autoCompactWindowEnv = autoCompactWindow.trim()
const modelContextWindows = buildModelContextWindows(models, modelContextInputs)
const existingEnv = (settings.env as Record<string, string>) || {}
const cleanedEnv = stripProviderSettingsJsonEnv(existingEnv, presetDefaultEnvKeys)
const merged = {
...settings,
skipWebFetchPreflight: settings.skipWebFetchPreflight ?? true,
env: {
...cleanedEnv,
...omitAuthEnv(selectedPreset.defaultEnv),
...(autoCompactWindowEnv ? { [AUTO_COMPACT_WINDOW_ENV_KEY]: autoCompactWindowEnv } : {}),
...(Object.keys(modelContextWindows).length > 0
? { [MODEL_CONTEXT_WINDOWS_ENV_KEY]: JSON.stringify(modelContextWindows) }
: {}),
ANTHROPIC_BASE_URL: needsProxy ? 'http://127.0.0.1:3456/proxy' : baseUrl,
...buildSettingsJsonAuthEnv(apiFormat, authStrategy, apiKey, selectedPreset),
ANTHROPIC_MODEL: models.main,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
},
}
setSettingsJson(JSON.stringify(merged, null, 2))
}).catch(() => {
setSettingsJson(JSON.stringify({}, null, 2))
})
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedPreset.id])
const handlePresetChange = (preset: ProviderPreset) => {
setSelectedPreset(preset)
setName(preset.name)
setBaseUrl(preset.baseUrl)
setApiFormat(preset.apiFormat ?? 'anthropic')
setAuthStrategy(getPresetAuthStrategy(preset))
setModels({ ...preset.defaultModels })
setModelContextInputs(getModelContextInputs(preset.defaultModels, preset))
setAutoCompactWindow(getPresetAutoCompactWindow(preset))
setShowContextSettings(false)
setTestResult(null)
}
const isCustom = selectedPreset.id === 'custom'
const requiresApiKey = selectedPreset.needsApiKey !== false
const autoCompactWindowErrorKey = getAutoCompactWindowErrorKey(autoCompactWindow)
const modelContextWindowErrorSlots = MODEL_SLOTS.filter((slot) => getModelContextWindowErrorKey(modelContextInputs[slot]))
const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || !requiresApiKey || apiKey.trim()) && models.main.trim() && !settingsJsonError && !autoCompactWindowErrorKey && modelContextWindowErrorSlots.length === 0
const apiKeyUrl = selectedPreset.apiKeyUrl?.trim()
const promoText = selectedPreset.promoText?.trim()
const displayedSettingsJson = showApiKey
? settingsJson
: maskSettingsJsonSecrets(settingsJson)
const apiFormatItems = [
{
value: 'anthropic' as const,
label: t('settings.providers.apiFormatAnthropic'),
icon: <span className="material-symbols-outlined text-[17px]">hub</span>,
},
{
value: 'openai_chat' as const,
label: t('settings.providers.apiFormatOpenaiChat'),
icon: <span className="material-symbols-outlined text-[17px]">forum</span>,
},
{
value: 'openai_responses' as const,
label: t('settings.providers.apiFormatOpenaiResponses'),
icon: <span className="material-symbols-outlined text-[17px]">route</span>,
},
]
const selectedApiFormatLabel = apiFormatItems.find((item) => item.value === apiFormat)?.label ?? t('settings.providers.apiFormatAnthropic')
const authStrategyItems = [
{
value: 'auth_token' as const,
label: t('settings.providers.authStrategyAuthToken'),
description: t('settings.providers.authStrategyAuthTokenDesc'),
icon: <span className="material-symbols-outlined text-[17px]">key</span>,
},
{
value: 'auth_token_empty_api_key' as const,
label: t('settings.providers.authStrategyAuthTokenEmptyApiKey'),
description: t('settings.providers.authStrategyAuthTokenEmptyApiKeyDesc'),
icon: <span className="material-symbols-outlined text-[17px]">key_off</span>,
},
{
value: 'api_key' as const,
label: t('settings.providers.authStrategyApiKey'),
description: t('settings.providers.authStrategyApiKeyDesc'),
icon: <span className="material-symbols-outlined text-[17px]">vpn_key</span>,
},
{
value: 'dual_same_token' as const,
label: t('settings.providers.authStrategyDualSameToken'),
description: t('settings.providers.authStrategyDualSameTokenDesc'),
icon: <span className="material-symbols-outlined text-[17px]">sync_alt</span>,
},
{
value: 'dual_dummy' as const,
label: t('settings.providers.authStrategyDualDummy'),
description: t('settings.providers.authStrategyDualDummyDesc'),
icon: <span className="material-symbols-outlined text-[17px]">construction</span>,
},
] satisfies Array<{ value: ProviderAuthStrategy; label: string; description: string; icon: ReactNode }>
const selectedAuthStrategyLabel = authStrategyItems.find((item) => item.value === authStrategy)?.label ?? t('settings.providers.authStrategyAuthToken')
const configuredContextWindows = buildModelContextWindows(models, modelContextInputs)
const configuredContextSummary = Object.entries(configuredContextWindows)
.filter(([model], index, entries) => entries.findIndex(([candidate]) => candidate === model) === index)
.map(([model, value]) => `${model}: ${formatContextWindow(value)}`)
const parsedFallbackContextWindow = parseAutoCompactWindowInput(autoCompactWindow)
const fallbackContextSummary = parsedFallbackContextWindow !== undefined
? t('settings.providers.contextFallbackSummary', {
tokens: formatContextWindow(parsedFallbackContextWindow),
})
: t('settings.providers.contextFallbackAuto')
const contextSummary = configuredContextSummary.length > 0
? [...configuredContextSummary, fallbackContextSummary].join(' · ')
: t('settings.providers.contextSummaryAuto')
const shouldShowContextFields = showContextSettings || modelContextWindowErrorSlots.length > 0 || !!autoCompactWindowErrorKey
const handleAutoCompactWindowChange = (value: string) => {
setAutoCompactWindow(value)
setSettingsJson((current) => updateSettingsJsonAutoCompactWindow(current, value))
}
const handleBaseUrlChange = (value: string) => {
setBaseUrl(value)
setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, authStrategy, apiKey, selectedPreset, value))
}
const handleApiKeyChange = (value: string) => {
setApiKey(value)
setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, authStrategy, value, selectedPreset, baseUrl))
}
const handleApiFormatChange = (value: ApiFormat) => {
setApiFormat(value)
setSettingsJson((current) => updateSettingsJsonProviderConnection(current, value, authStrategy, apiKey, selectedPreset, baseUrl))
}
const handleAuthStrategyChange = (value: ProviderAuthStrategy) => {
setAuthStrategy(value)
setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, value, apiKey, selectedPreset, baseUrl))
}
const handleModelChange = (slot: ModelSlot, value: string) => {
const nextModels = { ...models, [slot]: value }
const nextInputs = {
...modelContextInputs,
[slot]: getModelContextInputValue(value, selectedPreset, provider),
}
setModels(nextModels)
setModelContextInputs(nextInputs)
setSettingsJson((current) => updateSettingsJsonModelContextWindows(
updateSettingsJsonModels(current, nextModels),
buildModelContextWindows(nextModels, nextInputs),
))
}
const handleModelContextWindowChange = (slot: ModelSlot, value: string) => {
const nextInputs = { ...modelContextInputs, [slot]: value }
setModelContextInputs(nextInputs)
setSettingsJson((current) => updateSettingsJsonModelContextWindows(
current,
buildModelContextWindows(models, nextInputs),
))
}
const renderPresetButton = (preset: ProviderPreset) => (
<button
key={preset.id}
onClick={() => handlePresetChange(preset)}
className={`px-3 py-1.5 text-xs font-medium rounded-full border transition-all ${
selectedPreset.id === preset.id
? 'border-[var(--color-brand)] bg-[var(--color-surface-container-high)] text-[var(--color-brand)] shadow-[var(--shadow-focus-ring)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]'
}`}
>
{preset.name}
</button>
)
const handleSubmit = async () => {
if (!canSubmit) return
const parsedAutoCompactWindow = parseAutoCompactWindowInput(autoCompactWindow)
const parsedModelContextWindows = buildModelContextWindows(models, modelContextInputs)
setIsSubmitting(true)
try {
// Write the edited cc-haha settings.json first so provider-specific model
// settings never conflict with the user's global ~/.claude/settings.json.
if (settingsJson.trim()) {
try {
const parsed = restoreSettingsJsonSecrets(JSON.parse(settingsJson), settingsJson, apiKey)
const { providersApi } = await import('../api/providers')
await providersApi.updateSettings(parsed)
} catch {
// JSON validation already prevents this
}
}
if (mode === 'create') {
await createProvider({
presetId: selectedPreset.id,
name: name.trim(),
apiKey: apiKey.trim(),
authStrategy,
baseUrl: baseUrl.trim(),
apiFormat,
models,
...(parsedAutoCompactWindow !== undefined && { autoCompactWindow: parsedAutoCompactWindow }),
...(Object.keys(parsedModelContextWindows).length > 0 && { modelContextWindows: parsedModelContextWindows }),
notes: notes.trim() || undefined,
})
} else if (provider) {
const input: UpdateProviderInput = {
name: name.trim(),
baseUrl: baseUrl.trim(),
authStrategy,
apiFormat,
models,
autoCompactWindow: parsedAutoCompactWindow ?? null,
modelContextWindows: Object.keys(parsedModelContextWindows).length > 0
? parsedModelContextWindows
: null,
notes: notes.trim() || undefined,
}
if (apiKey.trim()) input.apiKey = apiKey.trim()
await updateProvider(provider.id, input)
}
await fetchSettings()
onClose()
} catch (err) {
console.error('Failed to save provider:', err)
} finally {
setIsSubmitting(false)
}
}
const handleTest = async () => {
if (!baseUrl.trim() || !models.main.trim()) return
setIsTesting(true)
setTestResult(null)
try {
let result: ProviderTestResult
if (mode === 'edit' && provider && !apiKey.trim()) {
result = await useProviderStore.getState().testProvider(provider.id, {
baseUrl: baseUrl.trim(),
modelId: models.main.trim(),
apiFormat,
authStrategy,
})
} else {
if (requiresApiKey && !apiKey.trim()) return
result = await testConfig({
baseUrl: baseUrl.trim(),
apiKey: apiKey.trim() || selectedPreset.defaultEnv?.ANTHROPIC_AUTH_TOKEN || 'local',
modelId: models.main.trim(),
authStrategy,
apiFormat,
})
}
setTestResult(result)
} catch {
setTestResult({ connectivity: { success: false, latencyMs: 0, error: t('settings.providers.requestFailed') } })
} finally {
setIsTesting(false)
}
}
return (
<Modal
open={open}
onClose={onClose}
title={mode === 'create' ? t('settings.providers.addTitle') : t('settings.providers.editTitle')}
width={720}
footer={
<>
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={handleSubmit} disabled={!canSubmit} loading={isSubmitting}>
{mode === 'create' ? t('common.add') : t('common.save')}
</Button>
</>
}
>
<div className="flex flex-col gap-4">
{/* Preset chips */}
{mode === 'create' && (
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.preset')}</label>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-2">
{regularPresets.map(renderPresetButton)}
</div>
{featuredPresets.length > 0 && (
<div className="flex flex-wrap gap-2 border-t border-[var(--color-border)]/60 pt-2">
{featuredPresets.map(renderPresetButton)}
</div>
)}
</div>
</div>
)}
<Input label={t('settings.providers.name')} required value={name} onChange={(e) => setName(e.target.value)} placeholder={t('settings.providers.namePlaceholder')} />
<Input label={t('settings.providers.notes')} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder={t('settings.providers.notesPlaceholder')} />
<Input label={t('settings.providers.baseUrl')} required value={baseUrl} onChange={(e) => handleBaseUrlChange(e.target.value)} placeholder={t('settings.providers.baseUrlPlaceholder')} />
{/* API Format */}
{(isCustom || mode === 'edit') ? (
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.apiFormat')}</label>
<Dropdown<ApiFormat>
items={apiFormatItems}
value={apiFormat}
onChange={handleApiFormatChange}
width="100%"
className="block w-full"
trigger={
<button
type="button"
className="flex h-10 w-full items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-left text-sm text-[var(--color-text-primary)] outline-none transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-low)] focus-visible:border-[var(--color-border-focus)] focus-visible:shadow-[var(--shadow-focus-ring)]"
>
<span className="min-w-0 flex-1 truncate">{selectedApiFormatLabel}</span>
<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-text-secondary)]">expand_more</span>
</button>
}
/>
{apiFormat !== 'anthropic' && (
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">{t('settings.providers.proxyHint')}</p>
)}
</div>
) : apiFormat !== 'anthropic' ? (
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.apiFormat')}</label>
<div className="text-xs text-[var(--color-text-tertiary)] px-3 py-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container-low)] border border-[var(--color-border)]">
{apiFormat === 'openai_chat' ? t('settings.providers.apiFormatOpenaiChat') : t('settings.providers.apiFormatOpenaiResponses')}
</div>
</div>
) : null}
{apiFormat === 'anthropic' && (
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.authStrategy')}</label>
<Dropdown<ProviderAuthStrategy>
items={authStrategyItems}
value={authStrategy}
onChange={handleAuthStrategyChange}
width="100%"
className="block w-full"
trigger={
<button
type="button"
className="flex min-h-10 w-full items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-left text-sm text-[var(--color-text-primary)] outline-none transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-low)] focus-visible:border-[var(--color-border-focus)] focus-visible:shadow-[var(--shadow-focus-ring)]"
>
<span className="min-w-0 flex-1 truncate">{selectedAuthStrategyLabel}</span>
<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-text-secondary)]">expand_more</span>
</button>
}
/>
</div>
)}
<div className="flex flex-col gap-1">
<label htmlFor="provider-api-key" className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.providers.apiKey')}
{mode === 'create' && requiresApiKey && <span className="text-[var(--color-error)] ml-0.5">*</span>}
</label>
<div className="relative">
<input
id="provider-api-key"
type={showApiKey ? 'text' : 'password'}
value={apiKey}
onChange={(e) => handleApiKeyChange(e.target.value)}
placeholder="sk-..."
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 pr-10 text-sm text-[var(--color-text-primary)] outline-none transition-colors duration-150 placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-border-focus)] focus:shadow-[var(--shadow-focus-ring)]"
/>
<button
type="button"
onClick={() => setShowApiKey((visible) => !visible)}
aria-label={showApiKey ? 'Hide API Key' : 'Show API Key'}
className="absolute right-1.5 top-1/2 flex h-7 w-7 -translate-y-1/2 cursor-pointer items-center justify-center rounded-[var(--radius-sm)] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus:outline-none focus:shadow-[var(--shadow-focus-ring)]"
>
<span className="material-symbols-outlined text-[16px]">
{showApiKey ? 'visibility_off' : 'visibility'}
</span>
</button>
</div>
</div>
{(apiKeyUrl || promoText) && (
<div className="-mt-2 flex flex-col gap-1.5">
{apiKeyUrl && (
<button
type="button"
onClick={() => openExternalUrl(apiKeyUrl)}
className="group inline-flex h-6 w-fit cursor-pointer items-center gap-1 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 text-[11px] font-medium leading-none text-[var(--color-brand)] transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus:outline-none focus:shadow-[var(--shadow-focus-ring)]"
>
<span className="material-symbols-outlined text-[13px]">key</span>
{t('settings.providers.getApiKey')}
<span className="material-symbols-outlined text-[9px] opacity-60 transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5">arrow_outward</span>
</button>
)}
{promoText && (
<button
type="button"
onClick={() => apiKeyUrl && openExternalUrl(apiKeyUrl)}
disabled={!apiKeyUrl}
className="group flex w-full cursor-pointer items-start gap-1.5 rounded-[var(--radius-sm)] border border-[var(--color-brand)]/25 bg-[var(--color-brand)]/8 px-2.5 py-1.5 text-left text-[11px] leading-5 text-[var(--color-text-primary)] transition-colors hover:border-[var(--color-brand)]/45 hover:bg-[var(--color-brand)]/12 focus:outline-none focus:shadow-[var(--shadow-focus-ring)] disabled:cursor-default disabled:hover:border-[var(--color-brand)]/25 disabled:hover:bg-[var(--color-brand)]/8"
>
<span className="material-symbols-outlined mt-0.5 text-[13px] text-[var(--color-brand)]">tips_and_updates</span>
<span>{promoText}</span>
{apiKeyUrl && (
<span className="material-symbols-outlined ml-auto mt-1 text-[10px] text-[var(--color-brand)] opacity-45 transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5">arrow_outward</span>
)}
</button>
)}
</div>
)}
{/* Model Mapping */}
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.modelMapping')}</label>
<div className="grid grid-cols-2 gap-2">
<Input label={t('settings.providers.mainModel')} required value={models.main} onChange={(e) => handleModelChange('main', e.target.value)} placeholder="Model ID" />
<Input label={t('settings.providers.haikuModel')} value={models.haiku} onChange={(e) => handleModelChange('haiku', e.target.value)} placeholder={t('settings.providers.sameAsMain')} />
<Input label={t('settings.providers.sonnetModel')} value={models.sonnet} onChange={(e) => handleModelChange('sonnet', e.target.value)} placeholder={t('settings.providers.sameAsMain')} />
<Input label={t('settings.providers.opusModel')} value={models.opus} onChange={(e) => handleModelChange('opus', e.target.value)} placeholder={t('settings.providers.sameAsMain')} />
</div>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<button
type="button"
onClick={() => setShowContextSettings((visible) => !visible)}
className="flex w-full items-start gap-3 px-3 py-3 text-left outline-none transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:shadow-[var(--shadow-focus-ring)]"
aria-expanded={shouldShowContextFields}
>
<span className="material-symbols-outlined mt-0.5 text-[18px] text-[var(--color-brand)]">compress</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.providers.contextSettingsTitle')}
</span>
<span className="mt-1 block truncate text-xs text-[var(--color-text-secondary)]">
{contextSummary}
</span>
<span className="mt-1 block text-[11px] leading-5 text-[var(--color-text-tertiary)]">
{t('settings.providers.contextSettingsDesc')}
</span>
</span>
<span className="mt-0.5 inline-flex items-center gap-1 text-xs font-medium text-[var(--color-brand)]">
{shouldShowContextFields
? t('settings.providers.contextSettingsHide')
: t('settings.providers.contextSettingsEdit')}
<span className="material-symbols-outlined text-[16px]">
{shouldShowContextFields ? 'expand_less' : 'expand_more'}
</span>
</span>
</button>
{shouldShowContextFields && (
<div className="border-t border-[var(--color-border)] px-3 pb-3 pt-3">
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.modelContextWindows')}</label>
<div className="grid grid-cols-2 gap-2">
{MODEL_SLOTS.map((slot) => {
const errorKey = getModelContextWindowErrorKey(modelContextInputs[slot])
const labelKey = slot === 'main'
? 'settings.providers.mainContextWindow'
: slot === 'haiku'
? 'settings.providers.haikuContextWindow'
: slot === 'sonnet'
? 'settings.providers.sonnetContextWindow'
: 'settings.providers.opusContextWindow'
return (
<div key={slot}>
<Input
label={t(labelKey)}
value={modelContextInputs[slot]}
onChange={(e) => handleModelContextWindowChange(slot, e.target.value)}
placeholder={t('settings.providers.contextWindowPlaceholder')}
/>
{errorKey && (
<p className="text-[11px] text-[var(--color-error)] mt-1">
{errorKey === 'number'
? t('settings.providers.modelContextWindowNumberError')
: t('settings.providers.modelContextWindowRangeError')}
</p>
)}
</div>
)
})}
</div>
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">
{t('settings.providers.modelContextWindowsDesc')}
</p>
</div>
<div className="mt-3">
<Input
label={t('settings.providers.autoCompactWindow')}
value={autoCompactWindow}
onChange={(e) => handleAutoCompactWindowChange(e.target.value)}
placeholder={t('settings.providers.autoCompactWindowPlaceholder')}
/>
{autoCompactWindowErrorKey ? (
<p className="text-[11px] text-[var(--color-error)] mt-1">
{autoCompactWindowErrorKey === 'number'
? t('settings.providers.autoCompactWindowNumberError')
: t('settings.providers.autoCompactWindowRangeError')}
</p>
) : (
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">
{t('settings.providers.autoCompactWindowDesc')}
</p>
)}
</div>
</div>
)}
</div>
{/* Test connection */}
<div className="flex items-center gap-3">
<Button variant="secondary" size="sm" onClick={handleTest} loading={isTesting} disabled={!baseUrl.trim() || !models.main.trim()}>
{t('settings.providers.testConnection')}
</Button>
{testResult && (
<div className="flex flex-col gap-0.5">
<span className={`text-xs ${testResult.connectivity.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
{testResult.connectivity.success
? t('settings.providers.connectivityOk', { latency: String(testResult.connectivity.latencyMs) })
: t('settings.providers.connectivityFailed', { error: testResult.connectivity.error || '' })}
</span>
{testResult.proxy && (
<span className={`text-xs ${testResult.proxy.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
{testResult.proxy.success
? t('settings.providers.proxyOk', { latency: String(testResult.proxy.latencyMs) })
: t('settings.providers.proxyFailed', { error: testResult.proxy.error || '' })}
</span>
)}
</div>
)}
</div>
{/* Settings JSON — editable, shown for all presets including official */}
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.settingsJson')}</label>
<textarea
value={displayedSettingsJson}
onChange={(e) => {
const raw = e.target.value
try {
const parsed = restoreSettingsJsonSecrets(JSON.parse(raw), settingsJson, apiKey)
setSettingsJson(JSON.stringify(parsed, null, 2))
setSettingsJsonError(null)
// Auto-fill form fields from parsed JSON env
const env = parsed.env as Record<string, string> | undefined
if (env) {
if (env.ANTHROPIC_BASE_URL) {
setBaseUrl(env.ANTHROPIC_BASE_URL)
// Auto-switch to matching preset or Custom
if (mode === 'create') {
const matchedPreset = availablePresets.find((p) => p.id !== 'custom' && p.baseUrl === env.ANTHROPIC_BASE_URL)
const targetPreset = requirePreset(
matchedPreset ?? availablePresets.find((p) => p.id === 'custom'),
)
if (targetPreset.id !== selectedPreset.id) {
jsonPastedRef.current = true
setSelectedPreset(targetPreset)
}
}
}
const nextApiKey = env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY
if (nextApiKey && nextApiKey !== '(your API key)' && nextApiKey !== API_KEY_JSON_PLACEHOLDER) {
setApiKey(nextApiKey)
}
const nextAuthStrategy = inferAuthStrategyFromEnv(env)
if (nextAuthStrategy) {
setAuthStrategy(nextAuthStrategy)
}
if (env[AUTO_COMPACT_WINDOW_ENV_KEY] !== undefined) {
setAutoCompactWindow(String(env[AUTO_COMPACT_WINDOW_ENV_KEY]))
} else {
setAutoCompactWindow('')
}
let parsedContextWindows: Record<string, number> = {}
if (typeof env[MODEL_CONTEXT_WINDOWS_ENV_KEY] === 'string') {
try {
const parsedContext = JSON.parse(env[MODEL_CONTEXT_WINDOWS_ENV_KEY]) as Record<string, unknown>
parsedContextWindows = Object.fromEntries(
Object.entries(parsedContext)
.filter(([, value]) => typeof value === 'number' && Number.isInteger(value)),
) as Record<string, number>
} catch {
parsedContextWindows = {}
}
}
const newModels: Partial<ModelMapping> = {}
if (env.ANTHROPIC_MODEL) newModels.main = env.ANTHROPIC_MODEL
if (env.ANTHROPIC_DEFAULT_HAIKU_MODEL) newModels.haiku = env.ANTHROPIC_DEFAULT_HAIKU_MODEL
if (env.ANTHROPIC_DEFAULT_SONNET_MODEL) newModels.sonnet = env.ANTHROPIC_DEFAULT_SONNET_MODEL
if (env.ANTHROPIC_DEFAULT_OPUS_MODEL) newModels.opus = env.ANTHROPIC_DEFAULT_OPUS_MODEL
if (Object.keys(newModels).length > 0) {
setModels((prev) => {
const nextModels = { ...prev, ...newModels }
setModelContextInputs(getModelContextInputs(nextModels, {
...selectedPreset,
modelContextWindows: parsedContextWindows,
}))
return nextModels
})
} else if (Object.keys(parsedContextWindows).length > 0) {
setModelContextInputs(getModelContextInputs(models, {
...selectedPreset,
modelContextWindows: parsedContextWindows,
}))
}
}
} catch (err) {
setSettingsJson(raw)
setSettingsJsonError(err instanceof Error ? err.message : 'Invalid JSON')
}
}}
rows={16}
spellCheck={false}
className={`w-full text-xs px-3 py-3 rounded-[var(--radius-md)] bg-[var(--color-surface-container-low)] border font-mono leading-relaxed resize-y text-[var(--color-text-secondary)] outline-none ${
settingsJsonError
? 'border-[var(--color-error)] focus:border-[var(--color-error)]'
: 'border-[var(--color-border)] focus:border-[var(--color-border-focus)]'
}`}
/>
{settingsJsonError && (
<p className="text-[11px] text-[var(--color-error)] mt-1">{t('settings.providers.jsonError', { error: settingsJsonError })}</p>
)}
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">{t('settings.providers.settingsJsonDesc')}</p>
</div>
</div>
</Modal>
)
}
// ─── Permission Settings ──────────────────────────────────────
function PermissionSettings() {
const { permissionMode, setPermissionMode } = useSettingsStore()
const t = useTranslation()
const MODES: Array<{ mode: PermissionMode; icon: string; label: string; desc: string }> = [
{ mode: 'default', icon: 'verified_user', label: t('settings.permissions.default'), desc: t('settings.permissions.defaultDesc') },
{ mode: 'acceptEdits', icon: 'edit_note', label: t('settings.permissions.acceptEdits'), desc: t('settings.permissions.acceptEditsDesc') },
{ mode: 'plan', icon: 'architecture', label: t('settings.permissions.plan'), desc: t('settings.permissions.planDesc') },
{ mode: 'bypassPermissions', icon: 'bolt', label: t('settings.permissions.bypass'), desc: t('settings.permissions.bypassDesc') },
]
return (
<div className="max-w-xl">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.permissions.title')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">{t('settings.permissions.description')}</p>
<div className="flex flex-col gap-2">
{MODES.map(({ mode, icon, label, desc }) => {
const isSelected = permissionMode === mode
return (
<button
key={mode}
onClick={() => setPermissionMode(mode)}
className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-all text-left ${
isSelected
? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className="material-symbols-outlined text-[20px] text-[var(--color-text-secondary)]">{icon}</span>
<div className="flex-1">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{label}</div>
<div className="text-xs text-[var(--color-text-tertiary)]">{desc}</div>
</div>
{isSelected && (
<span className="material-symbols-outlined text-[18px] text-[var(--color-brand)]" style={{ fontVariationSettings: "'FILL' 1" }}>
check_circle
</span>
)}
</button>
)
})}
</div>
</div>
)
}
// ─── General Settings ──────────────────────────────────────
function GeneralSettings() {
const {
effortLevel,
setEffort,
thinkingEnabled,
setThinkingEnabled,
locale,
setLocale,
theme,
setTheme,
skipWebFetchPreflight,
setSkipWebFetchPreflight,
desktopNotificationsEnabled,
setDesktopNotificationsEnabled,
webSearch,
setWebSearch,
responseLanguage,
setResponseLanguage,
appMode,
appModeRequiresRestart,
fetchAppMode,
setAppMode: setAppModeAction,
uiZoom,
setUiZoom,
} = useSettingsStore()
const t = useTranslation()
const [webSearchDraft, setWebSearchDraft] = useState(webSearch)
const [notificationPermission, setNotificationPermission] = useState<DesktopNotificationPermission>('default')
const [notificationActionRunning, setNotificationActionRunning] = useState(false)
const [modeSwitchConfirmOpen, setModeSwitchConfirmOpen] = useState(false)
const [pendingMode, setPendingMode] = useState<AppMode | null>(null)
const webSearchDirty = JSON.stringify(webSearchDraft) !== JSON.stringify(webSearch)
useEffect(() => {
setWebSearchDraft(webSearch)
}, [webSearch])
useEffect(() => {
let cancelled = false
getDesktopNotificationPermission().then((permission) => {
if (!cancelled) setNotificationPermission(permission)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (!isTauriRuntime()) return
void fetchAppMode()
}, [fetchAppMode])
const EFFORT_LABELS: Record<EffortLevel, string> = {
low: t('settings.general.effort.low'),
medium: t('settings.general.effort.medium'),
high: t('settings.general.effort.high'),
max: t('settings.general.effort.max'),
}
const LANGUAGES: Array<{ value: Locale; label: string }> = [
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文' },
]
const RESPONSE_LANGUAGES: Array<{ value: string; label: string }> = [
{ value: '', label: t('settings.general.responseLangDefault') },
{ value: 'english', label: 'English' },
{ value: 'chinese', label: '中文 (Chinese)' },
{ value: 'japanese', label: '日本語 (Japanese)' },
{ value: 'korean', label: '한국어 (Korean)' },
{ value: 'spanish', label: 'Español (Spanish)' },
{ value: 'french', label: 'Français (French)' },
{ value: 'german', label: 'Deutsch (German)' },
{ value: 'portuguese', label: 'Português (Portuguese)' },
{ value: 'italian', label: 'Italiano (Italian)' },
{ value: 'russian', label: 'Русский (Russian)' },
{ value: 'dutch', label: 'Nederlands (Dutch)' },
{ value: 'polish', label: 'Polski (Polish)' },
{ value: 'turkish', label: 'Türkçe (Turkish)' },
{ value: 'hindi', label: 'हिन्दी (Hindi)' },
{ value: 'indonesian', label: 'Bahasa Indonesia' },
{ value: 'ukrainian', label: 'Українська (Ukrainian)' },
{ value: 'greek', label: 'Ελληνικά (Greek)' },
{ value: 'czech', label: 'Čeština (Czech)' },
{ value: 'danish', label: 'Dansk (Danish)' },
{ value: 'swedish', label: 'Svenska (Swedish)' },
{ value: 'norwegian', label: 'Norsk (Norwegian)' },
]
const selectedResponseLanguageLabel =
RESPONSE_LANGUAGES.find(({ value }) => value === responseLanguage)?.label ?? RESPONSE_LANGUAGES[0]!.label
const THEMES: Array<{ value: ThemeMode; label: string }> = [
{ value: 'light', label: t('settings.general.appearance.light') },
{ value: 'dark', label: t('settings.general.appearance.dark') },
{ value: 'white', label: t('settings.general.appearance.white') },
]
const WEB_SEARCH_MODES: Array<{ value: WebSearchMode; label: string }> = [
{ value: 'auto', label: t('settings.general.webSearch.mode.auto') },
{ value: 'tavily', label: t('settings.general.webSearch.mode.tavily') },
{ value: 'brave', label: t('settings.general.webSearch.mode.brave') },
{ value: 'anthropic', label: t('settings.general.webSearch.mode.anthropic') },
{ value: 'disabled', label: t('settings.general.webSearch.mode.disabled') },
]
const notificationStatusLabel: Record<DesktopNotificationPermission, string> = {
granted: t('settings.general.notificationsStatusGranted'),
denied: t('settings.general.notificationsStatusDenied'),
default: t('settings.general.notificationsStatusDefault'),
unsupported: t('settings.general.notificationsStatusUnsupported'),
}
const handleDesktopNotificationsToggle = async (enabled: boolean) => {
await setDesktopNotificationsEnabled(enabled)
if (!enabled) return
setNotificationActionRunning(true)
try {
const permission = await requestDesktopNotificationPermission()
setNotificationPermission(permission)
if (permission === 'granted') {
void notifyDesktop({
title: t('settings.general.notificationsTestTitle'),
body: t('settings.general.notificationsTestBody'),
})
}
if (permission === 'denied') {
await openDesktopNotificationSettings()
}
} finally {
setNotificationActionRunning(false)
}
}
const handleNotificationPermissionAction = async () => {
setNotificationActionRunning(true)
try {
if (notificationPermission === 'denied') {
await openDesktopNotificationSettings()
} else {
const permission = await requestDesktopNotificationPermission()
setNotificationPermission(permission)
if (permission === 'granted') {
void notifyDesktop({
title: t('settings.general.notificationsTestTitle'),
body: t('settings.general.notificationsTestBody'),
})
}
if (permission === 'denied') {
await openDesktopNotificationSettings()
}
}
} finally {
setNotificationActionRunning(false)
}
}
return (
<div className="max-w-xl">
{/* Mode Section */}
{isTauriRuntime() && (
<div className="mb-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.modeTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.modeDescription')}</p>
<div className="flex gap-2">
<button
onClick={() => {
if (appMode.mode === 'default') return
setPendingMode('default')
setModeSwitchConfirmOpen(true)
}}
aria-pressed={appMode.mode === 'default'}
className={`flex-1 py-3 text-sm font-semibold rounded-lg border transition-all ${
appMode.mode === 'default'
? 'bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)] border-transparent shadow-[var(--shadow-button-primary)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
<div className="flex flex-col items-center gap-1">
<span className="material-symbols-outlined text-[22px]">settings_applications</span>
<span>{t('settings.general.modeDefault')}</span>
</div>
</button>
<button
onClick={() => {
if (appMode.mode === 'portable') return
setPendingMode('portable')
setModeSwitchConfirmOpen(true)
}}
aria-pressed={appMode.mode === 'portable'}
className={`flex-1 py-3 text-sm font-semibold rounded-lg border transition-all ${
appMode.mode === 'portable'
? 'bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)] border-transparent shadow-[var(--shadow-button-primary)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
<div className="flex flex-col items-center gap-1">
<span className="material-symbols-outlined text-[22px]">drive_file_move</span>
<span>{t('settings.general.modePortable')}</span>
</div>
</button>
</div>
{appMode.mode === 'portable' && appMode.portableDir && (
<div className="mt-2 text-xs text-[var(--color-text-tertiary)] font-mono break-all">
{t('settings.general.modePortableDir')}: {appMode.portableDir}
</div>
)}
{appMode.mode === 'default' && (
<div className="mt-2 text-xs text-[var(--color-text-tertiary)]">
{t('settings.general.modeDefaultHint')}
</div>
)}
</div>
)}
{/* Restart Required Banner */}
{appModeRequiresRestart && (
<div className="mb-6 rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning)]/10 px-4 py-3 flex items-center gap-3">
<span className="material-symbols-outlined text-[20px] text-[var(--color-warning)]" style={{ fontVariationSettings: "'FILL' 1" }}>
warning
</span>
<div>
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.general.modeRestartTitle')}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">
{t('settings.general.modeRestartHint')}
</div>
</div>
</div>
)}
{/* Appearance selector */}
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.appearanceTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.appearanceDescription')}</p>
<div className="flex gap-2 mb-8">
{THEMES.map(({ value, label }) => (
<button
key={value}
onClick={() => void setTheme(value)}
aria-pressed={theme === value}
className={`flex-1 py-2 text-xs font-semibold rounded-lg border transition-all ${
theme === value
? 'bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)] border-transparent shadow-[var(--shadow-button-primary)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
{label}
</button>
))}
</div>
{/* Language selector */}
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.languageTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.languageDescription')}</p>
<div className="flex gap-2 mb-8">
{LANGUAGES.map(({ value, label }) => (
<button
key={value}
onClick={() => setLocale(value)}
className={`flex-1 py-2 text-xs font-semibold rounded-lg border transition-all ${
locale === value
? 'bg-[var(--color-brand)] text-white border-[var(--color-brand)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
{label}
</button>
))}
</div>
{/* Response Language */}
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.responseLangTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.responseLangDescription')}</p>
<Dropdown<string>
items={RESPONSE_LANGUAGES}
value={responseLanguage}
onChange={(value) => void setResponseLanguage(value)}
width="100%"
maxHeight={320}
className="mb-8 block w-full"
trigger={
<button
type="button"
aria-label={t('settings.general.responseLangTitle')}
className="flex h-10 w-full items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-left text-sm text-[var(--color-text-primary)] outline-none transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-low)] focus-visible:border-[var(--color-border-focus)] focus-visible:shadow-[var(--shadow-focus-ring)]"
>
<span className="min-w-0 flex-1 truncate">{selectedResponseLanguageLabel}</span>
<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-text-secondary)]">expand_more</span>
</button>
}
/>
{/* Effort Level */}
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.effortTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.effortDescription')}</p>
<div className="flex gap-2">
{(['low', 'medium', 'high', 'max'] as EffortLevel[]).map((level) => (
<button
key={level}
onClick={() => setEffort(level)}
className={`flex-1 py-2 text-xs font-semibold rounded-lg border transition-all ${
effortLevel === level
? 'bg-[var(--color-brand)] text-white border-[var(--color-brand)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
{EFFORT_LABELS[level]}
</button>
))}
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.thinkingTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.thinkingDescription')}</p>
<label className="flex items-start gap-3 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 cursor-pointer hover:border-[var(--color-border-focus)] transition-colors">
<input
type="checkbox"
aria-label={t('settings.general.thinkingEnabled')}
checked={thinkingEnabled}
onChange={(e) => void setThinkingEnabled(e.target.checked)}
className="peer sr-only"
/>
<SettingsCheckboxMark checked={thinkingEnabled} />
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.general.thinkingEnabled')}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-1 leading-5">
{t('settings.general.thinkingHint')}
</div>
</div>
</label>
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.notificationsTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.notificationsDescription')}</p>
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
aria-label={t('settings.general.notificationsEnabled')}
checked={desktopNotificationsEnabled}
onChange={(e) => void handleDesktopNotificationsToggle(e.target.checked)}
className="peer sr-only"
/>
<SettingsCheckboxMark checked={desktopNotificationsEnabled} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.general.notificationsEnabled')}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-1 leading-5">
{desktopNotificationsEnabled
? t('settings.general.notificationsHintOn')
: t('settings.general.notificationsHintOff')}
</div>
</div>
</label>
{desktopNotificationsEnabled && (
<div className="mt-3 flex items-center justify-between gap-3 border-t border-[var(--color-border)]/60 pt-3">
<div className="min-w-0 text-xs text-[var(--color-text-tertiary)]">
{t('settings.general.notificationsStatus')}: {notificationStatusLabel[notificationPermission]}
</div>
{notificationPermission !== 'granted' && notificationPermission !== 'unsupported' && (
<Button
size="sm"
variant="secondary"
className="px-3 whitespace-nowrap"
disabled={notificationActionRunning}
onClick={() => void handleNotificationPermissionAction()}
>
{notificationPermission === 'denied'
? t('settings.general.notificationsOpenSettings')
: t('settings.general.notificationsAuthorize')}
</Button>
)}
</div>
)}
</div>
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.webFetchPreflightTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.webFetchPreflightDescription')}</p>
<label className="flex items-start gap-3 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 cursor-pointer hover:border-[var(--color-border-focus)] transition-colors">
<input
type="checkbox"
aria-label={t('settings.general.webFetchPreflightEnabled')}
checked={skipWebFetchPreflight}
onChange={(e) => void setSkipWebFetchPreflight(e.target.checked)}
className="peer sr-only"
/>
<SettingsCheckboxMark checked={skipWebFetchPreflight} />
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.general.webFetchPreflightEnabled')}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-1 leading-5">
{t('settings.general.webFetchPreflightHint')}
</div>
</div>
</label>
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.webSearchTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.webSearchDescription')}</p>
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-4">
<div className="grid grid-cols-5 gap-1.5 mb-4">
{WEB_SEARCH_MODES.map(({ value, label }) => (
<button
key={value}
onClick={() => setWebSearchDraft({ ...webSearchDraft, mode: value })}
className={`h-9 px-2 text-xs font-semibold rounded-lg border transition-all truncate ${
(webSearchDraft.mode ?? 'auto') === value
? 'bg-[var(--color-brand)] text-white border-[var(--color-brand)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
title={label}
>
{label}
</button>
))}
</div>
<div className="grid grid-cols-1 gap-3">
<Input
id="web-search-tavily-key"
type="password"
label={t('settings.general.webSearchTavilyKey')}
value={webSearchDraft.tavilyApiKey ?? ''}
placeholder="tvly-..."
autoComplete="off"
onChange={(event) =>
setWebSearchDraft({
...webSearchDraft,
tavilyApiKey: event.target.value,
})
}
/>
<div className="-mt-1 flex items-center justify-between gap-3 text-xs text-[var(--color-text-tertiary)]">
<span>{t('settings.general.webSearchTavilyFreeHint')}</span>
<a
href="https://app.tavily.com/home"
target="_blank"
rel="noreferrer"
aria-label={t('settings.general.webSearchTavilyApiKeyLink')}
className="font-medium text-[var(--color-brand)] hover:underline whitespace-nowrap"
>
{t('settings.general.webSearchGetApiKey')}
</a>
</div>
<Input
id="web-search-brave-key"
type="password"
label={t('settings.general.webSearchBraveKey')}
value={webSearchDraft.braveApiKey ?? ''}
placeholder={t('settings.general.webSearchBravePlaceholder')}
autoComplete="off"
onChange={(event) =>
setWebSearchDraft({
...webSearchDraft,
braveApiKey: event.target.value,
})
}
/>
<div className="-mt-1 flex items-center justify-between gap-3 text-xs text-[var(--color-text-tertiary)]">
<span>{t('settings.general.webSearchBraveFreeHint')}</span>
<a
href="https://api-dashboard.search.brave.com/app/keys"
target="_blank"
rel="noreferrer"
aria-label={t('settings.general.webSearchBraveApiKeyLink')}
className="font-medium text-[var(--color-brand)] hover:underline whitespace-nowrap"
>
{t('settings.general.webSearchGetApiKey')}
</a>
</div>
</div>
<div className="mt-4 flex flex-col gap-3">
<p className="text-xs text-[var(--color-text-tertiary)] leading-5">
{t('settings.general.webSearchHint')}
</p>
<div className="flex justify-end">
<Button
size="sm"
variant="secondary"
className="min-w-[72px] px-4 whitespace-nowrap"
disabled={!webSearchDirty}
onClick={() => void setWebSearch(webSearchDraft)}
>
{t('settings.general.webSearchSave')}
</Button>
</div>
</div>
</div>
</div>
{/* UI Zoom */}
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.uiZoom')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.uiZoomDescription')}</p>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-end">
<span className="text-sm text-[var(--color-text-secondary)]">
{Math.round(uiZoom * 100)}%
</span>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-[var(--color-text-tertiary)]">{Math.round(UI_ZOOM_MIN * 100)}%</span>
<input
type="range"
min={UI_ZOOM_MIN}
max={UI_ZOOM_MAX}
step={UI_ZOOM_STEP}
value={uiZoom}
onChange={(e) => setUiZoom(parseFloat(e.target.value))}
onMouseUp={(e) => e.currentTarget.blur()}
className="flex-1"
/>
<span className="text-xs text-[var(--color-text-tertiary)]">{Math.round(UI_ZOOM_MAX * 100)}%</span>
</div>
</div>
</div>
{/* Confirm dialog for mode switch */}
<ConfirmDialog
open={modeSwitchConfirmOpen}
onClose={() => { setModeSwitchConfirmOpen(false); setPendingMode(null); }}
onConfirm={() => {
if (pendingMode) { void setAppModeAction(pendingMode); }
setModeSwitchConfirmOpen(false);
setPendingMode(null);
}}
title={t('settings.general.modeSwitchTitle')}
body={t('settings.general.modeSwitchBody', {
mode: pendingMode === 'portable' ? t('settings.general.modePortable') : t('settings.general.modeDefault'),
})}
confirmLabel={t('settings.general.modeSwitchConfirm')}
cancelLabel={t('common.cancel')}
/>
</div>
)
}
// ─── H5 Access Settings ──────────────────────────────────────
function H5AccessSettings() {
const {
h5Access,
h5AccessError,
enableH5Access,
disableH5Access,
regenerateH5AccessToken,
updateH5AccessSettings,
} = useSettingsStore()
const t = useTranslation()
const [h5PublicBaseUrlDraft, setH5PublicBaseUrlDraft] = useState(h5Access.publicBaseUrl ?? '')
const [h5GeneratedToken, setH5GeneratedToken] = useState<string | null>(null)
const [h5TokenVisible, setH5TokenVisible] = useState(false)
const [h5EnableConfirmOpen, setH5EnableConfirmOpen] = useState(false)
const [h5QrDataUrl, setH5QrDataUrl] = useState<string | null>(null)
const [h5ActionRunning, setH5ActionRunning] = useState(false)
const h5AccessUrl = h5Access.publicBaseUrl
const h5LaunchUrl = useMemo(
() => buildH5LaunchUrl(h5AccessUrl, h5GeneratedToken),
[h5AccessUrl, h5GeneratedToken],
)
const h5AccessDirty = h5PublicBaseUrlDraft.trim() !== (h5Access.publicBaseUrl ?? '')
useEffect(() => {
setH5PublicBaseUrlDraft(h5Access.publicBaseUrl ?? '')
}, [h5Access])
useEffect(() => {
let cancelled = false
if (!h5Access.enabled || !h5LaunchUrl || !h5GeneratedToken) {
setH5QrDataUrl(null)
return () => {
cancelled = true
}
}
QRCode.toDataURL(h5LaunchUrl, { margin: 1, width: 192 })
.then((dataUrl) => {
if (!cancelled) setH5QrDataUrl(dataUrl)
})
.catch(() => {
if (!cancelled) setH5QrDataUrl(null)
})
return () => {
cancelled = true
}
}, [h5Access.enabled, h5LaunchUrl, h5GeneratedToken])
const runH5Action = async (action: () => Promise<void>) => {
setH5ActionRunning(true)
try {
await action()
} catch {
// The store owns H5-specific error state.
} finally {
setH5ActionRunning(false)
}
}
const handleH5SettingsSave = async () => {
await runH5Action(async () => {
await updateH5AccessSettings({
publicBaseUrl: h5PublicBaseUrlDraft.trim() || null,
})
})
}
const handleH5UrlCopy = async () => {
if (!h5AccessUrl) return
await copyTextToClipboard(h5AccessUrl)
}
const handleH5LaunchUrlCopy = async () => {
if (!h5LaunchUrl) return
await copyTextToClipboard(h5LaunchUrl)
}
const handleH5EnableConfirm = async () => {
await runH5Action(async () => {
const token = await enableH5Access()
setH5GeneratedToken(token)
setH5TokenVisible(false)
setH5EnableConfirmOpen(false)
})
}
const handleH5Disable = async () => {
await runH5Action(async () => {
await disableH5Access()
setH5GeneratedToken(null)
setH5TokenVisible(false)
})
}
const handleH5Regenerate = async () => {
await runH5Action(async () => {
const token = await regenerateH5AccessToken()
setH5GeneratedToken(token)
setH5TokenVisible(false)
})
}
return (
<div className="max-w-3xl">
<section aria-labelledby="h5-access-title" role="region">
<div className="mb-5 flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-brand)]">
<QrCode className="h-5 w-5" aria-hidden="true" />
</div>
<div className="min-w-0">
<h2
id="h5-access-title"
className="text-base font-semibold text-[var(--color-text-primary)] mb-1"
>
{t('settings.general.h5AccessTitle')}
</h2>
<p className="text-sm text-[var(--color-text-tertiary)]">
{t('settings.general.h5AccessDescription')}
</p>
</div>
</div>
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-4">
<div className="flex items-start justify-between gap-4">
<label className="flex min-w-0 items-start gap-3">
<input
type="checkbox"
className="mt-1 h-4 w-4 rounded border-[var(--color-border)] accent-[var(--color-primary)]"
checked={h5Access.enabled}
disabled={h5ActionRunning}
aria-label={t('settings.general.h5AccessEnabled')}
onChange={(event) => {
if (event.target.checked) {
setH5EnableConfirmOpen(true)
} else {
void handleH5Disable()
}
}}
/>
<span className="min-w-0">
<span className="block text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.general.h5AccessEnabled')}
</span>
<span className="mt-1 block text-xs leading-5 text-[var(--color-text-tertiary)]">
{t('settings.general.h5AccessEnabledHint')}
</span>
</span>
</label>
<span
className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${
h5Access.enabled
? 'bg-[var(--color-success)]/10 text-[var(--color-success)]'
: 'bg-[var(--color-surface)] text-[var(--color-text-tertiary)] border border-[var(--color-border)]'
}`}
>
{h5Access.enabled ? t('settings.general.h5AccessStatusEnabled') : t('settings.general.h5AccessDisabledValue')}
</span>
</div>
<div className="mt-4 grid grid-cols-1 gap-3">
<Input
id="h5-access-public-url"
label={t('settings.general.h5AccessPublicUrl')}
value={h5PublicBaseUrlDraft}
placeholder={t('settings.general.h5AccessPublicUrlPlaceholder')}
onChange={(event) => setH5PublicBaseUrlDraft(event.target.value)}
/>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.general.h5AccessOpenHint')}
</p>
<Button
size="sm"
variant="secondary"
onClick={() => void handleH5SettingsSave()}
disabled={!h5AccessDirty || h5ActionRunning}
aria-label={t('settings.general.h5AccessSave')}
>
{t('settings.general.h5AccessSave')}
</Button>
</div>
</div>
{h5AccessUrl && (
<div className="mt-4 border-t border-[var(--color-border)]/60 pt-4">
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<div className="text-xs uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{t('settings.general.h5AccessUrl')}
</div>
<div className="mt-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] break-all">
{h5AccessUrl}
</div>
</div>
<Button
size="sm"
variant="secondary"
className="shrink-0"
icon={<Copy className="h-3.5 w-3.5" aria-hidden="true" />}
aria-label={t('settings.general.h5AccessCopyUrl')}
onClick={() => void handleH5UrlCopy()}
>
{t('settings.general.h5AccessCopy')}
</Button>
</div>
</div>
)}
{h5Access.enabled && h5AccessUrl && (
<div className="mt-4 border-t border-[var(--color-border)]/60 pt-4">
<div className="flex flex-col gap-4 sm:flex-row">
<div className="flex h-48 w-48 shrink-0 items-center justify-center rounded-lg border border-[var(--color-border)] bg-white p-3">
{h5QrDataUrl ? (
<img
src={h5QrDataUrl}
alt={t('settings.general.h5AccessQrAlt')}
className="h-full w-full"
/>
) : (
<div className="flex flex-col items-center gap-3 px-4 text-center">
<QrCode className="h-12 w-12 text-neutral-400" aria-hidden="true" />
<p className="text-xs leading-5 text-neutral-500">
{t('settings.general.h5AccessQrEmptyHint')}
</p>
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-medium uppercase text-[var(--color-text-tertiary)]">
{t('settings.general.h5AccessQrTitle')}
</div>
<p className="mt-1 text-xs leading-5 text-[var(--color-text-tertiary)]">
{h5GeneratedToken
? t('settings.general.h5AccessQrHint')
: t('settings.general.h5AccessQrRefreshHint')}
</p>
{h5LaunchUrl && (
<div className="mt-3 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] break-all">
{h5LaunchUrl}
</div>
)}
<div className="mt-3 flex flex-wrap gap-2">
<Button
size="sm"
variant="secondary"
icon={<Copy className="h-3.5 w-3.5" aria-hidden="true" />}
disabled={!h5LaunchUrl || !h5GeneratedToken}
onClick={() => void handleH5LaunchUrlCopy()}
>
{t('settings.general.h5AccessCopyLaunchUrl')}
</Button>
<Button
size="sm"
variant={h5GeneratedToken ? 'secondary' : 'primary'}
icon={<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />}
loading={h5ActionRunning}
onClick={() => void handleH5Regenerate()}
>
{h5GeneratedToken ? t('settings.general.h5AccessRegenerate') : t('settings.general.h5AccessGenerateToken')}
</Button>
</div>
</div>
</div>
</div>
)}
{h5Access.enabled && (
<div className="mt-4 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-medium uppercase text-[var(--color-text-tertiary)]">
{t('settings.general.h5AccessTokenPreview')}
</div>
<div className="mt-1 break-all text-sm text-[var(--color-text-primary)]">
{h5TokenVisible && h5GeneratedToken
? h5GeneratedToken
: h5Access.tokenPreview || t('settings.general.h5AccessTokenNotAvailable')}
</div>
</div>
<div className="flex shrink-0 flex-wrap justify-end gap-2">
<Button
size="sm"
variant="secondary"
icon={h5TokenVisible ? <EyeOff className="h-3.5 w-3.5" aria-hidden="true" /> : <Eye className="h-3.5 w-3.5" aria-hidden="true" />}
disabled={!h5GeneratedToken}
onClick={() => setH5TokenVisible((visible) => !visible)}
>
{h5TokenVisible ? t('settings.general.h5AccessHideToken') : t('settings.general.h5AccessShowToken')}
</Button>
<Button
size="sm"
variant="danger"
icon={<PowerOff className="h-3.5 w-3.5" aria-hidden="true" />}
loading={h5ActionRunning}
onClick={() => void handleH5Disable()}
>
{t('settings.general.h5AccessDisable')}
</Button>
</div>
</div>
</div>
)}
<p className="mt-4 text-xs text-[var(--color-text-tertiary)] leading-5">
{t('settings.general.h5AccessSafetyNote')}
</p>
{h5AccessError && (
<p className="mt-2 text-xs text-[var(--color-error)]">
{h5AccessError}
</p>
)}
</div>
</section>
<ConfirmDialog
open={h5EnableConfirmOpen}
onClose={() => {
if (!h5ActionRunning) setH5EnableConfirmOpen(false)
}}
onConfirm={handleH5EnableConfirm}
title={t('settings.general.h5AccessConfirmTitle')}
body={t('settings.general.h5AccessConfirmBody')}
confirmLabel={t('settings.general.h5AccessConfirmEnable')}
cancelLabel={t('common.cancel')}
confirmVariant="danger"
loading={h5ActionRunning}
/>
</div>
)
}
function SettingsCheckboxMark({ checked, disabled = false }: { checked: boolean; disabled?: boolean }) {
return (
<span
aria-hidden="true"
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition-all peer-focus-visible:ring-2 peer-focus-visible:ring-[var(--color-brand)]/40 ${
checked
? 'border-[var(--color-brand)] bg-[var(--color-brand)] text-white shadow-[var(--shadow-button-primary)]'
: 'border-[var(--color-border-focus)] bg-[var(--color-surface)] text-transparent'
} ${disabled ? 'opacity-50' : ''}`}
>
<span className="material-symbols-outlined text-[16px] leading-none" style={{ fontVariationSettings: "'FILL' 1" }}>
check
</span>
</span>
)
}
// ─── Agents Settings ──────────────────────────────────────
const AGENT_COLORS: Record<string, string> = {
red: '#ef4444',
orange: '#f97316',
yellow: '#eab308',
green: '#22c55e',
blue: '#3b82f6',
purple: '#a855f7',
pink: '#ec4899',
cyan: '#06b6d4',
}
const AGENT_SOURCE_ORDER: AgentSource[] = [
'userSettings',
'projectSettings',
'localSettings',
'policySettings',
'plugin',
'flagSettings',
'built-in',
]
function AgentsSettings() {
const {
activeAgents,
allAgents,
isLoading,
error,
selectedAgent,
selectedAgentReturnTab,
fetchAgents,
selectAgent,
} = useAgentStore()
const sessions = useSessionStore((s) => s.sessions)
const activeSessionId = useSessionStore((s) => s.activeSessionId)
const t = useTranslation()
const activeSession = sessions.find((s) => s.id === activeSessionId)
const currentWorkDir = activeSession?.workDir || undefined
useEffect(() => {
void fetchAgents(currentWorkDir)
}, [fetchAgents, currentWorkDir])
const groupedAgents = useMemo(() => {
const groups: Partial<Record<AgentSource, AgentDefinition[]>> = {}
for (const agent of allAgents) {
;(groups[agent.source] ??= []).push(agent)
}
return groups
}, [allAgents])
const sourceCount = AGENT_SOURCE_ORDER.filter((source) => (groupedAgents[source] ?? []).length > 0).length
const handleAgentBack = () => {
const returnTab = selectedAgentReturnTab
selectAgent(null)
if (returnTab === 'plugins') {
useUIStore.getState().setPendingSettingsTab('plugins')
}
}
if (selectedAgent) {
return (
<div className="w-full min-w-0">
<AgentDetailView agent={selectedAgent} onBack={handleAgentBack} />
</div>
)
}
return (
<div className="w-full min-w-0">
{isLoading && allAgents.length === 0 ? (
<div className="flex justify-center py-12">
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
</div>
) : error ? (
<div className="text-center py-12 px-4">
<span className="material-symbols-outlined text-[40px] text-[var(--color-error)] mb-3 block">error_outline</span>
<p className="text-sm text-[var(--color-error)] mb-2">{error}</p>
<button
onClick={() => void fetchAgents(currentWorkDir)}
className="text-xs text-[var(--color-text-accent)] hover:underline"
>
{t('common.retry')}
</button>
</div>
) : allAgents.length === 0 ? (
<div className="text-center py-12 px-4 rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<span className="material-symbols-outlined text-[40px] text-[var(--color-text-tertiary)] mb-3 block">smart_toy</span>
<p className="text-sm text-[var(--color-text-secondary)] mb-1">{t('settings.agents.empty')}</p>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.agents.emptyHint')}</p>
</div>
) : (
<div className="flex flex-col gap-6 min-w-0">
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
<div className="grid gap-4 px-5 py-5 min-w-0 xl:grid-cols-[minmax(0,1.6fr)_minmax(320px,1fr)] xl:items-end">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
{t('settings.agents.browserEyebrow')}
</div>
<div className="flex items-center gap-3 mb-2">
<span className="material-symbols-outlined text-[22px] text-[var(--color-brand)]">
smart_toy
</span>
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
{t('settings.agents.browserTitle')}
</h3>
</div>
<p className="text-sm leading-6 text-[var(--color-text-secondary)] max-w-3xl">
{t('settings.agents.description')}
</p>
</div>
<div className="grid grid-cols-2 gap-3 min-w-0 sm:grid-cols-3">
<SummaryCard
label={t('settings.agents.summary.totalAgents')}
value={String(allAgents.length)}
icon="smart_toy"
/>
<SummaryCard
label={t('settings.agents.summary.activeAgents')}
value={String(activeAgents.length)}
icon="bolt"
/>
<SummaryCard
label={t('settings.agents.summary.sources')}
value={String(sourceCount)}
icon="layers"
className="col-span-2 sm:col-span-1"
/>
</div>
</div>
</section>
<div className={`grid gap-4 ${sourceCount >= 2 ? 'xl:grid-cols-2' : ''}`}>
{AGENT_SOURCE_ORDER.map((source) => {
const group = groupedAgents[source]
if (!group?.length) return null
const sourceLabel = t(`settings.agents.source.${source}`)
return (
<section
key={source}
className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden min-w-0"
>
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className={`inline-flex h-7 w-7 items-center justify-center rounded-full ${getAgentSourceAccentClass(source)}`}>
<span className="material-symbols-outlined text-[16px]">
{getAgentSourceIcon(source)}
</span>
</span>
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
{sourceLabel}
</h4>
<span className="text-xs text-[var(--color-text-tertiary)]">
{group.length}
</span>
</div>
<p className="text-xs leading-5 text-[var(--color-text-tertiary)]">
{t('settings.agents.groupHint', {
source: sourceLabel,
count: String(group.length),
})}
</p>
</div>
</div>
<div className="flex flex-col p-2">
{group.map((agent) => (
<button
key={`${agent.source}-${agent.agentType}`}
onClick={() => selectAgent(agent, 'agents')}
className="group rounded-xl border border-transparent px-3 py-3 text-left transition-all hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)]"
>
<div className="flex items-start gap-3">
<span
className="mt-0.5 flex-shrink-0 inline-flex items-center justify-center"
style={{ color: getAgentDotColor(agent.color) }}
>
<span className="material-symbols-outlined text-[18px]">smart_toy</span>
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-bold text-[var(--color-text-primary)] break-all">
{agent.agentType}
</span>
{agent.modelDisplay && (
<MetaPill>{agent.modelDisplay}</MetaPill>
)}
<MetaPill>{sourceLabel}</MetaPill>
<MetaPill>
{agent.isActive
? t('settings.agents.status.active')
: t('settings.agents.status.available')}
</MetaPill>
{agent.overriddenBy && (
<MetaPill>
{t('settings.agents.overriddenBy', {
source: t(`settings.agents.source.${agent.overriddenBy}`),
})}
</MetaPill>
)}
</div>
<div className="mt-1 text-xs leading-5 text-[var(--color-text-secondary)] break-words [&_.prose]:text-xs [&_.prose]:leading-5 [&_.prose]:text-[var(--color-text-secondary)]">
<MarkdownRenderer
content={agent.description || t('settings.agents.noDescription')}
/>
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-[var(--color-text-tertiary)]">
<span>
{agent.tools?.length
? t('settings.agents.toolCount', { count: String(agent.tools.length) })
: t('settings.agents.noTools')}
</span>
{agent.baseDir && (
<span className="break-all">{agent.baseDir}</span>
)}
</div>
</div>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] opacity-60 transition-transform group-hover:translate-x-0.5 group-hover:opacity-100">
chevron_right
</span>
</div>
</button>
))}
</div>
</section>
)
})}
</div>
</div>
)}
</div>
)
}
function AgentDetailView({ agent, onBack }: { agent: AgentDefinition; onBack: () => void }) {
const t = useTranslation()
const sourceLabel = t(`settings.agents.source.${agent.source}`)
return (
<div className="flex h-full min-h-0 flex-col gap-4 min-w-0">
<div>
<button
onClick={onBack}
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-sm text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]"
>
<span className="material-symbols-outlined text-[16px]">arrow_back</span>
{t('settings.agents.backToList')}
</button>
</div>
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
<div className="grid gap-4 px-5 py-5 lg:grid-cols-[minmax(0,1.5fr)_minmax(280px,0.9fr)] lg:items-start">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
{t('settings.agents.entryEyebrow')}
</div>
<div className="flex flex-wrap items-center gap-2 mb-2">
<span
className="h-3 w-3 rounded-full flex-shrink-0"
style={{ backgroundColor: getAgentDotColor(agent.color) }}
/>
<h3 className="text-[22px] font-semibold leading-tight text-[var(--color-text-primary)] break-all">
{agent.agentType}
</h3>
<MetaPill>{sourceLabel}</MetaPill>
{agent.modelDisplay && <MetaPill>{agent.modelDisplay}</MetaPill>}
<MetaPill>
{agent.isActive
? t('settings.agents.status.active')
: t('settings.agents.status.available')}
</MetaPill>
{agent.overriddenBy && (
<MetaPill>
{t('settings.agents.overriddenByShort', {
source: t(`settings.agents.source.${agent.overriddenBy}`),
})}
</MetaPill>
)}
</div>
<div className="max-w-4xl text-sm leading-6 text-[var(--color-text-secondary)]">
<MarkdownRenderer
content={agent.description || t('settings.agents.noDescription')}
/>
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2 text-xs text-[var(--color-text-tertiary)]">
<span>
{agent.tools?.length
? t('settings.agents.toolCount', { count: String(agent.tools.length) })
: t('settings.agents.noTools')}
</span>
{agent.baseDir && <span className="break-all">{agent.baseDir}</span>}
</div>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-2">
<DetailStat
label={t('settings.agents.summary.source')}
value={sourceLabel}
icon="layers"
/>
<DetailStat
label={t('settings.agents.summary.model')}
value={agent.modelDisplay || '—'}
icon="psychology"
/>
<DetailStat
label={t('settings.agents.summary.tools')}
value={String(agent.tools?.length ?? 0)}
icon="build"
/>
<DetailStat
label={t('settings.agents.summary.status')}
value={agent.isActive ? t('settings.agents.status.active') : t('settings.agents.status.available')}
icon="bolt"
/>
</div>
</div>
</section>
{agent.tools && agent.tools.length > 0 && (
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-4">
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">
build
</span>
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('settings.agents.tools')}
</h4>
</div>
<div className="flex flex-wrap gap-2">
{agent.tools.map((tool) => (
<MetaPill key={tool}>{tool}</MetaPill>
))}
</div>
</section>
)}
<section className="flex flex-1 min-h-0 min-w-0 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-mono text-[var(--color-text-secondary)] break-all">
{agent.baseDir || sourceLabel}
</span>
</div>
<div className="mt-1 text-[11px] text-[var(--color-text-tertiary)]">
{t('settings.agents.promptHint')}
</div>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-[var(--color-surface)] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)] border border-[var(--color-border)]">
{t('settings.agents.systemPrompt')}
</span>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto bg-[var(--color-surface-container-lowest)]">
{agent.systemPrompt ? (
<div className="px-6 py-5 lg:px-8">
<MarkdownRenderer
content={agent.systemPrompt}
variant="document"
className="mx-auto max-w-[72ch]"
/>
</div>
) : (
<div className="px-6 py-10 text-center">
<span className="material-symbols-outlined text-[32px] text-[var(--color-text-tertiary)] mb-2 block">
article
</span>
<p className="text-sm text-[var(--color-text-tertiary)]">
{t('settings.agents.noSystemPrompt')}
</p>
</div>
)}
</div>
</div>
</section>
</div>
)
}
function getAgentDotColor(color?: string) {
return color && AGENT_COLORS[color] ? AGENT_COLORS[color] : 'var(--color-text-tertiary)'
}
function getAgentSourceIcon(source: AgentSource) {
switch (source) {
case 'userSettings':
return 'person'
case 'projectSettings':
return 'folder'
case 'localSettings':
return 'folder_lock'
case 'policySettings':
return 'shield'
case 'plugin':
return 'extension'
case 'flagSettings':
return 'terminal'
case 'built-in':
return 'inventory_2'
}
}
function getAgentSourceAccentClass(source: AgentSource) {
switch (source) {
case 'userSettings':
return 'bg-[var(--color-primary-fixed)] text-[var(--color-brand)]'
case 'projectSettings':
return 'bg-[var(--color-success-container)] text-[var(--color-success)]'
case 'localSettings':
return 'bg-[var(--color-info-container)] text-[var(--color-info)]'
case 'policySettings':
return 'bg-[var(--color-warning-container)] text-[var(--color-warning)]'
case 'plugin':
return 'bg-[var(--color-warning-container)] text-[var(--color-warning)]'
case 'flagSettings':
return 'bg-[var(--color-error)]/10 text-[var(--color-error)]'
case 'built-in':
return 'bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]'
}
}
function MetaPill({ children }: { children: ReactNode }) {
return (
<span className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
{children}
</span>
)
}
function SummaryCard({
label,
value,
icon,
className = '',
}: {
label: string
value: string
icon: string
className?: string
}) {
return (
<div className={`rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 min-w-0 ${className}`}>
<div className="flex items-center gap-1.5 text-[11px] uppercase tracking-[0.12em] text-[var(--color-text-tertiary)] min-w-0">
<span className="material-symbols-outlined text-[14px] flex-shrink-0">{icon}</span>
<span className="truncate">{label}</span>
</div>
<div className="mt-2 text-lg font-semibold text-[var(--color-text-primary)] truncate">
{value}
</div>
</div>
)
}
function DetailStat({
label,
value,
icon,
}: {
label: string
value: string
icon: string
}) {
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3">
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined text-[14px]">{icon}</span>
<span>{label}</span>
</div>
<div className="mt-2 text-base font-semibold text-[var(--color-text-primary)] break-all">
{value}
</div>
</div>
)
}
// ─── Skill Settings ──────────────────────────────────────
function SkillSettings() {
const selectedSkill = useSkillStore((s) => s.selectedSkill)
const t = useTranslation()
if (selectedSkill) {
return (
<div className="w-full min-w-0">
<SkillDetail />
</div>
)
}
return (
<div className="w-full min-w-0">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">
{t('settings.skills.title')}
</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">
{t('settings.skills.description')}
</p>
<SkillList />
</div>
)
}
function PluginSettings() {
const selectedPlugin = usePluginStore((s) => s.selectedPlugin)
const t = useTranslation()
if (selectedPlugin) {
return (
<div className="w-full min-w-0">
<PluginDetail />
</div>
)
}
return (
<div className="w-full min-w-0">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">
{t('settings.plugins.title')}
</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">
{t('settings.plugins.description')}
</p>
<PluginList />
</div>
)
}
// ─── About Settings ──────────────────────────────────────
const GITHUB_REPO = 'https://github.com/NanmiCoder/cc-haha'
const GITHUB_ISSUES = `${GITHUB_REPO}/issues`
const GITHUB_RELEASES = `${GITHUB_REPO}/releases`
const AUTHOR_GITHUB = 'https://github.com/NanmiCoder'
const SOCIAL_LINKS = [
{ name: 'Bilibili', icon: '/icons/bilibili.svg', url: 'https://space.bilibili.com/434377496', label: '程序员阿江-Relakkes' },
{ name: 'Douyin', icon: '/icons/douyin.svg', url: 'https://www.douyin.com/user/MS4wLjABAAAATJPY7LAlaa5X-c8uNdWkvz0jUGgpw4eeXIwu_8BhvqE', label: '程序员阿江-Relakkes' },
{ name: 'Xiaohongshu', icon: '/icons/xiaohongshu.svg', url: 'https://www.xiaohongshu.com/user/profile/5f58bd990000000001003753', label: '程序员阿江-Relakkes' },
] as const
function AboutSettings() {
const t = useTranslation()
const [version, setVersion] = useState('')
const updateStatus = useUpdateStore((s) => s.status)
const availableVersion = useUpdateStore((s) => s.availableVersion)
const releaseNotes = useUpdateStore((s) => s.releaseNotes)
const progressPercent = useUpdateStore((s) => s.progressPercent)
const downloadedBytes = useUpdateStore((s) => s.downloadedBytes)
const totalBytes = useUpdateStore((s) => s.totalBytes)
const error = useUpdateStore((s) => s.error)
const checkedAt = useUpdateStore((s) => s.checkedAt)
const checkForUpdates = useUpdateStore((s) => s.checkForUpdates)
const installUpdate = useUpdateStore((s) => s.installUpdate)
const initialize = useUpdateStore((s) => s.initialize)
useEffect(() => {
let cancelled = false
import('@tauri-apps/api/app')
.then((mod) => mod.getVersion())
.then((value) => {
if (!cancelled) setVersion(value)
})
.catch(() => {
if (!cancelled) setVersion('0.1.0')
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
void initialize()
}, [initialize])
const openUrl = (url: string) => {
import('@tauri-apps/plugin-shell').then((mod) => mod.open(url)).catch(() => window.open(url, '_blank'))
}
const checkedAtText =
checkedAt
? new Date(checkedAt).toLocaleString(undefined, {
hour: '2-digit',
minute: '2-digit',
month: 'short',
day: 'numeric',
})
: null
const hasKnownProgress = typeof totalBytes === 'number' && totalBytes > 0
const downloadedText = formatBytes(downloadedBytes)
const updateDescription =
updateStatus === 'checking'
? t('update.checking')
: updateStatus === 'downloading'
? hasKnownProgress
? t('update.progress', { progress: String(progressPercent) })
: t('update.progressBytes', { downloaded: downloadedText })
: updateStatus === 'restarting'
? t('update.restarting')
: updateStatus === 'available' && availableVersion
? t('update.newVersion', { version: availableVersion })
: updateStatus === 'up-to-date'
? t('update.upToDate', { version: version || t('update.currentVersionUnknown') })
: error
? t('update.failed', { error })
: t('update.idle')
return (
<div className="w-full min-w-0 max-w-lg mx-auto flex flex-col items-center py-6">
{/* Logo + App Name + Version */}
<img src="/app-icon.png" alt="Claude Code Haha" className="w-20 h-20 mb-4" />
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">Claude Code Haha</h1>
{version && (
<div className="mt-1 flex items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
<span>{t('settings.about.version')} {version}</span>
<span className="text-[var(--color-border)]">·</span>
<button
onClick={() => openUrl(GITHUB_RELEASES)}
className="rounded-[var(--radius-sm)] text-[var(--color-text-accent)] transition-colors hover:text-[var(--color-brand)] focus:outline-none focus:shadow-[var(--shadow-focus-ring)]"
>
{t('settings.about.changelog')}
</button>
</div>
)}
{/* GitHub Repo */}
<div className="mt-6 w-full">
<button
onClick={() => openUrl(GITHUB_REPO)}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl border border-[var(--color-border)] hover:bg-[var(--color-surface-hover)] transition-colors cursor-pointer"
>
<img src="/icons/github.svg" alt="GitHub" className="w-5 h-5 opacity-70" />
<div className="flex-1 text-left">
<div className="text-sm font-medium text-[var(--color-text-primary)]">NanmiCoder/cc-haha</div>
<div className="text-xs text-[var(--color-text-tertiary)]">{t('settings.about.starHint')}</div>
</div>
</button>
</div>
<div className="mt-4 w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-medium text-[var(--color-text-primary)]">{t('settings.about.updates')}</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-1">
{t('settings.about.updatesDesc')}
</div>
</div>
<Button
size="sm"
variant="secondary"
onClick={() => void checkForUpdates()}
loading={updateStatus === 'checking'}
>
{t('update.checkNow')}
</Button>
</div>
<div className="mt-4 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-tertiary)]">
{t('settings.about.version')}
</div>
<div className="text-sm font-medium text-[var(--color-text-primary)] mt-1">
{version || t('update.currentVersionUnknown')}
</div>
</div>
{availableVersion && (
<div className="text-right">
<div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-tertiary)]">
{t('update.availableLabel')}
</div>
<div className="text-sm font-medium text-[var(--color-text-primary)] mt-1">
{availableVersion}
</div>
</div>
)}
</div>
<p className={`mt-3 text-sm ${error ? 'text-[var(--color-error)]' : 'text-[var(--color-text-secondary)]'}`}>
{updateDescription}
</p>
{checkedAtText && (
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">
{t('update.checkedAt', { time: checkedAtText })}
</p>
)}
{(updateStatus === 'downloading' || updateStatus === 'restarting') && (
<div className="mt-3">
<div className="h-1.5 bg-[var(--color-surface-container-low)] rounded-full overflow-hidden">
{hasKnownProgress || updateStatus === 'restarting' ? (
<div
className="h-full bg-[var(--color-text-accent)] transition-all duration-300"
style={{ width: `${Math.min(progressPercent, 100)}%` }}
/>
) : (
<div className="h-full w-1/3 rounded-full bg-[var(--color-text-accent)]/75 animate-pulse" />
)}
</div>
{!hasKnownProgress && updateStatus === 'downloading' && downloadedBytes > 0 && (
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">
{downloadedText}
</p>
)}
</div>
)}
{releaseNotes && availableVersion && (
<div className="mt-3 rounded-lg bg-[var(--color-surface-container-low)] px-3 py-3">
<div className="text-[11px] uppercase tracking-[0.14em] text-[var(--color-text-tertiary)]">
{t('update.releaseNotes')}
</div>
<MarkdownRenderer
content={releaseNotes}
variant="document"
className="mt-2 text-[13px] leading-6 text-[var(--color-text-secondary)] [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_p]:text-[13px] [&_p]:leading-6"
/>
</div>
)}
{availableVersion && (
<div className="mt-3 flex justify-end">
<Button
size="sm"
onClick={() => void installUpdate()}
loading={updateStatus === 'downloading' || updateStatus === 'restarting'}
disabled={updateStatus === 'checking'}
>
{updateStatus === 'restarting' ? t('update.restarting') : t('update.now')}
</Button>
</div>
)}
</div>
</div>
{/* Divider */}
<div className="w-full border-t border-[var(--color-border)]/40 my-6" />
{/* Author */}
<div className="w-full">
<h3 className="text-xs font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider mb-3">{t('settings.about.author')}</h3>
<button
onClick={() => openUrl(AUTHOR_GITHUB)}
className="w-full flex items-center gap-3 px-4 py-2.5 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors cursor-pointer"
>
<img src="/icons/github.svg" alt="GitHub" className="w-4 h-4 opacity-60" />
<span className="text-sm text-[var(--color-text-primary)]">-Relakkes</span>
<span className="text-xs text-[var(--color-text-tertiary)] ml-auto">GitHub</span>
</button>
</div>
{/* Social Media */}
<div className="w-full mt-4">
<h3 className="text-xs font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider mb-3">{t('settings.about.socialMedia')}</h3>
<div className="flex flex-col gap-0.5">
{SOCIAL_LINKS.map((link) => (
<button
key={link.name}
onClick={() => openUrl(link.url)}
className="w-full flex items-center gap-3 px-4 py-2.5 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors cursor-pointer"
>
<img src={link.icon} alt={link.name} className="w-4 h-4 opacity-60" />
<span className="text-sm text-[var(--color-text-primary)]">{link.label}</span>
<span className="text-xs text-[var(--color-text-tertiary)] ml-auto">{link.name}</span>
</button>
))}
</div>
</div>
<div className="mt-6 w-full">
<button
onClick={() => openUrl(GITHUB_ISSUES)}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl border border-[var(--color-border)] hover:bg-[var(--color-surface-hover)] transition-colors cursor-pointer"
>
<span className="material-symbols-outlined text-[20px] text-[var(--color-text-tertiary)]">feedback</span>
<div className="flex-1 text-left">
<div className="text-sm font-medium text-[var(--color-text-primary)]">{t('settings.about.feedback')}</div>
<div className="text-xs text-[var(--color-text-tertiary)]">{t('settings.about.feedbackDesc')}</div>
</div>
</button>
</div>
</div>
)
}