free-coding-models 0.5.5 → 0.5.8

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 (63) hide show
  1. package/README.md +19 -11
  2. package/bin/free-coding-models.js +6 -0
  3. package/changelog/v0.5.5.md +1 -0
  4. package/changelog/v0.5.6.md +24 -0
  5. package/changelog/v0.5.7.md +20 -0
  6. package/package.json +4 -4
  7. package/src/core/changelog-loader.js +5 -1
  8. package/src/core/endpoint-installer.js +3 -3
  9. package/src/core/router-daemon.js +11 -0
  10. package/src/core/tool-launchers.js +2 -2
  11. package/web/dist/assets/index-DKmmiDip.css +1 -0
  12. package/web/dist/assets/index-DzVt42Nf.js +39 -0
  13. package/web/dist/index.html +2 -2
  14. package/web/server.js +532 -2
  15. package/web/src/App.jsx +235 -73
  16. package/web/src/components/analytics/AnalyticsView.jsx +4 -0
  17. package/web/src/components/analytics/TokenUsagePanel.jsx +105 -0
  18. package/web/src/components/analytics/TokenUsagePanel.module.css +126 -0
  19. package/web/src/components/atoms/TierBadge.module.css +3 -3
  20. package/web/src/components/changelog/ChangelogView.jsx +135 -0
  21. package/web/src/components/changelog/ChangelogView.module.css +160 -0
  22. package/web/src/components/dashboard/DetailPanel.jsx +29 -4
  23. package/web/src/components/dashboard/DetailPanel.module.css +26 -0
  24. package/web/src/components/dashboard/FilterBar.jsx +17 -2
  25. package/web/src/components/dashboard/FilterBar.module.css +17 -0
  26. package/web/src/components/dashboard/ModelTable.jsx +17 -5
  27. package/web/src/components/dashboard/ModelTable.module.css +14 -1
  28. package/web/src/components/help/HelpView.jsx +188 -0
  29. package/web/src/components/help/HelpView.module.css +157 -0
  30. package/web/src/components/install/InstallEndpointsView.jsx +278 -0
  31. package/web/src/components/install/InstallEndpointsView.module.css +351 -0
  32. package/web/src/components/installed/InstalledModelsView.jsx +89 -0
  33. package/web/src/components/installed/InstalledModelsView.module.css +200 -0
  34. package/web/src/components/launch/IncompatibleFallbackModal.jsx +69 -0
  35. package/web/src/components/launch/LaunchButton.jsx +30 -0
  36. package/web/src/components/launch/LaunchButton.module.css +35 -0
  37. package/web/src/components/launch/LaunchModal.module.css +125 -0
  38. package/web/src/components/layout/Header.jsx +78 -14
  39. package/web/src/components/layout/Header.module.css +62 -2
  40. package/web/src/components/palette/CommandPalette.jsx +228 -74
  41. package/web/src/components/recommend/RecommendView.jsx +121 -0
  42. package/web/src/components/recommend/RecommendView.module.css +55 -0
  43. package/web/src/components/router/RouterView.jsx +286 -0
  44. package/web/src/components/router/RouterView.module.css +357 -0
  45. package/web/src/components/settings/SettingsView.jsx +281 -8
  46. package/web/src/components/settings/SettingsView.module.css +174 -0
  47. package/web/src/components/tools/ToolPicker.jsx +86 -0
  48. package/web/src/components/tools/ToolPicker.module.css +84 -0
  49. package/web/src/components/update/UpdateChip.jsx +104 -0
  50. package/web/src/components/update/UpdateChip.module.css +146 -0
  51. package/web/src/global.css +23 -0
  52. package/web/src/hooks/urlState.constants.js +28 -0
  53. package/web/src/hooks/useChangelog.js +51 -0
  54. package/web/src/hooks/useInstalledModels.js +42 -0
  55. package/web/src/hooks/useRecommend.js +67 -0
  56. package/web/src/hooks/useRouterDashboard.js +133 -0
  57. package/web/src/hooks/useTokenUsage.js +103 -0
  58. package/web/src/hooks/useToolMode.js +77 -0
  59. package/web/src/hooks/useUpdateChecker.js +91 -0
  60. package/web/src/hooks/useUrlState.js +123 -62
  61. package/web/src/utils/m3.js +55 -0
  62. package/web/dist/assets/index-uD3faN3G.js +0 -39
  63. package/web/dist/assets/index-uTifVKX1.css +0 -1
@@ -0,0 +1,357 @@
1
+ .overlay {
2
+ position: fixed;
3
+ inset: 0;
4
+ z-index: 200;
5
+ background: rgba(0, 0, 0, 0.6);
6
+ display: flex;
7
+ align-items: center;
8
+ justify-content: center;
9
+ animation: fadeIn 0.15s ease;
10
+ }
11
+
12
+ .modal {
13
+ background: var(--bg-primary, #0f0f17);
14
+ border: 1px solid var(--border, #1e1e2e);
15
+ border-radius: 12px;
16
+ width: 720px;
17
+ max-width: 95vw;
18
+ max-height: 85vh;
19
+ display: flex;
20
+ flex-direction: column;
21
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
22
+ }
23
+
24
+ .header {
25
+ display: flex;
26
+ align-items: center;
27
+ justify-content: space-between;
28
+ padding: 16px 20px;
29
+ border-bottom: 1px solid var(--border, #1e1e2e);
30
+ }
31
+
32
+ .title {
33
+ margin: 0;
34
+ font-size: 16px;
35
+ font-weight: 600;
36
+ display: flex;
37
+ align-items: center;
38
+ gap: 8px;
39
+ color: var(--text-primary, #e0e0e0);
40
+ }
41
+
42
+ .closeBtn {
43
+ background: none;
44
+ border: none;
45
+ color: var(--text-muted, #888);
46
+ font-size: 18px;
47
+ cursor: pointer;
48
+ padding: 4px 8px;
49
+ border-radius: 6px;
50
+ &:hover { background: var(--bg-hover, #1a1a2e); color: var(--text-primary, #e0e0e0); }
51
+ }
52
+
53
+ .body {
54
+ padding: 20px;
55
+ overflow-y: auto;
56
+ flex: 1;
57
+ }
58
+
59
+ /* Hero Card */
60
+ .heroCard {
61
+ display: flex;
62
+ align-items: center;
63
+ justify-content: space-between;
64
+ padding: 16px;
65
+ border-radius: 10px;
66
+ margin-bottom: 16px;
67
+ }
68
+
69
+ .heroRunning {
70
+ background: linear-gradient(135deg, rgba(0, 200, 100, 0.08), rgba(0, 200, 100, 0.02));
71
+ border: 1px solid rgba(0, 200, 100, 0.2);
72
+ }
73
+
74
+ .heroStopped {
75
+ background: var(--bg-secondary, #14141f);
76
+ border: 1px solid var(--border, #1e1e2e);
77
+ }
78
+
79
+ .heroLeft {
80
+ display: flex;
81
+ flex-direction: column;
82
+ gap: 4px;
83
+ }
84
+
85
+ .heroStatus {
86
+ display: flex;
87
+ align-items: center;
88
+ gap: 8px;
89
+ }
90
+
91
+ .statusDot {
92
+ width: 10px;
93
+ height: 10px;
94
+ border-radius: 50%;
95
+ display: inline-block;
96
+ }
97
+
98
+ .dotGreen {
99
+ background: #00c864;
100
+ box-shadow: 0 0 6px rgba(0, 200, 100, 0.4);
101
+ }
102
+
103
+ .dotGray {
104
+ background: #555;
105
+ }
106
+
107
+ .heroLabel {
108
+ font-size: 15px;
109
+ font-weight: 600;
110
+ color: var(--text-primary, #e0e0e0);
111
+ }
112
+
113
+ .heroMeta {
114
+ font-size: 12px;
115
+ color: var(--text-muted, #888);
116
+ display: flex;
117
+ align-items: center;
118
+ gap: 6px;
119
+ }
120
+
121
+ .heroActions {
122
+ display: flex;
123
+ align-items: center;
124
+ gap: 8px;
125
+ }
126
+
127
+ .startBtn {
128
+ display: flex;
129
+ align-items: center;
130
+ gap: 6px;
131
+ padding: 8px 16px;
132
+ background: #00c864;
133
+ color: #000;
134
+ border: none;
135
+ border-radius: 8px;
136
+ font-size: 13px;
137
+ font-weight: 600;
138
+ cursor: pointer;
139
+ &:hover { background: #00d874; }
140
+ &:disabled { opacity: 0.5; cursor: not-allowed; }
141
+ }
142
+
143
+ .stopBtn {
144
+ display: flex;
145
+ align-items: center;
146
+ gap: 6px;
147
+ padding: 8px 16px;
148
+ background: #dc3545;
149
+ color: #fff;
150
+ border: none;
151
+ border-radius: 8px;
152
+ font-size: 13px;
153
+ font-weight: 600;
154
+ cursor: pointer;
155
+ &:hover { background: #e04555; }
156
+ &:disabled { opacity: 0.5; cursor: not-allowed; }
157
+ }
158
+
159
+ .refreshBtn {
160
+ display: flex;
161
+ align-items: center;
162
+ justify-content: center;
163
+ width: 32px;
164
+ height: 32px;
165
+ background: var(--bg-secondary, #14141f);
166
+ border: 1px solid var(--border, #1e1e2e);
167
+ border-radius: 8px;
168
+ color: var(--text-muted, #888);
169
+ cursor: pointer;
170
+ &:hover { color: var(--text-primary, #e0e0e0); background: var(--bg-hover, #1a1a2e); }
171
+ }
172
+
173
+ /* Quick Setup */
174
+ .quickSetup {
175
+ margin-bottom: 16px;
176
+ }
177
+
178
+ .sectionTitle {
179
+ display: flex;
180
+ align-items: center;
181
+ gap: 6px;
182
+ font-size: 13px;
183
+ font-weight: 600;
184
+ color: var(--text-secondary, #aaa);
185
+ margin: 0 0 8px 0;
186
+ text-transform: uppercase;
187
+ letter-spacing: 0.5px;
188
+ }
189
+
190
+ .quickRows {
191
+ display: flex;
192
+ flex-direction: column;
193
+ gap: 6px;
194
+ background: var(--bg-secondary, #14141f);
195
+ border: 1px solid var(--border, #1e1e2e);
196
+ border-radius: 8px;
197
+ padding: 10px 12px;
198
+ }
199
+
200
+ .quickRow {
201
+ display: flex;
202
+ align-items: center;
203
+ gap: 8px;
204
+ }
205
+
206
+ .quickLabel {
207
+ font-size: 11px;
208
+ font-weight: 600;
209
+ color: var(--text-muted, #888);
210
+ min-width: 60px;
211
+ text-transform: uppercase;
212
+ }
213
+
214
+ .quickValue {
215
+ flex: 1;
216
+ font-size: 13px;
217
+ color: var(--text-primary, #e0e0e0);
218
+ background: var(--bg-primary, #0f0f17);
219
+ padding: 4px 8px;
220
+ border-radius: 4px;
221
+ font-family: 'SF Mono', monospace;
222
+ }
223
+
224
+ .copyBtn {
225
+ background: none;
226
+ border: none;
227
+ color: var(--text-muted, #888);
228
+ cursor: pointer;
229
+ padding: 4px;
230
+ border-radius: 4px;
231
+ &:hover { color: var(--text-primary, #e0e0e0); background: var(--bg-hover, #1a1a2e); }
232
+ }
233
+
234
+ /* Probe Mode */
235
+ .section {
236
+ margin-bottom: 16px;
237
+ }
238
+
239
+ .probeModes {
240
+ display: flex;
241
+ gap: 8px;
242
+ }
243
+
244
+ .probeBtn {
245
+ padding: 6px 14px;
246
+ border: 1px solid var(--border, #1e1e2e);
247
+ border-radius: 6px;
248
+ background: var(--bg-secondary, #14141f);
249
+ color: var(--text-secondary, #aaa);
250
+ font-size: 12px;
251
+ cursor: pointer;
252
+ text-transform: capitalize;
253
+ &:hover { border-color: var(--text-muted, #888); }
254
+ }
255
+
256
+ .probeActive {
257
+ background: rgba(0, 200, 100, 0.1);
258
+ border-color: rgba(0, 200, 100, 0.3);
259
+ color: #00c864;
260
+ font-weight: 600;
261
+ }
262
+
263
+ /* Model Health */
264
+ .modelList {
265
+ display: flex;
266
+ flex-direction: column;
267
+ gap: 4px;
268
+ }
269
+
270
+ .modelRow {
271
+ display: flex;
272
+ align-items: center;
273
+ gap: 8px;
274
+ padding: 6px 10px;
275
+ background: var(--bg-secondary, #14141f);
276
+ border-radius: 6px;
277
+ font-size: 12px;
278
+ }
279
+
280
+ .modelPriority {
281
+ color: var(--text-muted, #888);
282
+ min-width: 22px;
283
+ font-weight: 600;
284
+ }
285
+
286
+ .modelName {
287
+ flex: 1;
288
+ color: var(--text-primary, #e0e0e0);
289
+ font-family: 'SF Mono', monospace;
290
+ overflow: hidden;
291
+ text-overflow: ellipsis;
292
+ white-space: nowrap;
293
+ }
294
+
295
+ .modelScore {
296
+ color: var(--text-muted, #888);
297
+ min-width: 40px;
298
+ text-align: right;
299
+ }
300
+
301
+ .modelLatency {
302
+ color: var(--text-muted, #888);
303
+ min-width: 50px;
304
+ text-align: right;
305
+ }
306
+
307
+ .circuitBadge {
308
+ font-size: 10px;
309
+ font-weight: 700;
310
+ padding: 2px 6px;
311
+ border-radius: 4px;
312
+ text-transform: uppercase;
313
+ letter-spacing: 0.5px;
314
+ min-width: 70px;
315
+ text-align: center;
316
+ }
317
+
318
+ .circuitClosed { background: rgba(0, 200, 100, 0.15); color: #00c864; }
319
+ .circuitOpen { background: rgba(220, 53, 69, 0.15); color: #dc3545; }
320
+ .circuitHalfOpen { background: rgba(255, 193, 7, 0.15); color: #ffc107; }
321
+ .circuitAuth { background: rgba(255, 140, 0, 0.15); color: #ff8c00; }
322
+ .circuitUnknown { background: rgba(128, 128, 128, 0.15); color: #888; }
323
+
324
+ /* Request Log */
325
+ .logList {
326
+ display: flex;
327
+ flex-direction: column;
328
+ gap: 2px;
329
+ }
330
+
331
+ .logRow {
332
+ display: flex;
333
+ align-items: center;
334
+ gap: 8px;
335
+ padding: 4px 8px;
336
+ font-size: 11px;
337
+ font-family: 'SF Mono', monospace;
338
+ }
339
+
340
+ .logOk { color: #00c864; min-width: 28px; }
341
+ .logErr { color: #dc3545; min-width: 28px; }
342
+ .logModel { color: var(--text-secondary, #aaa); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
343
+ .logLatency { color: var(--text-muted, #888); min-width: 50px; text-align: right; }
344
+ .logTokens { color: var(--text-muted, #888); min-width: 60px; text-align: right; }
345
+ .logFailover {
346
+ font-size: 9px;
347
+ padding: 1px 4px;
348
+ background: rgba(255, 193, 7, 0.15);
349
+ color: #ffc107;
350
+ border-radius: 3px;
351
+ text-transform: uppercase;
352
+ }
353
+
354
+ @keyframes fadeIn {
355
+ from { opacity: 0; }
356
+ to { opacity: 1; }
357
+ }
@@ -1,21 +1,40 @@
1
1
  /**
2
2
  * @file web/src/components/settings/SettingsView.jsx
3
- * @description Full settings page for managing API keys and provider configurations.
4
- * 📖 Fetches config from /api/config, renders expandable provider cards with
5
- * key display (masked/revealed), save/delete/toggle actions, and search filter.
3
+ * @description Full settings page M2 parity with the TUI Settings overlay.
4
+ * 📖 M1: API key management (per-provider cards: enable/disable, masked key,
5
+ * 📖 reveal, copy, save, delete, search filter).
6
+ * 📖 M2: theme dropdown, favorites display mode toggle, startup AI speed scan
7
+ * 📖 toggle, shell-env export toggle, legacy proxy cleanup button, per-provider
8
+ * 📖 test key button (calls /api/key/:provider/test), open Changelog link,
9
+ * 📖 update status row.
6
10
  * @functions SettingsView → main settings page component
7
11
  */
8
12
  import { useState, useEffect, useCallback } from 'react'
9
- import { IconSettings, IconPlug, IconCircleCheck, IconKey, IconEye, IconEyeOff, IconCopy, IconTrash } from '@tabler/icons-react'
13
+ import {
14
+ IconSettings, IconPlug, IconCircleCheck, IconKey, IconEye, IconEyeOff, IconCopy, IconTrash,
15
+ IconBolt, IconCircleCheckFilled, IconHistory, IconRefresh, IconDownload, IconSun, IconStar,
16
+ } from '@tabler/icons-react'
10
17
  import styles from './SettingsView.module.css'
11
18
  import { maskKey } from '../../utils/format.js'
12
19
 
13
- export default function SettingsView({ onToast }) {
20
+ const TEST_OUTCOME_META = {
21
+ ok: { label: 'OK', icon: IconCircleCheckFilled, className: 'testOk' },
22
+ auth_error: { label: 'Auth error', icon: IconKey, className: 'testErr' },
23
+ rate_limited: { label: 'Rate limited', icon: IconRefresh, className: 'testWarn' },
24
+ no_callable_model: { label: 'No callable model', icon: IconKey, className: 'testWarn' },
25
+ fail: { label: 'Failed', icon: IconKey, className: 'testErr' },
26
+ missing_key: { label: 'Missing key', icon: IconKey, className: 'testNeutral' },
27
+ }
28
+
29
+ export default function SettingsView({ onToast, onOpenChangelog, onCheckForUpdate }) {
14
30
  const [config, setConfig] = useState(null)
15
31
  const [searchQuery, setSearchQuery] = useState('')
16
32
  const [expandedCards, setExpandedCards] = useState(new Set())
17
33
  const [revealedKeys, setRevealedKeys] = useState(new Set())
18
34
  const [keyInputs, setKeyInputs] = useState({})
35
+ const [testResults, setTestResults] = useState({}) // { providerKey: { outcome, code?, detail? } }
36
+ const [testingKeys, setTestingKeys] = useState(new Set())
37
+ const [legacyCleanupMsg, setLegacyCleanupMsg] = useState(null)
19
38
 
20
39
  const loadConfig = useCallback(async () => {
21
40
  try {
@@ -142,6 +161,100 @@ export default function SettingsView({ onToast }) {
142
161
  }
143
162
  }
144
163
 
164
+ // 📖 M2: per-provider key test. Fires a parallel auth probe + chat ping
165
+ // 📖 through /api/key/:provider/test and stores the outcome for badge display.
166
+ const testKey = useCallback(async (key) => {
167
+ if (testingKeys.has(key)) return
168
+ setTestingKeys((prev) => new Set(prev).add(key))
169
+ setTestResults((prev) => ({ ...prev, [key]: { outcome: 'pending' } }))
170
+ try {
171
+ const resp = await fetch(`/api/key/${encodeURIComponent(key)}/test`, { method: 'POST' })
172
+ const data = await resp.json().catch(() => ({}))
173
+ if (resp.ok) {
174
+ setTestResults((prev) => ({ ...prev, [key]: data }))
175
+ const meta = TEST_OUTCOME_META[data.outcome] || TEST_OUTCOME_META.fail
176
+ onToast?.(`${key} test: ${meta.label}${data.code ? ` (HTTP ${data.code})` : ''}`, data.outcome === 'ok' ? 'success' : 'info')
177
+ } else {
178
+ setTestResults((prev) => ({ ...prev, [key]: { outcome: 'fail', detail: data.error || 'HTTP ' + resp.status } }))
179
+ onToast?.(`${key} test failed: ${data.error || resp.statusText}`, 'error')
180
+ }
181
+ } catch (err) {
182
+ setTestResults((prev) => ({ ...prev, [key]: { outcome: 'fail', detail: err.message } }))
183
+ onToast?.(`${key} test failed: ${err.message}`, 'error')
184
+ } finally {
185
+ setTestingKeys((prev) => {
186
+ const next = new Set(prev)
187
+ next.delete(key)
188
+ return next
189
+ })
190
+ }
191
+ }, [testingKeys, onToast])
192
+
193
+ // 📖 M2: feature toggles (theme / favorites mode / startup AI scan / shell env)
194
+ // 📖 go through /api/settings/feature which persists to the same config file
195
+ // 📖 the TUI uses. Theme is a tri-state string, not a boolean.
196
+ const toggleFeature = useCallback(async (feature, value) => {
197
+ try {
198
+ const body = value === undefined ? { feature } : { feature, value }
199
+ const resp = await fetch('/api/settings/feature', {
200
+ method: 'POST',
201
+ headers: { 'Content-Type': 'application/json' },
202
+ body: JSON.stringify(body),
203
+ })
204
+ const data = await resp.json()
205
+ if (data.success) {
206
+ await loadConfig()
207
+ onToast?.(`${feature} updated`, 'success')
208
+ } else {
209
+ onToast?.(data.error || 'Failed to update feature', 'error')
210
+ }
211
+ } catch {
212
+ onToast?.('Network error', 'error')
213
+ }
214
+ }, [onToast])
215
+
216
+ const setShellEnv = useCallback(async (enabled) => {
217
+ try {
218
+ const resp = await fetch('/api/shell-env/toggle', {
219
+ method: 'POST',
220
+ headers: { 'Content-Type': 'application/json' },
221
+ body: JSON.stringify({ enabled }),
222
+ })
223
+ const data = await resp.json()
224
+ if (data.success) {
225
+ await loadConfig()
226
+ onToast?.(`Shell env export ${data.enabled ? 'enabled' : 'disabled'} — restart your shell to apply.`, 'success')
227
+ } else {
228
+ onToast?.(data.error || 'Failed to toggle shell env', 'error')
229
+ }
230
+ } catch {
231
+ onToast?.('Network error', 'error')
232
+ }
233
+ }, [onToast])
234
+
235
+ const runLegacyCleanup = useCallback(async () => {
236
+ if (!confirm('Remove discontinued proxy config leftovers? This is safe to run.')) return
237
+ try {
238
+ const resp = await fetch('/api/legacy-cleanup', { method: 'POST' })
239
+ const data = await resp.json()
240
+ const cleaned = (data.removedFiles?.length || 0) + (data.updatedFiles?.length || 0)
241
+ if (data.changed) {
242
+ setLegacyCleanupMsg(`Cleaned ${cleaned} legacy file(s). ${data.errors.length} error(s).`)
243
+ onToast?.(`Legacy proxy cleanup: ${cleaned} file(s) cleaned.`, 'success')
244
+ } else {
245
+ setLegacyCleanupMsg('No discontinued proxy config was found. You are on the stable direct-provider setup.')
246
+ onToast?.('No legacy proxy config found — already on stable setup.', 'info')
247
+ }
248
+ await loadConfig()
249
+ } catch (err) {
250
+ onToast?.(`Legacy cleanup failed: ${err.message}`, 'error')
251
+ }
252
+ }, [onToast])
253
+
254
+ const onCheckUpdatesClick = useCallback(() => {
255
+ onCheckForUpdate?.()
256
+ }, [onCheckForUpdate])
257
+
145
258
  if (!config) {
146
259
  return (
147
260
  <div className={styles.page}>
@@ -163,14 +276,150 @@ export default function SettingsView({ onToast }) {
163
276
  <div className={styles.pageHeader}>
164
277
  <h1 className={styles.pageTitle}>
165
278
  <IconSettings size={24} stroke={1.5} style={{ marginRight: 8, verticalAlign: 'middle' }} />
166
- Provider Settings
279
+ Settings
167
280
  </h1>
168
281
  <p className={styles.pageSubtitle}>
169
- Manage your API keys and provider configurations. Keys are stored locally in{' '}
170
- <code>~/.free-coding-models.json</code>
282
+ API keys, theme, favorites mode, shell env, and update controls.
283
+ All settings are stored locally in <code>~/.free-coding-models.json</code>
284
+ and shared with the TUI.
171
285
  </p>
172
286
  </div>
173
287
 
288
+ {/* ── M2: global feature toggles ────────────────────────────────────── */}
289
+ {config && (
290
+ <section className={styles.featureSection}>
291
+ <h2 className={styles.sectionHeading}>⚙️ Global settings</h2>
292
+ <div className={styles.featureGrid}>
293
+ {/* Theme */}
294
+ <div className={styles.featureRow}>
295
+ <div className={styles.featureLabel}>
296
+ <IconSun size={16} stroke={1.5} />
297
+ <div>
298
+ <div className={styles.featureTitle}>Theme</div>
299
+ <div className={styles.featureDesc}>Tri-state cycle. Auto follows your OS.</div>
300
+ </div>
301
+ </div>
302
+ <select
303
+ className={styles.select}
304
+ value={config.settings?.theme || 'auto'}
305
+ onChange={(e) => toggleFeature('theme', e.target.value)}
306
+ >
307
+ <option value="auto">Auto (OS)</option>
308
+ <option value="dark">Dark</option>
309
+ <option value="light">Light</option>
310
+ </select>
311
+ </div>
312
+
313
+ {/* Favorites display mode */}
314
+ <div className={styles.featureRow}>
315
+ <div className={styles.featureLabel}>
316
+ <IconStar size={16} stroke={1.5} />
317
+ <div>
318
+ <div className={styles.featureTitle}>Favorites pinned + always visible</div>
319
+ <div className={styles.featureDesc}>Favorites bypass filters and stay on top (TUI: Y key).</div>
320
+ </div>
321
+ </div>
322
+ <label className={styles.toggleSwitch}>
323
+ <input
324
+ type="checkbox"
325
+ checked={Boolean(config.settings?.favoritesPinnedAndSticky)}
326
+ onChange={(e) => toggleFeature('favoritesPinnedAndSticky', e.target.checked)}
327
+ />
328
+ <span className={styles.toggleSlider} />
329
+ </label>
330
+ </div>
331
+
332
+ {/* Startup AI Speed Scan */}
333
+ <div className={styles.featureRow}>
334
+ <div className={styles.featureLabel}>
335
+ <IconBolt size={16} stroke={1.5} />
336
+ <div>
337
+ <div className={styles.featureTitle}>Run AI Speed Test on startup</div>
338
+ <div className={styles.featureDesc}>Auto-fire the global benchmark right after launch (TUI: U → 'Enable').</div>
339
+ </div>
340
+ </div>
341
+ <label className={styles.toggleSwitch}>
342
+ <input
343
+ type="checkbox"
344
+ checked={Boolean(config.settings?.runAiSpeedTestOnStartup)}
345
+ onChange={(e) => toggleFeature('runAiSpeedTestOnStartup', e.target.checked)}
346
+ />
347
+ <span className={styles.toggleSlider} />
348
+ </label>
349
+ </div>
350
+
351
+ {/* Shell env export */}
352
+ <div className={styles.featureRow}>
353
+ <div className={styles.featureLabel}>
354
+ <IconCircleCheck size={16} stroke={1.5} />
355
+ <div>
356
+ <div className={styles.featureTitle}>Export API keys to shell rc</div>
357
+ <div className={styles.featureDesc}>Write NVIDIA_API_KEY / GROQ_API_KEY / … to your shell rc file.</div>
358
+ </div>
359
+ </div>
360
+ <label className={styles.toggleSwitch}>
361
+ <input
362
+ type="checkbox"
363
+ checked={Boolean(config.settings?.shellEnvEnabled)}
364
+ onChange={(e) => setShellEnv(e.target.checked)}
365
+ />
366
+ <span className={styles.toggleSlider} />
367
+ </label>
368
+ </div>
369
+
370
+ {/* Update row */}
371
+ <div className={styles.featureRow}>
372
+ <div className={styles.featureLabel}>
373
+ <IconDownload size={16} stroke={1.5} />
374
+ <div>
375
+ <div className={styles.featureTitle}>Check for updates</div>
376
+ <div className={styles.featureDesc}>
377
+ The header chip turns green when a newer npm version is available.
378
+ Use the 'Update now' button to install.
379
+ </div>
380
+ </div>
381
+ </div>
382
+ <div className={styles.featureActions}>
383
+ <button
384
+ className={styles.smallBtn}
385
+ onClick={onCheckUpdatesClick}
386
+ >
387
+ Check now
388
+ </button>
389
+ <button
390
+ className={styles.smallBtn}
391
+ onClick={() => onOpenChangelog?.(null)}
392
+ >
393
+ <IconHistory size={13} stroke={1.5} /> Changelog
394
+ </button>
395
+ </div>
396
+ </div>
397
+
398
+ {/* Legacy proxy cleanup */}
399
+ <div className={styles.featureRow}>
400
+ <div className={styles.featureLabel}>
401
+ <IconRefresh size={16} stroke={1.5} />
402
+ <div>
403
+ <div className={styles.featureTitle}>Cleanup discontinued proxy artifacts</div>
404
+ <div className={styles.featureDesc}>
405
+ Remove old config / env / service leftovers from the old multi-tool proxy.
406
+ </div>
407
+ </div>
408
+ </div>
409
+ <button
410
+ className={styles.smallBtn}
411
+ onClick={runLegacyCleanup}
412
+ >
413
+ Run cleanup
414
+ </button>
415
+ </div>
416
+ </div>
417
+ {legacyCleanupMsg && (
418
+ <div className={styles.notice}>{legacyCleanupMsg}</div>
419
+ )}
420
+ </section>
421
+ )}
422
+
174
423
  <div className={styles.toolbar}>
175
424
  <div className={styles.toolbarSearch}>
176
425
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -221,6 +470,20 @@ export default function SettingsView({ onToast }) {
221
470
 
222
471
  <div className={styles.cardBody}>
223
472
  <div className={styles.cardContent}>
473
+ {p.hasKey && testResults[key] && (
474
+ <div className={`${styles.testBadge} ${styles[`test_${testResults[key].outcome}`] || ''}`}>
475
+ {(() => {
476
+ const meta = TEST_OUTCOME_META[testResults[key].outcome] || TEST_OUTCOME_META.fail
477
+ const Icon = meta.icon
478
+ return (
479
+ <>
480
+ <Icon size={12} stroke={1.5} />
481
+ <span>Last test: {meta.label}{testResults[key].code ? ` (HTTP ${testResults[key].code})` : ''}</span>
482
+ </>
483
+ )
484
+ })()}
485
+ </div>
486
+ )}
224
487
  {p.hasKey && (
225
488
  <div className={styles.keyGroup}>
226
489
  <label className={styles.keyLabel}>Current API Key</label>
@@ -235,6 +498,16 @@ export default function SettingsView({ onToast }) {
235
498
  <button className={styles.actionBtn} onClick={() => copyKey(key)} title="Copy">
236
499
  <IconCopy size={14} stroke={1.5} />
237
500
  </button>
501
+ <button
502
+ className={styles.actionBtn}
503
+ onClick={() => testKey(key)}
504
+ disabled={testingKeys.has(key)}
505
+ title="Test this key against the provider (TUI: T key in Settings)"
506
+ aria-label={`Test key for ${key}`}
507
+ >
508
+ {testingKeys.has(key) ? <span className={styles.testSpinner} /> : <IconBolt size={14} stroke={1.5} />}
509
+ {testingKeys.has(key) ? 'Testing…' : 'Test'}
510
+ </button>
238
511
  <button className={`${styles.actionBtn} ${styles.actionBtnDanger}`} onClick={() => deleteKey(key)} title="Delete Key">
239
512
  <IconTrash size={14} stroke={1.5} />
240
513
  </button>