bingocode 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/bin/bingo-win.cjs +34 -3
  2. package/desktop/README.md +30 -0
  3. package/desktop/bunfig.toml +1 -0
  4. package/desktop/index.html +17 -0
  5. package/desktop/package.json +55 -0
  6. package/desktop/pnpm-lock.yaml +3832 -0
  7. package/desktop/public/app-icon.jpg +0 -0
  8. package/desktop/public/fonts/inter-latin-ext.woff2 +0 -0
  9. package/desktop/public/fonts/inter-latin.woff2 +0 -0
  10. package/desktop/public/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
  11. package/desktop/public/fonts/jetbrains-mono-latin.woff2 +0 -0
  12. package/desktop/public/fonts/manrope-latin-ext.woff2 +0 -0
  13. package/desktop/public/fonts/manrope-latin.woff2 +0 -0
  14. package/desktop/public/fonts/material-symbols-outlined.woff2 +0 -0
  15. package/desktop/public/icons/bilibili.svg +1 -0
  16. package/desktop/public/icons/douyin.svg +1 -0
  17. package/desktop/public/icons/github.svg +3 -0
  18. package/desktop/public/icons/xiaohongshu.svg +1 -0
  19. package/desktop/scripts/build-macos-arm64.sh +270 -0
  20. package/desktop/scripts/build-sidecars.ts +183 -0
  21. package/desktop/scripts/build-windows-x64.ps1 +295 -0
  22. package/desktop/scripts/scan-missing-imports.ts +235 -0
  23. package/desktop/sidecars/claude-sidecar.ts +156 -0
  24. package/desktop/src/App.tsx +5 -0
  25. package/desktop/src/__tests__/agentsSettings.test.tsx +349 -0
  26. package/desktop/src/__tests__/pages.test.tsx +290 -0
  27. package/desktop/src/__tests__/skillsSettings.test.tsx +205 -0
  28. package/desktop/src/api/adapters.ts +12 -0
  29. package/desktop/src/api/agents.ts +36 -0
  30. package/desktop/src/api/cliTasks.ts +28 -0
  31. package/desktop/src/api/client.ts +63 -0
  32. package/desktop/src/api/computerUse.ts +76 -0
  33. package/desktop/src/api/filesystem.ts +30 -0
  34. package/desktop/src/api/hahaOAuth.ts +38 -0
  35. package/desktop/src/api/models.ts +28 -0
  36. package/desktop/src/api/providers.ts +63 -0
  37. package/desktop/src/api/search.ts +29 -0
  38. package/desktop/src/api/sessions.ts +56 -0
  39. package/desktop/src/api/settings.ts +20 -0
  40. package/desktop/src/api/skills.ts +19 -0
  41. package/desktop/src/api/tasks.ts +36 -0
  42. package/desktop/src/api/teams.ts +44 -0
  43. package/desktop/src/api/websocket.ts +164 -0
  44. package/desktop/src/components/chat/AskUserQuestion.tsx +268 -0
  45. package/desktop/src/components/chat/AssistantMessage.tsx +29 -0
  46. package/desktop/src/components/chat/AttachmentGallery.tsx +113 -0
  47. package/desktop/src/components/chat/ChatInput.tsx +622 -0
  48. package/desktop/src/components/chat/CodeViewer.tsx +161 -0
  49. package/desktop/src/components/chat/ComputerUsePermissionModal.test.tsx +174 -0
  50. package/desktop/src/components/chat/ComputerUsePermissionModal.tsx +311 -0
  51. package/desktop/src/components/chat/DiffViewer.tsx +157 -0
  52. package/desktop/src/components/chat/FileSearchMenu.tsx +198 -0
  53. package/desktop/src/components/chat/ImageGalleryModal.tsx +91 -0
  54. package/desktop/src/components/chat/InlineImageGallery.tsx +106 -0
  55. package/desktop/src/components/chat/InlineTaskSummary.tsx +60 -0
  56. package/desktop/src/components/chat/MermaidRenderer.test.tsx +98 -0
  57. package/desktop/src/components/chat/MermaidRenderer.tsx +361 -0
  58. package/desktop/src/components/chat/MessageActionBar.tsx +27 -0
  59. package/desktop/src/components/chat/MessageList.test.tsx +313 -0
  60. package/desktop/src/components/chat/MessageList.tsx +249 -0
  61. package/desktop/src/components/chat/PermissionDialog.tsx +262 -0
  62. package/desktop/src/components/chat/SessionTaskBar.test.tsx +99 -0
  63. package/desktop/src/components/chat/SessionTaskBar.tsx +159 -0
  64. package/desktop/src/components/chat/StreamingIndicator.tsx +41 -0
  65. package/desktop/src/components/chat/TerminalChrome.tsx +35 -0
  66. package/desktop/src/components/chat/ThinkingBlock.tsx +87 -0
  67. package/desktop/src/components/chat/ToolCallBlock.tsx +247 -0
  68. package/desktop/src/components/chat/ToolCallGroup.tsx +617 -0
  69. package/desktop/src/components/chat/ToolResultBlock.tsx +107 -0
  70. package/desktop/src/components/chat/UserMessage.tsx +38 -0
  71. package/desktop/src/components/chat/chatBlocks.test.tsx +136 -0
  72. package/desktop/src/components/chat/clipboard.ts +25 -0
  73. package/desktop/src/components/chat/composerUtils.test.ts +55 -0
  74. package/desktop/src/components/chat/composerUtils.ts +149 -0
  75. package/desktop/src/components/controls/ModelSelector.tsx +156 -0
  76. package/desktop/src/components/controls/PermissionModeSelector.tsx +229 -0
  77. package/desktop/src/components/layout/AppShell.tsx +107 -0
  78. package/desktop/src/components/layout/ContentRouter.tsx +27 -0
  79. package/desktop/src/components/layout/ProjectFilter.tsx +126 -0
  80. package/desktop/src/components/layout/Sidebar.test.tsx +158 -0
  81. package/desktop/src/components/layout/Sidebar.tsx +384 -0
  82. package/desktop/src/components/layout/StatusBar.tsx +31 -0
  83. package/desktop/src/components/layout/TabBar.test.tsx +136 -0
  84. package/desktop/src/components/layout/TabBar.tsx +318 -0
  85. package/desktop/src/components/layout/TitleBar.tsx +96 -0
  86. package/desktop/src/components/layout/WindowControls.test.tsx +69 -0
  87. package/desktop/src/components/layout/WindowControls.tsx +89 -0
  88. package/desktop/src/components/markdown/MarkdownRenderer.test.tsx +100 -0
  89. package/desktop/src/components/markdown/MarkdownRenderer.tsx +229 -0
  90. package/desktop/src/components/settings/ClaudeOfficialLogin.tsx +107 -0
  91. package/desktop/src/components/shared/Button.tsx +63 -0
  92. package/desktop/src/components/shared/CopyButton.tsx +58 -0
  93. package/desktop/src/components/shared/DirectoryPicker.tsx +316 -0
  94. package/desktop/src/components/shared/Dropdown.tsx +91 -0
  95. package/desktop/src/components/shared/Input.tsx +38 -0
  96. package/desktop/src/components/shared/Modal.tsx +65 -0
  97. package/desktop/src/components/shared/ProjectContextChip.tsx +30 -0
  98. package/desktop/src/components/shared/Spinner.tsx +30 -0
  99. package/desktop/src/components/shared/Textarea.tsx +38 -0
  100. package/desktop/src/components/shared/Toast.tsx +47 -0
  101. package/desktop/src/components/shared/UpdateChecker.tsx +90 -0
  102. package/desktop/src/components/skills/SkillDetail.test.tsx +89 -0
  103. package/desktop/src/components/skills/SkillDetail.tsx +403 -0
  104. package/desktop/src/components/skills/SkillList.tsx +254 -0
  105. package/desktop/src/components/tasks/DayOfWeekPicker.tsx +57 -0
  106. package/desktop/src/components/tasks/NewTaskModal.tsx +407 -0
  107. package/desktop/src/components/tasks/PromptEditor.tsx +74 -0
  108. package/desktop/src/components/tasks/TaskEmptyState.tsx +30 -0
  109. package/desktop/src/components/tasks/TaskList.tsx +46 -0
  110. package/desktop/src/components/tasks/TaskRow.tsx +253 -0
  111. package/desktop/src/components/tasks/TaskRunsPanel.tsx +195 -0
  112. package/desktop/src/components/teams/TeamStatusBar.tsx +147 -0
  113. package/desktop/src/config/providerPresets.ts +78 -0
  114. package/desktop/src/config/spinnerVerbs.ts +193 -0
  115. package/desktop/src/hooks/useKeyboardShortcuts.ts +60 -0
  116. package/desktop/src/i18n/index.ts +54 -0
  117. package/desktop/src/i18n/locales/en.ts +670 -0
  118. package/desktop/src/i18n/locales/zh.ts +670 -0
  119. package/desktop/src/lib/__tests__/cronDescribe.test.ts +93 -0
  120. package/desktop/src/lib/cronDescribe.ts +188 -0
  121. package/desktop/src/lib/desktopRuntime.ts +54 -0
  122. package/desktop/src/lib/parseRunOutput.ts +79 -0
  123. package/desktop/src/main.tsx +13 -0
  124. package/desktop/src/mocks/data.ts +202 -0
  125. package/desktop/src/pages/ActiveSession.test.tsx +181 -0
  126. package/desktop/src/pages/ActiveSession.tsx +219 -0
  127. package/desktop/src/pages/AdapterSettings.tsx +375 -0
  128. package/desktop/src/pages/AgentTeams.tsx +200 -0
  129. package/desktop/src/pages/ComputerUseSettings.tsx +420 -0
  130. package/desktop/src/pages/EmptySession.tsx +518 -0
  131. package/desktop/src/pages/NewTaskModal.tsx +346 -0
  132. package/desktop/src/pages/ScheduledTasks.tsx +66 -0
  133. package/desktop/src/pages/ScheduledTasksEmpty.tsx +152 -0
  134. package/desktop/src/pages/ScheduledTasksList.tsx +416 -0
  135. package/desktop/src/pages/SessionControls.tsx +460 -0
  136. package/desktop/src/pages/Settings.tsx +1448 -0
  137. package/desktop/src/pages/ToolInspection.tsx +235 -0
  138. package/desktop/src/stores/adapterStore.ts +106 -0
  139. package/desktop/src/stores/agentStore.ts +34 -0
  140. package/desktop/src/stores/chatStore.test.ts +505 -0
  141. package/desktop/src/stores/chatStore.ts +850 -0
  142. package/desktop/src/stores/cliTaskStore.ts +152 -0
  143. package/desktop/src/stores/hahaOAuthStore.test.ts +77 -0
  144. package/desktop/src/stores/hahaOAuthStore.ts +97 -0
  145. package/desktop/src/stores/providerStore.ts +101 -0
  146. package/desktop/src/stores/sessionStore.test.ts +63 -0
  147. package/desktop/src/stores/sessionStore.ts +102 -0
  148. package/desktop/src/stores/settingsStore.ts +120 -0
  149. package/desktop/src/stores/skillStore.ts +51 -0
  150. package/desktop/src/stores/tabStore.ts +169 -0
  151. package/desktop/src/stores/taskStore.ts +68 -0
  152. package/desktop/src/stores/teamStore.ts +344 -0
  153. package/desktop/src/stores/uiStore.ts +100 -0
  154. package/desktop/src/stores/updateStore.test.ts +71 -0
  155. package/desktop/src/stores/updateStore.ts +221 -0
  156. package/desktop/src/theme/globals.css +465 -0
  157. package/desktop/src/types/adapter.ts +33 -0
  158. package/desktop/src/types/chat.ts +152 -0
  159. package/desktop/src/types/cliTask.ts +24 -0
  160. package/desktop/src/types/provider.ts +62 -0
  161. package/desktop/src/types/session.ts +27 -0
  162. package/desktop/src/types/settings.ts +22 -0
  163. package/desktop/src/types/skill.ts +38 -0
  164. package/desktop/src/types/task.ts +56 -0
  165. package/desktop/src/types/team.ts +38 -0
  166. package/desktop/src-tauri/Cargo.lock +5549 -0
  167. package/desktop/src-tauri/Cargo.toml +20 -0
  168. package/desktop/src-tauri/app-icon.svg +13 -0
  169. package/desktop/src-tauri/build.rs +3 -0
  170. package/desktop/src-tauri/capabilities/default.json +106 -0
  171. package/desktop/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml +5 -0
  172. package/desktop/src-tauri/icons/android/values/ic_launcher_background.xml +4 -0
  173. package/desktop/src-tauri/icons/icon.icns +0 -0
  174. package/desktop/src-tauri/icons/icon.ico +0 -0
  175. package/desktop/src-tauri/src/lib.rs +408 -0
  176. package/desktop/src-tauri/src/main.rs +6 -0
  177. package/desktop/src-tauri/tauri.conf.json +78 -0
  178. package/desktop/src-tauri/tauri.macos.conf.json +18 -0
  179. package/desktop/src-tauri/tauri.release-ci.json +5 -0
  180. package/desktop/src-tauri/tauri.windows.conf.json +16 -0
  181. package/desktop/src-tauri/windows-installer-hooks.nsh +17 -0
  182. package/desktop/tsconfig.json +25 -0
  183. package/desktop/vite.config.ts +26 -0
  184. package/desktop/vitest.config.ts +18 -0
  185. package/package.json +1 -1
  186. package/src/commands/desktop/desktop.tsx +9 -0
  187. package/src/commands/desktop/index.ts +26 -0
@@ -0,0 +1,1448 @@
1
+ import { useState, useEffect, useMemo, useRef, type ReactNode } from 'react'
2
+ import { useSettingsStore } from '../stores/settingsStore'
3
+ import { useProviderStore } from '../stores/providerStore'
4
+ import { useTranslation } from '../i18n'
5
+ import { Modal } from '../components/shared/Modal'
6
+ import { Input } from '../components/shared/Input'
7
+ import { Button } from '../components/shared/Button'
8
+ import type { PermissionMode, EffortLevel, ThemeMode } from '../types/settings'
9
+ import type { Locale } from '../i18n'
10
+ import { PROVIDER_PRESETS } from '../config/providerPresets'
11
+ import type { ProviderPreset } from '../config/providerPresets'
12
+ import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat } from '../types/provider'
13
+ import { AdapterSettings } from './AdapterSettings'
14
+ import { useAgentStore } from '../stores/agentStore'
15
+ import { useSessionStore } from '../stores/sessionStore'
16
+ import type { AgentDefinition, AgentSource } from '../api/agents'
17
+ import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer'
18
+ import { useSkillStore } from '../stores/skillStore'
19
+ import { SkillList } from '../components/skills/SkillList'
20
+ import { SkillDetail } from '../components/skills/SkillDetail'
21
+ import { ComputerUseSettings } from './ComputerUseSettings'
22
+ import { useUIStore, type SettingsTab } from '../stores/uiStore'
23
+ import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
24
+ import { useUpdateStore } from '../stores/updateStore'
25
+
26
+ export function Settings() {
27
+ const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
28
+ const pendingSettingsTab = useUIStore((s) => s.pendingSettingsTab)
29
+ const t = useTranslation()
30
+
31
+ useEffect(() => {
32
+ if (!pendingSettingsTab) return
33
+ setActiveTab(pendingSettingsTab)
34
+ useUIStore.getState().setPendingSettingsTab(null)
35
+ }, [pendingSettingsTab])
36
+
37
+ return (
38
+ <div className="flex-1 flex flex-col overflow-hidden bg-[var(--color-surface)]">
39
+ <div className="flex-1 flex overflow-hidden">
40
+ {/* Tab navigation */}
41
+ <div className="w-[180px] border-r border-[var(--color-border)] py-3 flex-shrink-0 flex flex-col">
42
+ <div className="flex-1">
43
+ <TabButton icon="dns" label={t('settings.tab.providers')} active={activeTab === 'providers'} onClick={() => setActiveTab('providers')} />
44
+ <TabButton icon="shield" label={t('settings.tab.permissions')} active={activeTab === 'permissions'} onClick={() => setActiveTab('permissions')} />
45
+ <TabButton icon="tune" label={t('settings.tab.general')} active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
46
+ <TabButton icon="chat" label={t('settings.tab.adapters')} active={activeTab === 'adapters'} onClick={() => setActiveTab('adapters')} />
47
+ <TabButton icon="smart_toy" label={t('settings.tab.agents')} active={activeTab === 'agents'} onClick={() => setActiveTab('agents')} />
48
+ <TabButton icon="auto_awesome" label={t('settings.tab.skills')} active={activeTab === 'skills'} onClick={() => setActiveTab('skills')} />
49
+ <TabButton icon="mouse" label={t('settings.tab.computerUse')} active={activeTab === 'computerUse'} onClick={() => setActiveTab('computerUse')} />
50
+ </div>
51
+ <div className="border-t border-[var(--color-border)]/40 pt-1">
52
+ <TabButton icon="info" label={t('settings.tab.about')} active={activeTab === 'about'} onClick={() => setActiveTab('about')} />
53
+ </div>
54
+ </div>
55
+
56
+ {/* Tab content */}
57
+ <div className="flex-1 overflow-y-auto px-8 py-6">
58
+ {activeTab === 'providers' && <ProviderSettings />}
59
+ {activeTab === 'permissions' && <PermissionSettings />}
60
+ {activeTab === 'general' && <GeneralSettings />}
61
+ {activeTab === 'adapters' && <AdapterSettings />}
62
+ {activeTab === 'agents' && <AgentsSettings />}
63
+ {activeTab === 'skills' && <SkillSettings />}
64
+ {activeTab === 'computerUse' && <ComputerUseSettings />}
65
+ {activeTab === 'about' && <AboutSettings />}
66
+ </div>
67
+ </div>
68
+ </div>
69
+ )
70
+ }
71
+
72
+ function TabButton({ icon, label, active, onClick }: { icon: string; label: string; active: boolean; onClick: () => void }) {
73
+ return (
74
+ <button
75
+ onClick={onClick}
76
+ className={`w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-left transition-colors ${
77
+ active
78
+ ? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] font-medium'
79
+ : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
80
+ }`}
81
+ >
82
+ <span className="material-symbols-outlined text-[18px]">{icon}</span>
83
+ {label}
84
+ </button>
85
+ )
86
+ }
87
+
88
+ // ─── Provider Settings ──────────────────────────────────────
89
+
90
+ function ProviderSettings() {
91
+ const { providers, activeId, isLoading, fetchProviders, deleteProvider, activateProvider, activateOfficial, testProvider } = useProviderStore()
92
+ const fetchSettings = useSettingsStore((s) => s.fetchAll)
93
+ const t = useTranslation()
94
+ const [editingProvider, setEditingProvider] = useState<SavedProvider | null>(null)
95
+ const [showCreateModal, setShowCreateModal] = useState(false)
96
+ const [testResults, setTestResults] = useState<Record<string, { loading: boolean; result?: ProviderTestResult }>>({})
97
+
98
+ useEffect(() => { fetchProviders() }, [fetchProviders])
99
+
100
+ const handleDelete = async (provider: SavedProvider) => {
101
+ if (activeId === provider.id) return
102
+ if (!window.confirm(t('settings.providers.confirmDelete', { name: provider.name }))) return
103
+ await deleteProvider(provider.id).catch(console.error)
104
+ }
105
+
106
+ const handleTest = async (provider: SavedProvider) => {
107
+ setTestResults((r) => ({ ...r, [provider.id]: { loading: true } }))
108
+ try {
109
+ const result = await testProvider(provider.id)
110
+ setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result } }))
111
+ } catch {
112
+ setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result: { connectivity: { success: false, latencyMs: 0, error: t('settings.providers.requestFailed') } } } }))
113
+ }
114
+ }
115
+
116
+ const handleActivate = async (id: string) => {
117
+ await activateProvider(id)
118
+ await fetchSettings()
119
+ }
120
+
121
+ const handleActivateOfficial = async () => {
122
+ await activateOfficial()
123
+ await fetchSettings()
124
+ }
125
+
126
+ const isOfficialActive = activeId === null
127
+
128
+ return (
129
+ <div className="max-w-2xl">
130
+ <div className="flex items-center justify-between mb-4">
131
+ <div>
132
+ <h2 className="text-base font-semibold text-[var(--color-text-primary)]">{t('settings.providers.title')}</h2>
133
+ <p className="text-sm text-[var(--color-text-tertiary)] mt-0.5">{t('settings.providers.description')}</p>
134
+ </div>
135
+ <Button size="sm" onClick={() => setShowCreateModal(true)}>
136
+ <span className="material-symbols-outlined text-[16px]">add</span>
137
+ {t('settings.providers.addProvider')}
138
+ </Button>
139
+ </div>
140
+
141
+ {/* Official provider — always visible at top */}
142
+ <div
143
+ className={`relative flex flex-col rounded-xl border transition-all mb-2 ${
144
+ isOfficialActive
145
+ ? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
146
+ : 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] cursor-pointer'
147
+ }`}
148
+ >
149
+ <div
150
+ className="flex items-center gap-4 px-4 py-3.5"
151
+ onClick={() => !isOfficialActive && handleActivateOfficial()}
152
+ >
153
+ <span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isOfficialActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
154
+ <div className="flex-1 min-w-0">
155
+ <div className="flex items-center gap-2">
156
+ <span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.providers.officialName')}</span>
157
+ {isOfficialActive && (
158
+ <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('common.active')}</span>
159
+ )}
160
+ </div>
161
+ <div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{t('settings.providers.officialDesc')}</div>
162
+ </div>
163
+ </div>
164
+
165
+ {isOfficialActive && (
166
+ <div className="px-4 pb-4 pt-3 border-t border-[var(--color-border-separator)]">
167
+ <ClaudeOfficialLogin />
168
+ </div>
169
+ )}
170
+ </div>
171
+
172
+ {/* Saved providers */}
173
+ {isLoading && providers.length === 0 ? (
174
+ <div className="flex justify-center py-8">
175
+ <div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
176
+ </div>
177
+ ) : (
178
+ <div className="flex flex-col gap-2">
179
+ {providers.map((provider) => {
180
+ const isActive = activeId === provider.id
181
+ const test = testResults[provider.id]
182
+ const preset = PROVIDER_PRESETS.find((p) => p.id === provider.presetId)
183
+ return (
184
+ <div
185
+ key={provider.id}
186
+ className={`relative flex items-center gap-4 px-4 py-3.5 rounded-xl border transition-all group ${
187
+ isActive
188
+ ? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
189
+ : 'border-[var(--color-border)] hover:border-[var(--color-border-focus)]'
190
+ }`}
191
+ >
192
+ <span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
193
+ <div className="flex-1 min-w-0">
194
+ <div className="flex items-center gap-2">
195
+ <span className="text-sm font-semibold text-[var(--color-text-primary)] truncate">{provider.name}</span>
196
+ {preset && preset.id !== 'custom' && (
197
+ <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>
198
+ )}
199
+ {provider.apiFormat && provider.apiFormat !== 'anthropic' && (
200
+ <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">
201
+ {provider.apiFormat === 'openai_chat' ? 'OpenAI Chat' : 'OpenAI Responses'}
202
+ </span>
203
+ )}
204
+ {isActive && (
205
+ <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('common.active')}</span>
206
+ )}
207
+ </div>
208
+ <div className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">
209
+ {provider.baseUrl} &middot; {provider.models.main}
210
+ </div>
211
+ {test && !test.loading && test.result && (
212
+ <div className="text-xs mt-1 flex flex-col gap-0.5">
213
+ <span className={test.result.connectivity.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}>
214
+ {test.result.connectivity.success
215
+ ? t('settings.providers.connectivityOk', { latency: String(test.result.connectivity.latencyMs) })
216
+ : t('settings.providers.connectivityFailed', { error: test.result.connectivity.error || '' })}
217
+ </span>
218
+ {test.result.proxy && (
219
+ <span className={test.result.proxy.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}>
220
+ {test.result.proxy.success
221
+ ? t('settings.providers.proxyOk', { latency: String(test.result.proxy.latencyMs) })
222
+ : t('settings.providers.proxyFailed', { error: test.result.proxy.error || '' })}
223
+ </span>
224
+ )}
225
+ </div>
226
+ )}
227
+ </div>
228
+ <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
229
+ {!isActive && (
230
+ <Button variant="ghost" size="sm" onClick={() => handleActivate(provider.id)}>{t('settings.providers.activate')}</Button>
231
+ )}
232
+ <Button variant="ghost" size="sm" onClick={() => handleTest(provider)} loading={test?.loading}>{t('settings.providers.test')}</Button>
233
+ <Button variant="ghost" size="sm" onClick={() => setEditingProvider(provider)}>{t('settings.providers.edit')}</Button>
234
+ {!isActive && (
235
+ <Button variant="ghost" size="sm" onClick={() => handleDelete(provider)} className="text-[var(--color-error)] hover:text-[var(--color-error)]">{t('common.delete')}</Button>
236
+ )}
237
+ </div>
238
+ </div>
239
+ )
240
+ })}
241
+ </div>
242
+ )}
243
+
244
+ {/* Create Modal — conditionally rendered so state resets on close */}
245
+ {showCreateModal && (
246
+ <ProviderFormModal open={true} onClose={() => setShowCreateModal(false)} mode="create" />
247
+ )}
248
+
249
+ {/* Edit Modal */}
250
+ {editingProvider && (
251
+ <ProviderFormModal key={editingProvider.id} open={true} onClose={() => setEditingProvider(null)} mode="edit" provider={editingProvider} />
252
+ )}
253
+ </div>
254
+ )
255
+ }
256
+
257
+ // ─── Provider Form Modal ──────────────────────────────────────
258
+
259
+ type ProviderFormProps = {
260
+ open: boolean
261
+ onClose: () => void
262
+ mode: 'create' | 'edit'
263
+ provider?: SavedProvider
264
+ }
265
+
266
+ function requirePreset(preset: ProviderPreset | undefined): ProviderPreset {
267
+ if (!preset) {
268
+ throw new Error('Provider presets are not configured')
269
+ }
270
+ return preset
271
+ }
272
+
273
+ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) {
274
+ const { createProvider, updateProvider, testConfig } = useProviderStore()
275
+ const fetchSettings = useSettingsStore((s) => s.fetchAll)
276
+ const t = useTranslation()
277
+
278
+ const availablePresets = PROVIDER_PRESETS.filter((p) => p.id !== 'official')
279
+ const fallbackPreset = requirePreset(
280
+ availablePresets[availablePresets.length - 1] ?? PROVIDER_PRESETS[0],
281
+ )
282
+ const initialPreset = requirePreset(
283
+ provider
284
+ ? availablePresets.find((p) => p.id === provider.presetId) ?? fallbackPreset
285
+ : availablePresets[0] ?? fallbackPreset,
286
+ )
287
+
288
+ const [selectedPreset, setSelectedPreset] = useState<ProviderPreset>(initialPreset)
289
+ const [name, setName] = useState(provider?.name ?? initialPreset.name)
290
+ const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl)
291
+ const [apiFormat, setApiFormat] = useState<ApiFormat>(provider?.apiFormat ?? initialPreset.apiFormat ?? 'anthropic')
292
+ const [apiKey, setApiKey] = useState('')
293
+ const [notes, setNotes] = useState(provider?.notes ?? '')
294
+ const [models, setModels] = useState<ModelMapping>(provider?.models ?? { ...initialPreset.defaultModels })
295
+ const [isSubmitting, setIsSubmitting] = useState(false)
296
+ const [testResult, setTestResult] = useState<ProviderTestResult | null>(null)
297
+ const [isTesting, setIsTesting] = useState(false)
298
+ const [settingsJson, setSettingsJson] = useState('')
299
+ const [settingsJsonError, setSettingsJsonError] = useState<string | null>(null)
300
+ const jsonPastedRef = useRef(false)
301
+
302
+ // Load current settings.json and merge provider env vars
303
+ useEffect(() => {
304
+ // Skip if JSON was just populated by user paste
305
+ if (jsonPastedRef.current) {
306
+ jsonPastedRef.current = false
307
+ return
308
+ }
309
+ import('../api/settings').then(({ settingsApi }) => {
310
+ settingsApi.getUser().then((settings) => {
311
+ const needsProxy = apiFormat !== 'anthropic'
312
+ const merged = {
313
+ ...settings,
314
+ env: {
315
+ ...((settings.env as Record<string, string>) || {}),
316
+ ANTHROPIC_BASE_URL: needsProxy ? 'http://127.0.0.1:3456/proxy' : baseUrl,
317
+ ANTHROPIC_AUTH_TOKEN: needsProxy ? 'proxy-managed' : (apiKey || '(your API key)'),
318
+ ANTHROPIC_MODEL: models.main,
319
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
320
+ ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
321
+ ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
322
+ },
323
+ }
324
+ setSettingsJson(JSON.stringify(merged, null, 2))
325
+ }).catch(() => {
326
+ setSettingsJson(JSON.stringify({}, null, 2))
327
+ })
328
+ })
329
+ // eslint-disable-next-line react-hooks/exhaustive-deps
330
+ }, [selectedPreset.id])
331
+
332
+ const handlePresetChange = (preset: ProviderPreset) => {
333
+ setSelectedPreset(preset)
334
+ setName(preset.name)
335
+ setBaseUrl(preset.baseUrl)
336
+ setApiFormat(preset.apiFormat ?? 'anthropic')
337
+ setModels({ ...preset.defaultModels })
338
+ setTestResult(null)
339
+ }
340
+
341
+ const isCustom = selectedPreset.id === 'custom'
342
+ const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || apiKey.trim()) && models.main.trim() && !settingsJsonError
343
+
344
+ const handleSubmit = async () => {
345
+ if (!canSubmit) return
346
+ setIsSubmitting(true)
347
+ try {
348
+ // Write the edited settings.json first (for all presets including official)
349
+ if (settingsJson.trim()) {
350
+ try {
351
+ const parsed = JSON.parse(settingsJson)
352
+ const { settingsApi } = await import('../api/settings')
353
+ await settingsApi.updateUser(parsed)
354
+ } catch {
355
+ // JSON validation already prevents this
356
+ }
357
+ }
358
+
359
+ if (mode === 'create') {
360
+ await createProvider({
361
+ presetId: selectedPreset.id,
362
+ name: name.trim(),
363
+ apiKey: apiKey.trim(),
364
+ baseUrl: baseUrl.trim(),
365
+ apiFormat,
366
+ models,
367
+ notes: notes.trim() || undefined,
368
+ })
369
+ } else if (provider) {
370
+ const input: UpdateProviderInput = {
371
+ name: name.trim(),
372
+ baseUrl: baseUrl.trim(),
373
+ apiFormat,
374
+ models,
375
+ notes: notes.trim() || undefined,
376
+ }
377
+ if (apiKey.trim()) input.apiKey = apiKey.trim()
378
+ await updateProvider(provider.id, input)
379
+ }
380
+ await fetchSettings()
381
+ onClose()
382
+ } catch (err) {
383
+ console.error('Failed to save provider:', err)
384
+ } finally {
385
+ setIsSubmitting(false)
386
+ }
387
+ }
388
+
389
+ const handleTest = async () => {
390
+ if (!baseUrl.trim() || !models.main.trim()) return
391
+ setIsTesting(true)
392
+ setTestResult(null)
393
+ try {
394
+ let result: ProviderTestResult
395
+ if (mode === 'edit' && provider && !apiKey.trim()) {
396
+ result = await useProviderStore.getState().testProvider(provider.id, {
397
+ baseUrl: baseUrl.trim(),
398
+ modelId: models.main.trim(),
399
+ apiFormat,
400
+ })
401
+ } else {
402
+ if (!apiKey.trim()) return
403
+ result = await testConfig({ baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), modelId: models.main.trim(), apiFormat })
404
+ }
405
+ setTestResult(result)
406
+ } catch {
407
+ setTestResult({ connectivity: { success: false, latencyMs: 0, error: t('settings.providers.requestFailed') } })
408
+ } finally {
409
+ setIsTesting(false)
410
+ }
411
+ }
412
+
413
+ return (
414
+ <Modal
415
+ open={open}
416
+ onClose={onClose}
417
+ title={mode === 'create' ? t('settings.providers.addTitle') : t('settings.providers.editTitle')}
418
+ width={720}
419
+ footer={
420
+ <>
421
+ <Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
422
+ <Button onClick={handleSubmit} disabled={!canSubmit} loading={isSubmitting}>
423
+ {mode === 'create' ? t('common.add') : t('common.save')}
424
+ </Button>
425
+ </>
426
+ }
427
+ >
428
+ <div className="flex flex-col gap-4">
429
+ {/* Preset chips */}
430
+ {mode === 'create' && (
431
+ <div>
432
+ <label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.preset')}</label>
433
+ <div className="flex flex-wrap gap-2">
434
+ {availablePresets.map((preset) => (
435
+ <button
436
+ key={preset.id}
437
+ onClick={() => handlePresetChange(preset)}
438
+ className={`px-3 py-1.5 text-xs font-medium rounded-full border transition-all ${
439
+ selectedPreset.id === preset.id
440
+ ? 'border-[var(--color-brand)] bg-[var(--color-surface-container-high)] text-[var(--color-brand)] shadow-[var(--shadow-focus-ring)]'
441
+ : 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]'
442
+ }`}
443
+ >
444
+ {preset.name}
445
+ </button>
446
+ ))}
447
+ </div>
448
+ </div>
449
+ )}
450
+
451
+ <Input label={t('settings.providers.name')} required value={name} onChange={(e) => setName(e.target.value)} placeholder={t('settings.providers.namePlaceholder')} />
452
+
453
+ <Input label={t('settings.providers.notes')} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder={t('settings.providers.notesPlaceholder')} />
454
+
455
+ {/* Base URL */}
456
+ {isCustom || mode === 'edit' ? (
457
+ <Input label={t('settings.providers.baseUrl')} required value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder={t('settings.providers.baseUrlPlaceholder')} />
458
+ ) : (
459
+ <div>
460
+ <label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.baseUrl')}</label>
461
+ <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)]">
462
+ {baseUrl}
463
+ </div>
464
+ </div>
465
+ )}
466
+
467
+ {/* API Format */}
468
+ {(isCustom || mode === 'edit') ? (
469
+ <div>
470
+ <label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.apiFormat')}</label>
471
+ <select
472
+ value={apiFormat}
473
+ onChange={(e) => setApiFormat(e.target.value as ApiFormat)}
474
+ className="w-full text-sm px-3 py-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container-low)] border border-[var(--color-border)] text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)]"
475
+ >
476
+ <option value="anthropic">{t('settings.providers.apiFormatAnthropic')}</option>
477
+ <option value="openai_chat">{t('settings.providers.apiFormatOpenaiChat')}</option>
478
+ <option value="openai_responses">{t('settings.providers.apiFormatOpenaiResponses')}</option>
479
+ </select>
480
+ {apiFormat !== 'anthropic' && (
481
+ <p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">{t('settings.providers.proxyHint')}</p>
482
+ )}
483
+ </div>
484
+ ) : apiFormat !== 'anthropic' ? (
485
+ <div>
486
+ <label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.apiFormat')}</label>
487
+ <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)]">
488
+ {apiFormat === 'openai_chat' ? t('settings.providers.apiFormatOpenaiChat') : t('settings.providers.apiFormatOpenaiResponses')}
489
+ </div>
490
+ </div>
491
+ ) : null}
492
+
493
+ <Input
494
+ label={mode === 'edit' ? t('settings.providers.apiKeyKeep') : t('settings.providers.apiKey')}
495
+ required={mode === 'create'}
496
+ type="password"
497
+ value={apiKey}
498
+ onChange={(e) => setApiKey(e.target.value)}
499
+ placeholder={mode === 'edit' ? '****' : 'sk-...'}
500
+ />
501
+
502
+ {/* Model Mapping */}
503
+ <div>
504
+ <label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.modelMapping')}</label>
505
+ <div className="grid grid-cols-2 gap-2">
506
+ <Input label={t('settings.providers.mainModel')} required value={models.main} onChange={(e) => setModels({ ...models, main: e.target.value })} placeholder="Model ID" />
507
+ <Input label={t('settings.providers.haikuModel')} value={models.haiku} onChange={(e) => setModels({ ...models, haiku: e.target.value })} placeholder={t('settings.providers.sameAsMain')} />
508
+ <Input label={t('settings.providers.sonnetModel')} value={models.sonnet} onChange={(e) => setModels({ ...models, sonnet: e.target.value })} placeholder={t('settings.providers.sameAsMain')} />
509
+ <Input label={t('settings.providers.opusModel')} value={models.opus} onChange={(e) => setModels({ ...models, opus: e.target.value })} placeholder={t('settings.providers.sameAsMain')} />
510
+ </div>
511
+ </div>
512
+
513
+ {/* Test connection */}
514
+ <div className="flex items-center gap-3">
515
+ <Button variant="secondary" size="sm" onClick={handleTest} loading={isTesting} disabled={!baseUrl.trim() || !models.main.trim()}>
516
+ {t('settings.providers.testConnection')}
517
+ </Button>
518
+ {testResult && (
519
+ <div className="flex flex-col gap-0.5">
520
+ <span className={`text-xs ${testResult.connectivity.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
521
+ {testResult.connectivity.success
522
+ ? t('settings.providers.connectivityOk', { latency: String(testResult.connectivity.latencyMs) })
523
+ : t('settings.providers.connectivityFailed', { error: testResult.connectivity.error || '' })}
524
+ </span>
525
+ {testResult.proxy && (
526
+ <span className={`text-xs ${testResult.proxy.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
527
+ {testResult.proxy.success
528
+ ? t('settings.providers.proxyOk', { latency: String(testResult.proxy.latencyMs) })
529
+ : t('settings.providers.proxyFailed', { error: testResult.proxy.error || '' })}
530
+ </span>
531
+ )}
532
+ </div>
533
+ )}
534
+ </div>
535
+
536
+ {/* Settings JSON — editable, shown for all presets including official */}
537
+ <div>
538
+ <label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.settingsJson')}</label>
539
+ <textarea
540
+ value={settingsJson}
541
+ onChange={(e) => {
542
+ const raw = e.target.value
543
+ setSettingsJson(raw)
544
+ try {
545
+ const parsed = JSON.parse(raw)
546
+ setSettingsJsonError(null)
547
+ // Auto-fill form fields from parsed JSON env
548
+ const env = parsed.env as Record<string, string> | undefined
549
+ if (env) {
550
+ if (env.ANTHROPIC_BASE_URL) {
551
+ setBaseUrl(env.ANTHROPIC_BASE_URL)
552
+ // Auto-switch to matching preset or Custom
553
+ if (mode === 'create') {
554
+ const matchedPreset = availablePresets.find((p) => p.id !== 'custom' && p.baseUrl === env.ANTHROPIC_BASE_URL)
555
+ const targetPreset = requirePreset(
556
+ matchedPreset ?? availablePresets.find((p) => p.id === 'custom'),
557
+ )
558
+ if (targetPreset.id !== selectedPreset.id) {
559
+ jsonPastedRef.current = true
560
+ setSelectedPreset(targetPreset)
561
+ }
562
+ }
563
+ }
564
+ if (env.ANTHROPIC_AUTH_TOKEN && env.ANTHROPIC_AUTH_TOKEN !== '(your API key)') setApiKey(env.ANTHROPIC_AUTH_TOKEN)
565
+ const newModels: Partial<ModelMapping> = {}
566
+ if (env.ANTHROPIC_MODEL) newModels.main = env.ANTHROPIC_MODEL
567
+ if (env.ANTHROPIC_DEFAULT_HAIKU_MODEL) newModels.haiku = env.ANTHROPIC_DEFAULT_HAIKU_MODEL
568
+ if (env.ANTHROPIC_DEFAULT_SONNET_MODEL) newModels.sonnet = env.ANTHROPIC_DEFAULT_SONNET_MODEL
569
+ if (env.ANTHROPIC_DEFAULT_OPUS_MODEL) newModels.opus = env.ANTHROPIC_DEFAULT_OPUS_MODEL
570
+ if (Object.keys(newModels).length > 0) {
571
+ setModels((prev) => ({ ...prev, ...newModels }))
572
+ }
573
+ }
574
+ } catch (err) {
575
+ setSettingsJsonError(err instanceof Error ? err.message : 'Invalid JSON')
576
+ }
577
+ }}
578
+ rows={16}
579
+ spellCheck={false}
580
+ 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 ${
581
+ settingsJsonError
582
+ ? 'border-[var(--color-error)] focus:border-[var(--color-error)]'
583
+ : 'border-[var(--color-border)] focus:border-[var(--color-border-focus)]'
584
+ }`}
585
+ />
586
+ {settingsJsonError && (
587
+ <p className="text-[11px] text-[var(--color-error)] mt-1">{t('settings.providers.jsonError', { error: settingsJsonError })}</p>
588
+ )}
589
+ <p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">{t('settings.providers.settingsJsonDesc')}</p>
590
+ </div>
591
+ </div>
592
+ </Modal>
593
+ )
594
+ }
595
+
596
+
597
+ // ─── Permission Settings ──────────────────────────────────────
598
+
599
+ function PermissionSettings() {
600
+ const { permissionMode, setPermissionMode } = useSettingsStore()
601
+ const t = useTranslation()
602
+
603
+ const MODES: Array<{ mode: PermissionMode; icon: string; label: string; desc: string }> = [
604
+ { mode: 'default', icon: 'verified_user', label: t('settings.permissions.default'), desc: t('settings.permissions.defaultDesc') },
605
+ { mode: 'acceptEdits', icon: 'edit_note', label: t('settings.permissions.acceptEdits'), desc: t('settings.permissions.acceptEditsDesc') },
606
+ { mode: 'plan', icon: 'architecture', label: t('settings.permissions.plan'), desc: t('settings.permissions.planDesc') },
607
+ { mode: 'bypassPermissions', icon: 'bolt', label: t('settings.permissions.bypass'), desc: t('settings.permissions.bypassDesc') },
608
+ ]
609
+
610
+ return (
611
+ <div className="max-w-xl">
612
+ <h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.permissions.title')}</h2>
613
+ <p className="text-sm text-[var(--color-text-tertiary)] mb-4">{t('settings.permissions.description')}</p>
614
+
615
+ <div className="flex flex-col gap-2">
616
+ {MODES.map(({ mode, icon, label, desc }) => {
617
+ const isSelected = permissionMode === mode
618
+ return (
619
+ <button
620
+ key={mode}
621
+ onClick={() => setPermissionMode(mode)}
622
+ className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-all text-left ${
623
+ isSelected
624
+ ? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
625
+ : 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]'
626
+ }`}
627
+ >
628
+ <span className="material-symbols-outlined text-[20px] text-[var(--color-text-secondary)]">{icon}</span>
629
+ <div className="flex-1">
630
+ <div className="text-sm font-semibold text-[var(--color-text-primary)]">{label}</div>
631
+ <div className="text-xs text-[var(--color-text-tertiary)]">{desc}</div>
632
+ </div>
633
+ {isSelected && (
634
+ <span className="material-symbols-outlined text-[18px] text-[var(--color-brand)]" style={{ fontVariationSettings: "'FILL' 1" }}>
635
+ check_circle
636
+ </span>
637
+ )}
638
+ </button>
639
+ )
640
+ })}
641
+ </div>
642
+ </div>
643
+ )
644
+ }
645
+
646
+ // ─── General Settings ──────────────────────────────────────
647
+
648
+ function GeneralSettings() {
649
+ const { effortLevel, setEffort, locale, setLocale, theme, setTheme } = useSettingsStore()
650
+ const t = useTranslation()
651
+
652
+ const EFFORT_LABELS: Record<EffortLevel, string> = {
653
+ low: t('settings.general.effort.low'),
654
+ medium: t('settings.general.effort.medium'),
655
+ high: t('settings.general.effort.high'),
656
+ max: t('settings.general.effort.max'),
657
+ }
658
+
659
+ const LANGUAGES: Array<{ value: Locale; label: string }> = [
660
+ { value: 'en', label: 'English' },
661
+ { value: 'zh', label: '中文' },
662
+ ]
663
+
664
+ const THEMES: Array<{ value: ThemeMode; label: string }> = [
665
+ { value: 'light', label: t('settings.general.appearance.light') },
666
+ { value: 'dark', label: t('settings.general.appearance.dark') },
667
+ ]
668
+
669
+ return (
670
+ <div className="max-w-xl">
671
+ {/* Appearance selector */}
672
+ <h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.appearanceTitle')}</h2>
673
+ <p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.appearanceDescription')}</p>
674
+ <div className="flex gap-2 mb-8">
675
+ {THEMES.map(({ value, label }) => (
676
+ <button
677
+ key={value}
678
+ onClick={() => void setTheme(value)}
679
+ className={`flex-1 py-2 text-xs font-semibold rounded-lg border transition-all ${
680
+ theme === value
681
+ ? 'bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)] border-transparent shadow-[var(--shadow-button-primary)]'
682
+ : 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
683
+ }`}
684
+ >
685
+ {label}
686
+ </button>
687
+ ))}
688
+ </div>
689
+
690
+ {/* Language selector */}
691
+ <h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.languageTitle')}</h2>
692
+ <p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.languageDescription')}</p>
693
+ <div className="flex gap-2 mb-8">
694
+ {LANGUAGES.map(({ value, label }) => (
695
+ <button
696
+ key={value}
697
+ onClick={() => setLocale(value)}
698
+ className={`flex-1 py-2 text-xs font-semibold rounded-lg border transition-all ${
699
+ locale === value
700
+ ? 'bg-[var(--color-brand)] text-white border-[var(--color-brand)]'
701
+ : 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
702
+ }`}
703
+ >
704
+ {label}
705
+ </button>
706
+ ))}
707
+ </div>
708
+
709
+ {/* Effort Level */}
710
+ <h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.effortTitle')}</h2>
711
+ <p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.effortDescription')}</p>
712
+ <div className="flex gap-2">
713
+ {(['low', 'medium', 'high', 'max'] as EffortLevel[]).map((level) => (
714
+ <button
715
+ key={level}
716
+ onClick={() => setEffort(level)}
717
+ className={`flex-1 py-2 text-xs font-semibold rounded-lg border transition-all ${
718
+ effortLevel === level
719
+ ? 'bg-[var(--color-brand)] text-white border-[var(--color-brand)]'
720
+ : 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
721
+ }`}
722
+ >
723
+ {EFFORT_LABELS[level]}
724
+ </button>
725
+ ))}
726
+ </div>
727
+ </div>
728
+ )
729
+ }
730
+
731
+ // ─── Agents Settings ──────────────────────────────────────
732
+
733
+ const AGENT_COLORS: Record<string, string> = {
734
+ red: '#ef4444',
735
+ orange: '#f97316',
736
+ yellow: '#eab308',
737
+ green: '#22c55e',
738
+ blue: '#3b82f6',
739
+ purple: '#a855f7',
740
+ pink: '#ec4899',
741
+ cyan: '#06b6d4',
742
+ }
743
+
744
+ const AGENT_SOURCE_ORDER: AgentSource[] = [
745
+ 'userSettings',
746
+ 'projectSettings',
747
+ 'localSettings',
748
+ 'policySettings',
749
+ 'plugin',
750
+ 'flagSettings',
751
+ 'built-in',
752
+ ]
753
+
754
+ function AgentsSettings() {
755
+ const {
756
+ activeAgents,
757
+ allAgents,
758
+ isLoading,
759
+ error,
760
+ selectedAgent,
761
+ fetchAgents,
762
+ selectAgent,
763
+ } = useAgentStore()
764
+ const sessions = useSessionStore((s) => s.sessions)
765
+ const activeSessionId = useSessionStore((s) => s.activeSessionId)
766
+ const t = useTranslation()
767
+
768
+ const activeSession = sessions.find((s) => s.id === activeSessionId)
769
+ const currentWorkDir = activeSession?.workDir || undefined
770
+
771
+ useEffect(() => {
772
+ void fetchAgents(currentWorkDir)
773
+ }, [fetchAgents, currentWorkDir])
774
+
775
+ const groupedAgents = useMemo(() => {
776
+ const groups: Partial<Record<AgentSource, AgentDefinition[]>> = {}
777
+ for (const agent of allAgents) {
778
+ ;(groups[agent.source] ??= []).push(agent)
779
+ }
780
+ return groups
781
+ }, [allAgents])
782
+
783
+ const sourceCount = AGENT_SOURCE_ORDER.filter((source) => (groupedAgents[source] ?? []).length > 0).length
784
+
785
+ if (selectedAgent) {
786
+ return (
787
+ <div className="w-full min-w-0">
788
+ <AgentDetailView agent={selectedAgent} onBack={() => selectAgent(null)} />
789
+ </div>
790
+ )
791
+ }
792
+
793
+ return (
794
+ <div className="w-full min-w-0">
795
+ {isLoading && allAgents.length === 0 ? (
796
+ <div className="flex justify-center py-12">
797
+ <div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
798
+ </div>
799
+ ) : error ? (
800
+ <div className="text-center py-12 px-4">
801
+ <span className="material-symbols-outlined text-[40px] text-[var(--color-error)] mb-3 block">error_outline</span>
802
+ <p className="text-sm text-[var(--color-error)] mb-2">{error}</p>
803
+ <button
804
+ onClick={() => void fetchAgents(currentWorkDir)}
805
+ className="text-xs text-[var(--color-text-accent)] hover:underline"
806
+ >
807
+ {t('common.retry')}
808
+ </button>
809
+ </div>
810
+ ) : allAgents.length === 0 ? (
811
+ <div className="text-center py-12 px-4 rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
812
+ <span className="material-symbols-outlined text-[40px] text-[var(--color-text-tertiary)] mb-3 block">smart_toy</span>
813
+ <p className="text-sm text-[var(--color-text-secondary)] mb-1">{t('settings.agents.empty')}</p>
814
+ <p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.agents.emptyHint')}</p>
815
+ </div>
816
+ ) : (
817
+ <div className="flex flex-col gap-6 min-w-0">
818
+ <section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
819
+ <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">
820
+ <div className="min-w-0">
821
+ <div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
822
+ {t('settings.agents.browserEyebrow')}
823
+ </div>
824
+ <div className="flex items-center gap-3 mb-2">
825
+ <span className="material-symbols-outlined text-[22px] text-[var(--color-brand)]">
826
+ smart_toy
827
+ </span>
828
+ <h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
829
+ {t('settings.agents.browserTitle')}
830
+ </h3>
831
+ </div>
832
+ <p className="text-sm leading-6 text-[var(--color-text-secondary)] max-w-3xl">
833
+ {t('settings.agents.description')}
834
+ </p>
835
+ </div>
836
+
837
+ <div className="grid grid-cols-2 gap-3 min-w-0 sm:grid-cols-3">
838
+ <SummaryCard
839
+ label={t('settings.agents.summary.totalAgents')}
840
+ value={String(allAgents.length)}
841
+ icon="smart_toy"
842
+ />
843
+ <SummaryCard
844
+ label={t('settings.agents.summary.activeAgents')}
845
+ value={String(activeAgents.length)}
846
+ icon="bolt"
847
+ />
848
+ <SummaryCard
849
+ label={t('settings.agents.summary.sources')}
850
+ value={String(sourceCount)}
851
+ icon="layers"
852
+ className="col-span-2 sm:col-span-1"
853
+ />
854
+ </div>
855
+ </div>
856
+ </section>
857
+
858
+ <div className={`grid gap-4 ${sourceCount >= 2 ? 'xl:grid-cols-2' : ''}`}>
859
+ {AGENT_SOURCE_ORDER.map((source) => {
860
+ const group = groupedAgents[source]
861
+ if (!group?.length) return null
862
+
863
+ const sourceLabel = t(`settings.agents.source.${source}`)
864
+ return (
865
+ <section
866
+ key={source}
867
+ className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden min-w-0"
868
+ >
869
+ <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)]">
870
+ <div className="min-w-0">
871
+ <div className="flex items-center gap-2 mb-1 flex-wrap">
872
+ <span className={`inline-flex h-7 w-7 items-center justify-center rounded-full ${getAgentSourceAccentClass(source)}`}>
873
+ <span className="material-symbols-outlined text-[16px]">
874
+ {getAgentSourceIcon(source)}
875
+ </span>
876
+ </span>
877
+ <h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
878
+ {sourceLabel}
879
+ </h4>
880
+ <span className="text-xs text-[var(--color-text-tertiary)]">
881
+ {group.length}
882
+ </span>
883
+ </div>
884
+ <p className="text-xs leading-5 text-[var(--color-text-tertiary)]">
885
+ {t('settings.agents.groupHint', {
886
+ source: sourceLabel,
887
+ count: String(group.length),
888
+ })}
889
+ </p>
890
+ </div>
891
+ </div>
892
+
893
+ <div className="flex flex-col p-2">
894
+ {group.map((agent) => (
895
+ <button
896
+ key={`${agent.source}-${agent.agentType}`}
897
+ onClick={() => selectAgent(agent)}
898
+ 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)]"
899
+ >
900
+ <div className="flex items-start gap-3">
901
+ <span
902
+ className="mt-0.5 flex-shrink-0 inline-flex items-center justify-center"
903
+ style={{ color: getAgentDotColor(agent.color) }}
904
+ >
905
+ <span className="material-symbols-outlined text-[18px]">smart_toy</span>
906
+ </span>
907
+ <div className="flex-1 min-w-0">
908
+ <div className="flex items-center gap-2 flex-wrap">
909
+ <span className="text-sm font-bold text-[var(--color-text-primary)] break-all">
910
+ {agent.agentType}
911
+ </span>
912
+ {agent.modelDisplay && (
913
+ <MetaPill>{agent.modelDisplay}</MetaPill>
914
+ )}
915
+ <MetaPill>{sourceLabel}</MetaPill>
916
+ <MetaPill>
917
+ {agent.isActive
918
+ ? t('settings.agents.status.active')
919
+ : t('settings.agents.status.available')}
920
+ </MetaPill>
921
+ {agent.overriddenBy && (
922
+ <MetaPill>
923
+ {t('settings.agents.overriddenBy', {
924
+ source: t(`settings.agents.source.${agent.overriddenBy}`),
925
+ })}
926
+ </MetaPill>
927
+ )}
928
+ </div>
929
+ <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)]">
930
+ <MarkdownRenderer
931
+ content={agent.description || t('settings.agents.noDescription')}
932
+ />
933
+ </div>
934
+ <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-[var(--color-text-tertiary)]">
935
+ <span>
936
+ {agent.tools?.length
937
+ ? t('settings.agents.toolCount', { count: String(agent.tools.length) })
938
+ : t('settings.agents.noTools')}
939
+ </span>
940
+ {agent.baseDir && (
941
+ <span className="break-all">{agent.baseDir}</span>
942
+ )}
943
+ </div>
944
+ </div>
945
+ <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">
946
+ chevron_right
947
+ </span>
948
+ </div>
949
+ </button>
950
+ ))}
951
+ </div>
952
+ </section>
953
+ )
954
+ })}
955
+ </div>
956
+ </div>
957
+ )}
958
+ </div>
959
+ )
960
+ }
961
+
962
+ function AgentDetailView({ agent, onBack }: { agent: AgentDefinition; onBack: () => void }) {
963
+ const t = useTranslation()
964
+ const sourceLabel = t(`settings.agents.source.${agent.source}`)
965
+
966
+ return (
967
+ <div className="flex h-full min-h-0 flex-col gap-4 min-w-0">
968
+ <div>
969
+ <button
970
+ onClick={onBack}
971
+ 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)]"
972
+ >
973
+ <span className="material-symbols-outlined text-[16px]">arrow_back</span>
974
+ {t('settings.agents.backToList')}
975
+ </button>
976
+ </div>
977
+
978
+ <section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
979
+ <div className="grid gap-4 px-5 py-5 lg:grid-cols-[minmax(0,1.5fr)_minmax(280px,0.9fr)] lg:items-start">
980
+ <div className="min-w-0">
981
+ <div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
982
+ {t('settings.agents.entryEyebrow')}
983
+ </div>
984
+ <div className="flex flex-wrap items-center gap-2 mb-2">
985
+ <span
986
+ className="h-3 w-3 rounded-full flex-shrink-0"
987
+ style={{ backgroundColor: getAgentDotColor(agent.color) }}
988
+ />
989
+ <h3 className="text-[22px] font-semibold leading-tight text-[var(--color-text-primary)] break-all">
990
+ {agent.agentType}
991
+ </h3>
992
+ <MetaPill>{sourceLabel}</MetaPill>
993
+ {agent.modelDisplay && <MetaPill>{agent.modelDisplay}</MetaPill>}
994
+ <MetaPill>
995
+ {agent.isActive
996
+ ? t('settings.agents.status.active')
997
+ : t('settings.agents.status.available')}
998
+ </MetaPill>
999
+ {agent.overriddenBy && (
1000
+ <MetaPill>
1001
+ {t('settings.agents.overriddenByShort', {
1002
+ source: t(`settings.agents.source.${agent.overriddenBy}`),
1003
+ })}
1004
+ </MetaPill>
1005
+ )}
1006
+ </div>
1007
+ <div className="max-w-4xl text-sm leading-6 text-[var(--color-text-secondary)]">
1008
+ <MarkdownRenderer
1009
+ content={agent.description || t('settings.agents.noDescription')}
1010
+ />
1011
+ </div>
1012
+ <div className="mt-3 flex flex-wrap gap-x-4 gap-y-2 text-xs text-[var(--color-text-tertiary)]">
1013
+ <span>
1014
+ {agent.tools?.length
1015
+ ? t('settings.agents.toolCount', { count: String(agent.tools.length) })
1016
+ : t('settings.agents.noTools')}
1017
+ </span>
1018
+ {agent.baseDir && <span className="break-all">{agent.baseDir}</span>}
1019
+ </div>
1020
+ </div>
1021
+
1022
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-2">
1023
+ <DetailStat
1024
+ label={t('settings.agents.summary.source')}
1025
+ value={sourceLabel}
1026
+ icon="layers"
1027
+ />
1028
+ <DetailStat
1029
+ label={t('settings.agents.summary.model')}
1030
+ value={agent.modelDisplay || '—'}
1031
+ icon="psychology"
1032
+ />
1033
+ <DetailStat
1034
+ label={t('settings.agents.summary.tools')}
1035
+ value={String(agent.tools?.length ?? 0)}
1036
+ icon="build"
1037
+ />
1038
+ <DetailStat
1039
+ label={t('settings.agents.summary.status')}
1040
+ value={agent.isActive ? t('settings.agents.status.active') : t('settings.agents.status.available')}
1041
+ icon="bolt"
1042
+ />
1043
+ </div>
1044
+ </div>
1045
+ </section>
1046
+
1047
+ {agent.tools && agent.tools.length > 0 && (
1048
+ <section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-4">
1049
+ <div className="flex items-center gap-2 mb-3">
1050
+ <span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">
1051
+ build
1052
+ </span>
1053
+ <h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
1054
+ {t('settings.agents.tools')}
1055
+ </h4>
1056
+ </div>
1057
+ <div className="flex flex-wrap gap-2">
1058
+ {agent.tools.map((tool) => (
1059
+ <MetaPill key={tool}>{tool}</MetaPill>
1060
+ ))}
1061
+ </div>
1062
+ </section>
1063
+ )}
1064
+
1065
+ <section className="flex flex-1 min-h-0 min-w-0 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
1066
+ <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
1067
+ <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">
1068
+ <div className="min-w-0">
1069
+ <div className="flex items-center gap-2 flex-wrap">
1070
+ <span className="text-xs font-mono text-[var(--color-text-secondary)] break-all">
1071
+ {agent.baseDir || sourceLabel}
1072
+ </span>
1073
+ </div>
1074
+ <div className="mt-1 text-[11px] text-[var(--color-text-tertiary)]">
1075
+ {t('settings.agents.promptHint')}
1076
+ </div>
1077
+ </div>
1078
+ <div className="flex items-center gap-2">
1079
+ <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)]">
1080
+ {t('settings.agents.systemPrompt')}
1081
+ </span>
1082
+ </div>
1083
+ </div>
1084
+
1085
+ <div className="min-h-0 flex-1 overflow-y-auto bg-[var(--color-surface-container-lowest)]">
1086
+ {agent.systemPrompt ? (
1087
+ <div className="px-6 py-5 lg:px-8">
1088
+ <MarkdownRenderer
1089
+ content={agent.systemPrompt}
1090
+ variant="document"
1091
+ className="mx-auto max-w-[72ch]"
1092
+ />
1093
+ </div>
1094
+ ) : (
1095
+ <div className="px-6 py-10 text-center">
1096
+ <span className="material-symbols-outlined text-[32px] text-[var(--color-text-tertiary)] mb-2 block">
1097
+ article
1098
+ </span>
1099
+ <p className="text-sm text-[var(--color-text-tertiary)]">
1100
+ {t('settings.agents.noSystemPrompt')}
1101
+ </p>
1102
+ </div>
1103
+ )}
1104
+ </div>
1105
+ </div>
1106
+ </section>
1107
+ </div>
1108
+ )
1109
+ }
1110
+
1111
+ function getAgentDotColor(color?: string) {
1112
+ return color && AGENT_COLORS[color] ? AGENT_COLORS[color] : 'var(--color-text-tertiary)'
1113
+ }
1114
+
1115
+ function getAgentSourceIcon(source: AgentSource) {
1116
+ switch (source) {
1117
+ case 'userSettings':
1118
+ return 'person'
1119
+ case 'projectSettings':
1120
+ return 'folder'
1121
+ case 'localSettings':
1122
+ return 'folder_lock'
1123
+ case 'policySettings':
1124
+ return 'shield'
1125
+ case 'plugin':
1126
+ return 'extension'
1127
+ case 'flagSettings':
1128
+ return 'terminal'
1129
+ case 'built-in':
1130
+ return 'inventory_2'
1131
+ }
1132
+ }
1133
+
1134
+ function getAgentSourceAccentClass(source: AgentSource) {
1135
+ switch (source) {
1136
+ case 'userSettings':
1137
+ return 'bg-[var(--color-primary-fixed)] text-[var(--color-brand)]'
1138
+ case 'projectSettings':
1139
+ return 'bg-[var(--color-success-container)] text-[var(--color-success)]'
1140
+ case 'localSettings':
1141
+ return 'bg-[var(--color-info-container)] text-[var(--color-info)]'
1142
+ case 'policySettings':
1143
+ return 'bg-[var(--color-warning-container)] text-[var(--color-warning)]'
1144
+ case 'plugin':
1145
+ return 'bg-[var(--color-warning-container)] text-[var(--color-warning)]'
1146
+ case 'flagSettings':
1147
+ return 'bg-[var(--color-error)]/10 text-[var(--color-error)]'
1148
+ case 'built-in':
1149
+ return 'bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]'
1150
+ }
1151
+ }
1152
+
1153
+ function MetaPill({ children }: { children: ReactNode }) {
1154
+ return (
1155
+ <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)]">
1156
+ {children}
1157
+ </span>
1158
+ )
1159
+ }
1160
+
1161
+ function SummaryCard({
1162
+ label,
1163
+ value,
1164
+ icon,
1165
+ className = '',
1166
+ }: {
1167
+ label: string
1168
+ value: string
1169
+ icon: string
1170
+ className?: string
1171
+ }) {
1172
+ return (
1173
+ <div className={`rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 min-w-0 ${className}`}>
1174
+ <div className="flex items-center gap-1.5 text-[11px] uppercase tracking-[0.12em] text-[var(--color-text-tertiary)] min-w-0">
1175
+ <span className="material-symbols-outlined text-[14px] flex-shrink-0">{icon}</span>
1176
+ <span className="truncate">{label}</span>
1177
+ </div>
1178
+ <div className="mt-2 text-lg font-semibold text-[var(--color-text-primary)] truncate">
1179
+ {value}
1180
+ </div>
1181
+ </div>
1182
+ )
1183
+ }
1184
+
1185
+ function DetailStat({
1186
+ label,
1187
+ value,
1188
+ icon,
1189
+ }: {
1190
+ label: string
1191
+ value: string
1192
+ icon: string
1193
+ }) {
1194
+ return (
1195
+ <div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3">
1196
+ <div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
1197
+ <span className="material-symbols-outlined text-[14px]">{icon}</span>
1198
+ <span>{label}</span>
1199
+ </div>
1200
+ <div className="mt-2 text-base font-semibold text-[var(--color-text-primary)] break-all">
1201
+ {value}
1202
+ </div>
1203
+ </div>
1204
+ )
1205
+ }
1206
+ // ─── Skill Settings ──────────────────────────────────────
1207
+
1208
+ function SkillSettings() {
1209
+ const selectedSkill = useSkillStore((s) => s.selectedSkill)
1210
+ const t = useTranslation()
1211
+
1212
+ if (selectedSkill) {
1213
+ return (
1214
+ <div className="w-full min-w-0">
1215
+ <SkillDetail />
1216
+ </div>
1217
+ )
1218
+ }
1219
+
1220
+ return (
1221
+ <div className="w-full min-w-0">
1222
+ <h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">
1223
+ {t('settings.skills.title')}
1224
+ </h2>
1225
+ <p className="text-sm text-[var(--color-text-tertiary)] mb-4">
1226
+ {t('settings.skills.description')}
1227
+ </p>
1228
+ <SkillList />
1229
+ </div>
1230
+ )
1231
+ }
1232
+
1233
+ // ─── About Settings ──────────────────────────────────────
1234
+
1235
+ const GITHUB_REPO = 'https://github.com/NanmiCoder/cc-haha'
1236
+ const AUTHOR_GITHUB = 'https://github.com/NanmiCoder'
1237
+ const SOCIAL_LINKS = [
1238
+ { name: 'Bilibili', icon: '/icons/bilibili.svg', url: 'https://space.bilibili.com/434377496', label: '程序员阿江-Relakkes' },
1239
+ { name: 'Douyin', icon: '/icons/douyin.svg', url: 'https://www.douyin.com/user/MS4wLjABAAAATJPY7LAlaa5X-c8uNdWkvz0jUGgpw4eeXIwu_8BhvqE', label: '程序员阿江-Relakkes' },
1240
+ { name: 'Xiaohongshu', icon: '/icons/xiaohongshu.svg', url: 'https://www.xiaohongshu.com/user/profile/5f58bd990000000001003753', label: '程序员阿江-Relakkes' },
1241
+ ] as const
1242
+
1243
+ function AboutSettings() {
1244
+ const t = useTranslation()
1245
+ const [version, setVersion] = useState('')
1246
+ const updateStatus = useUpdateStore((s) => s.status)
1247
+ const availableVersion = useUpdateStore((s) => s.availableVersion)
1248
+ const releaseNotes = useUpdateStore((s) => s.releaseNotes)
1249
+ const progressPercent = useUpdateStore((s) => s.progressPercent)
1250
+ const error = useUpdateStore((s) => s.error)
1251
+ const checkedAt = useUpdateStore((s) => s.checkedAt)
1252
+ const checkForUpdates = useUpdateStore((s) => s.checkForUpdates)
1253
+ const installUpdate = useUpdateStore((s) => s.installUpdate)
1254
+ const initialize = useUpdateStore((s) => s.initialize)
1255
+
1256
+ useEffect(() => {
1257
+ import('@tauri-apps/api/app').then((mod) => mod.getVersion()).then(setVersion).catch(() => setVersion('0.1.0'))
1258
+ }, [])
1259
+
1260
+ useEffect(() => {
1261
+ void initialize()
1262
+ }, [initialize])
1263
+
1264
+ const openUrl = (url: string) => {
1265
+ import('@tauri-apps/plugin-shell').then((mod) => mod.open(url)).catch(() => window.open(url, '_blank'))
1266
+ }
1267
+
1268
+ const checkedAtText =
1269
+ checkedAt
1270
+ ? new Date(checkedAt).toLocaleString(undefined, {
1271
+ hour: '2-digit',
1272
+ minute: '2-digit',
1273
+ month: 'short',
1274
+ day: 'numeric',
1275
+ })
1276
+ : null
1277
+
1278
+ const updateDescription =
1279
+ updateStatus === 'checking'
1280
+ ? t('update.checking')
1281
+ : updateStatus === 'downloading'
1282
+ ? t('update.progress', { progress: String(progressPercent) })
1283
+ : updateStatus === 'restarting'
1284
+ ? t('update.restarting')
1285
+ : updateStatus === 'available' && availableVersion
1286
+ ? t('update.newVersion', { version: availableVersion })
1287
+ : updateStatus === 'up-to-date'
1288
+ ? t('update.upToDate', { version: version || t('update.currentVersionUnknown') })
1289
+ : error
1290
+ ? t('update.failed', { error })
1291
+ : t('update.idle')
1292
+
1293
+ return (
1294
+ <div className="w-full min-w-0 max-w-lg mx-auto flex flex-col items-center py-6">
1295
+ {/* Logo + App Name + Version */}
1296
+ <img src="/app-icon.jpg" alt="Claude Code Haha" className="w-20 h-20 rounded-2xl shadow-md mb-4" />
1297
+ <h1 className="text-xl font-bold text-[var(--color-text-primary)]">Claude Code Haha</h1>
1298
+ {version && (
1299
+ <span className="text-xs text-[var(--color-text-tertiary)] mt-1">{t('settings.about.version')} {version}</span>
1300
+ )}
1301
+
1302
+ {/* GitHub Repo */}
1303
+ <div className="mt-6 w-full">
1304
+ <button
1305
+ onClick={() => openUrl(GITHUB_REPO)}
1306
+ 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"
1307
+ >
1308
+ <img src="/icons/github.svg" alt="GitHub" className="w-5 h-5 opacity-70" />
1309
+ <div className="flex-1 text-left">
1310
+ <div className="text-sm font-medium text-[var(--color-text-primary)]">NanmiCoder/cc-haha</div>
1311
+ <div className="text-xs text-[var(--color-text-tertiary)]">{t('settings.about.starHint')}</div>
1312
+ </div>
1313
+ <span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)]">open_in_new</span>
1314
+ </button>
1315
+ </div>
1316
+
1317
+ <div className="mt-4 w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-4">
1318
+ <div className="flex items-start justify-between gap-3">
1319
+ <div>
1320
+ <div className="text-sm font-medium text-[var(--color-text-primary)]">{t('settings.about.updates')}</div>
1321
+ <div className="text-xs text-[var(--color-text-tertiary)] mt-1">
1322
+ {t('settings.about.updatesDesc')}
1323
+ </div>
1324
+ </div>
1325
+ <Button
1326
+ size="sm"
1327
+ variant="secondary"
1328
+ onClick={() => void checkForUpdates()}
1329
+ loading={updateStatus === 'checking'}
1330
+ >
1331
+ {t('update.checkNow')}
1332
+ </Button>
1333
+ </div>
1334
+
1335
+ <div className="mt-4 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3">
1336
+ <div className="flex items-center justify-between gap-3">
1337
+ <div>
1338
+ <div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-tertiary)]">
1339
+ {t('settings.about.version')}
1340
+ </div>
1341
+ <div className="text-sm font-medium text-[var(--color-text-primary)] mt-1">
1342
+ {version || t('update.currentVersionUnknown')}
1343
+ </div>
1344
+ </div>
1345
+
1346
+ {availableVersion && (
1347
+ <div className="text-right">
1348
+ <div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-tertiary)]">
1349
+ {t('update.availableLabel')}
1350
+ </div>
1351
+ <div className="text-sm font-medium text-[var(--color-text-primary)] mt-1">
1352
+ {availableVersion}
1353
+ </div>
1354
+ </div>
1355
+ )}
1356
+ </div>
1357
+
1358
+ <p className={`mt-3 text-sm ${error ? 'text-[var(--color-error)]' : 'text-[var(--color-text-secondary)]'}`}>
1359
+ {updateDescription}
1360
+ </p>
1361
+
1362
+ {checkedAtText && (
1363
+ <p className="mt-1 text-xs text-[var(--color-text-tertiary)]">
1364
+ {t('update.checkedAt', { time: checkedAtText })}
1365
+ </p>
1366
+ )}
1367
+
1368
+ {(updateStatus === 'downloading' || updateStatus === 'restarting') && (
1369
+ <div className="mt-3">
1370
+ <div className="h-1.5 bg-[var(--color-surface-container-low)] rounded-full overflow-hidden">
1371
+ <div
1372
+ className="h-full bg-[var(--color-text-accent)] transition-all duration-300"
1373
+ style={{ width: `${Math.min(progressPercent, 100)}%` }}
1374
+ />
1375
+ </div>
1376
+ </div>
1377
+ )}
1378
+
1379
+ {releaseNotes && availableVersion && (
1380
+ <div className="mt-3 rounded-lg bg-[var(--color-surface-container-low)] px-3 py-2">
1381
+ <div className="text-[11px] uppercase tracking-[0.14em] text-[var(--color-text-tertiary)]">
1382
+ {t('update.releaseNotes')}
1383
+ </div>
1384
+ <p className="mt-1 text-xs leading-5 text-[var(--color-text-secondary)] whitespace-pre-wrap">
1385
+ {releaseNotes}
1386
+ </p>
1387
+ </div>
1388
+ )}
1389
+
1390
+ {availableVersion && (
1391
+ <div className="mt-3 flex justify-end">
1392
+ <Button
1393
+ size="sm"
1394
+ onClick={() => void installUpdate()}
1395
+ loading={updateStatus === 'downloading' || updateStatus === 'restarting'}
1396
+ disabled={updateStatus === 'checking'}
1397
+ >
1398
+ {updateStatus === 'restarting' ? t('update.restarting') : t('update.now')}
1399
+ </Button>
1400
+ </div>
1401
+ )}
1402
+ </div>
1403
+ </div>
1404
+
1405
+ {/* Divider */}
1406
+ <div className="w-full border-t border-[var(--color-border)]/40 my-6" />
1407
+
1408
+ {/* Author */}
1409
+ <div className="w-full">
1410
+ <h3 className="text-xs font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider mb-3">{t('settings.about.author')}</h3>
1411
+ <button
1412
+ onClick={() => openUrl(AUTHOR_GITHUB)}
1413
+ 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"
1414
+ >
1415
+ <img src="/icons/github.svg" alt="GitHub" className="w-4 h-4 opacity-60" />
1416
+ <span className="text-sm text-[var(--color-text-primary)]">程序员阿江-Relakkes</span>
1417
+ <span className="text-xs text-[var(--color-text-tertiary)] ml-auto">GitHub</span>
1418
+ </button>
1419
+ </div>
1420
+
1421
+ {/* Social Media */}
1422
+ <div className="w-full mt-4">
1423
+ <h3 className="text-xs font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider mb-3">{t('settings.about.socialMedia')}</h3>
1424
+ <div className="flex flex-col gap-0.5">
1425
+ {SOCIAL_LINKS.map((link) => (
1426
+ <button
1427
+ key={link.name}
1428
+ onClick={() => openUrl(link.url)}
1429
+ 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"
1430
+ >
1431
+ <img src={link.icon} alt={link.name} className="w-4 h-4 opacity-60" />
1432
+ <span className="text-sm text-[var(--color-text-primary)]">{link.label}</span>
1433
+ <span className="text-xs text-[var(--color-text-tertiary)] ml-auto">{link.name}</span>
1434
+ </button>
1435
+ ))}
1436
+ <button
1437
+ onClick={() => openUrl('mailto:relakkes@gmail.com')}
1438
+ 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"
1439
+ >
1440
+ <span className="material-symbols-outlined text-[16px] opacity-60">mail</span>
1441
+ <span className="text-sm text-[var(--color-text-primary)]">relakkes@gmail.com</span>
1442
+ <span className="text-xs text-[var(--color-text-tertiary)] ml-auto">Email</span>
1443
+ </button>
1444
+ </div>
1445
+ </div>
1446
+ </div>
1447
+ )
1448
+ }