@bibobonking/opensol 0.0.0-stage → 3.0.1

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 (114) hide show
  1. package/.github/workflows/build.yml +25 -0
  2. package/README.md +74 -2
  3. package/dist/assets/index-BmN7pBji.css +1 -0
  4. package/dist/assets/index-DgNDjkOz.js +301 -0
  5. package/dist/favicon.svg +15 -0
  6. package/dist/index.html +55 -0
  7. package/dist/preview-host.html +33 -0
  8. package/index.html +54 -0
  9. package/package.json +43 -4
  10. package/postcss.config.js +6 -0
  11. package/public/favicon.svg +15 -0
  12. package/public/preview-host.html +33 -0
  13. package/shared/plugin-builder.test.mjs +43 -0
  14. package/shared/plugin-package.d.mts +34 -0
  15. package/shared/plugin-package.mjs +197 -0
  16. package/shared/plugin-package.test.mjs +94 -0
  17. package/src/App.tsx +152 -0
  18. package/src/agent/adapters/anthropic.ts +233 -0
  19. package/src/agent/adapters/gemini.ts +225 -0
  20. package/src/agent/adapters/openai-responses.ts +215 -0
  21. package/src/agent/adapters/openai.ts +200 -0
  22. package/src/agent/adapters/shared.ts +96 -0
  23. package/src/agent/approval.ts +20 -0
  24. package/src/agent/compact.ts +234 -0
  25. package/src/agent/index.ts +36 -0
  26. package/src/agent/model.ts +20 -0
  27. package/src/agent/prompt.ts +107 -0
  28. package/src/agent/queue.test.mjs +111 -0
  29. package/src/agent/queue.ts +84 -0
  30. package/src/agent/subagent.ts +109 -0
  31. package/src/agent/tools.ts +541 -0
  32. package/src/components/ChatView.tsx +403 -0
  33. package/src/components/CommandPalette.tsx +416 -0
  34. package/src/components/Composer.tsx +678 -0
  35. package/src/components/ContextBar.tsx +58 -0
  36. package/src/components/DeployModal.tsx +512 -0
  37. package/src/components/ErrorBoundary.tsx +75 -0
  38. package/src/components/HomeDashboard.tsx +189 -0
  39. package/src/components/Logo.tsx +41 -0
  40. package/src/components/Markdown.tsx +232 -0
  41. package/src/components/MessageItem.tsx +655 -0
  42. package/src/components/ModelPicker.tsx +487 -0
  43. package/src/components/PixleSol.tsx +243 -0
  44. package/src/components/PluginBuilder.tsx +285 -0
  45. package/src/components/PluginManager.tsx +341 -0
  46. package/src/components/PreviewPane.tsx +166 -0
  47. package/src/components/SettingsModal.tsx +2052 -0
  48. package/src/components/Sidebar.tsx +504 -0
  49. package/src/components/Splash.tsx +104 -0
  50. package/src/components/ToolCallCard.tsx +177 -0
  51. package/src/components/TopBar.tsx +182 -0
  52. package/src/components/TrajectoryView.tsx +178 -0
  53. package/src/components/WorkspacePanel.tsx +710 -0
  54. package/src/components/ui.tsx +311 -0
  55. package/src/core/kernel.ts +45 -0
  56. package/src/core/plugins/tools/fs.ts +219 -0
  57. package/src/core/plugins/tools/git.ts +83 -0
  58. package/src/core/plugins/tools/grep.ts +98 -0
  59. package/src/core/plugins/tools/shell.ts +88 -0
  60. package/src/core/plugins/tools/subagent.ts +36 -0
  61. package/src/core/plugins/tools/web.ts +97 -0
  62. package/src/core/services/agent-loop.ts +417 -0
  63. package/src/core/services/approval.ts +66 -0
  64. package/src/core/services/compaction.ts +45 -0
  65. package/src/core/services/llm.ts +32 -0
  66. package/src/core/services/sessions.ts +47 -0
  67. package/src/core/services/subagents.ts +15 -0
  68. package/src/core/services/system-prompt.ts +177 -0
  69. package/src/core/services/tools.ts +135 -0
  70. package/src/core/services/ui.ts +109 -0
  71. package/src/core/types.ts +139 -0
  72. package/src/data/providers.ts +125 -0
  73. package/src/data/skills.ts +68 -0
  74. package/src/index.css +776 -0
  75. package/src/lib/boot.ts +94 -0
  76. package/src/lib/context.ts +75 -0
  77. package/src/lib/conversation.test.mjs +64 -0
  78. package/src/lib/conversation.ts +59 -0
  79. package/src/lib/daemon.ts +475 -0
  80. package/src/lib/hooks.ts +33 -0
  81. package/src/lib/permissions.ts +60 -0
  82. package/src/lib/pixlesol.test.mjs +51 -0
  83. package/src/lib/pixlesol.ts +55 -0
  84. package/src/lib/plugin-folder.test.mjs +145 -0
  85. package/src/lib/plugin-folder.ts +96 -0
  86. package/src/lib/reasoning.ts +87 -0
  87. package/src/lib/sse.ts +75 -0
  88. package/src/lib/themes.test.mjs +72 -0
  89. package/src/lib/themes.ts +52 -0
  90. package/src/lib/usage.test.mjs +72 -0
  91. package/src/lib/usage.ts +155 -0
  92. package/src/lib/utils.ts +73 -0
  93. package/src/main.tsx +16 -0
  94. package/src/plugins/api.test.mjs +77 -0
  95. package/src/plugins/api.ts +113 -0
  96. package/src/plugins/bridge.d.mts +1 -0
  97. package/src/plugins/bridge.mjs +26 -0
  98. package/src/plugins/bridge.test.mjs +17 -0
  99. package/src/plugins/index.ts +52 -0
  100. package/src/plugins/preview.test.mjs +18 -0
  101. package/src/plugins/preview.ts +34 -0
  102. package/src/plugins/runtime.ts +194 -0
  103. package/src/plugins/types.ts +40 -0
  104. package/src/store/chats.ts +278 -0
  105. package/src/store/models.ts +120 -0
  106. package/src/store/settings.ts +445 -0
  107. package/src/store/ui.ts +132 -0
  108. package/src/store/workspace.ts +299 -0
  109. package/src/types.ts +337 -0
  110. package/src/vite-env.d.ts +1 -0
  111. package/tailwind.config.js +83 -0
  112. package/tsconfig.json +22 -0
  113. package/tsconfig.tsbuildinfo +1 -0
  114. package/vite.config.ts +19 -0
@@ -0,0 +1,2052 @@
1
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
2
+ import {
3
+ ArrowDownToLine,
4
+ Bot,
5
+ Check,
6
+ ChevronDown,
7
+ Cloud,
8
+ Cpu,
9
+ Eye,
10
+ EyeOff,
11
+ FilePenLine,
12
+ FilePlus2,
13
+ FileSearch,
14
+ FileText,
15
+ FolderTree,
16
+ GitBranch,
17
+ Globe2,
18
+ HardDrive,
19
+ Info,
20
+ Link2,
21
+ LockKeyhole,
22
+ Monitor,
23
+ Moon,
24
+ Package,
25
+ Palette,
26
+ Plus,
27
+ RefreshCw,
28
+ Rocket,
29
+ RotateCcw,
30
+ Search,
31
+ ShieldCheck,
32
+ SlidersHorizontal,
33
+ Sparkles,
34
+ SquareTerminal,
35
+ Sun,
36
+ Terminal,
37
+ Trash2,
38
+ Upload,
39
+ Wrench,
40
+ X,
41
+ } from 'lucide-react'
42
+ import type { DeployProviderId, Effort, ModelInfo, ProviderId, SearchProvider, Skill, ToolName } from '../types'
43
+ import type { LucideIcon } from 'lucide-react'
44
+ import { EFFORT_LABELS, PROVIDERS, PROVIDER_MAP } from '../data/providers'
45
+ import { STARTER_SKILL } from '../data/skills'
46
+ import {
47
+ DEFAULT_SYSTEM_PROMPT,
48
+ activeModelId,
49
+ providerHasCredentials,
50
+ useSettings,
51
+ } from '../store/settings'
52
+ import { useActiveModel, useModelCatalog } from '../store/models'
53
+ import { useUi } from '../store/ui'
54
+ import { useChats, exportChats, importChats } from '../store/chats'
55
+ import { useWorkspace } from '../store/workspace'
56
+ import { stopAllRuns } from '../agent'
57
+ import { applySnapshot } from '../lib/boot'
58
+ import { executeDaemonTool, resetScope } from '../lib/daemon'
59
+ import { effortOptions, reasoningSummary } from '../lib/reasoning'
60
+ import { PERMISSION_MODES, permissionMode, permissionTools } from '../lib/permissions'
61
+ import { cn, formatBytes, readFileAsText } from '../lib/utils'
62
+ import { LogoMark } from './Logo'
63
+ import { PluginManager } from './PluginManager'
64
+ import { pluginRuntime } from '../plugins'
65
+ import { Modal, Segmented, Spinner, Switch } from './ui'
66
+
67
+ const TABS = [
68
+ { id: 'general', label: 'General', icon: SlidersHorizontal, description: 'Permissions, appearance and conversation display.' },
69
+ { id: 'models', label: 'Models', icon: Cpu, description: 'Providers, encrypted keys, live models and reasoning defaults.' },
70
+ { id: 'tools', label: 'Tools', icon: Wrench, description: 'What OpenSol can do on this machine, and when it must ask.' },
71
+ { id: 'search', label: 'Search', icon: Globe2, description: 'Give the agent current web results with your own key.' },
72
+ { id: 'skills', label: 'Skills', icon: Sparkles, description: 'Instructions added to every request when enabled.' },
73
+ { id: 'plugins', label: 'Plugins', icon: Package, description: 'Install local plugin packages, review what they ask for, and run them once you trust the code.' },
74
+ { id: 'deploy', label: 'Deploy', icon: Rocket, description: 'Hosting credentials and command-line tools.' },
75
+ { id: 'appearance', label: 'Appearance', icon: Palette, description: 'Theme and interface preferences.' },
76
+ { id: 'about', label: 'About', icon: Info, description: 'Local storage, history and reset controls.' },
77
+ ] as const
78
+
79
+ const fieldClass =
80
+ 'h-8 w-full rounded-lg border border-line bg-panel px-2.5 text-[12px] text-ink outline-none transition-colors placeholder:text-faint focus:border-accent/50 disabled:opacity-50'
81
+
82
+ function Label({ children, hint }: { children: ReactNode; hint?: string }) {
83
+ return (
84
+ <div className="mb-1 flex items-baseline justify-between gap-3">
85
+ <span className="text-[11px] font-medium text-ink">{children}</span>
86
+ {hint && <span className="text-[9.5px] text-faint">{hint}</span>}
87
+ </div>
88
+ )
89
+ }
90
+
91
+ function SettingsSection({
92
+ title,
93
+ description,
94
+ children,
95
+ action,
96
+ }: {
97
+ title: string
98
+ description?: string
99
+ children: ReactNode
100
+ action?: ReactNode
101
+ }) {
102
+ return (
103
+ <section className="border-b border-line px-5 py-5 last:border-b-0 sm:px-7">
104
+ <div className="flex items-start justify-between gap-3">
105
+ <div className="min-w-0">
106
+ <h3 className="font-display text-[14px] font-semibold tracking-[-0.01em] text-ink">{title}</h3>
107
+ {description && <p className="mt-1 max-w-[620px] text-[11px] leading-relaxed text-muted">{description}</p>}
108
+ </div>
109
+ {action && <div className="shrink-0">{action}</div>}
110
+ </div>
111
+ <div className="mt-3.5">{children}</div>
112
+ </section>
113
+ )
114
+ }
115
+
116
+ function PasswordInput({
117
+ value,
118
+ onChange,
119
+ placeholder,
120
+ disabled,
121
+ label,
122
+ }: {
123
+ value: string
124
+ onChange: (value: string) => void
125
+ placeholder?: string
126
+ disabled?: boolean
127
+ label: string
128
+ }) {
129
+ const [visible, setVisible] = useState(false)
130
+ return (
131
+ <div className="relative">
132
+ <input
133
+ type={visible ? 'text' : 'password'}
134
+ value={value}
135
+ onChange={(event) => onChange(event.target.value)}
136
+ placeholder={placeholder}
137
+ disabled={disabled}
138
+ autoComplete="off"
139
+ spellCheck={false}
140
+ aria-label={label}
141
+ className={cn(fieldClass, 'pr-8 font-mono text-[11px]')}
142
+ />
143
+ <button
144
+ type="button"
145
+ onClick={() => setVisible((current) => !current)}
146
+ className="absolute right-1.5 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded text-faint hover:text-ink"
147
+ aria-label={visible ? 'Hide secret' : 'Show secret'}
148
+ tabIndex={-1}
149
+ >
150
+ {visible ? <EyeOff size={12} /> : <Eye size={12} />}
151
+ </button>
152
+ </div>
153
+ )
154
+ }
155
+
156
+ const NO_MODELS: ModelInfo[] = []
157
+
158
+ function tokenLabel(value?: number) {
159
+ if (!value) return ''
160
+ return value >= 1000 ? `${Math.round(value / 1000)}k` : String(value)
161
+ }
162
+
163
+ const KEY_FORMATS: Record<string, { prefix: string; label: string }> = {
164
+ openrouter: { prefix: 'sk-or-', label: 'OpenRouter' },
165
+ anthropic: { prefix: 'sk-ant-', label: 'Anthropic' },
166
+ google: { prefix: 'AIza', label: 'Google' },
167
+ groq: { prefix: 'gsk_', label: 'Groq' },
168
+ xai: { prefix: 'xai-', label: 'xAI' },
169
+ }
170
+
171
+ function ProviderCard({
172
+ providerId,
173
+ open,
174
+ onToggle,
175
+ }: {
176
+ providerId: ProviderId
177
+ open: boolean
178
+ onToggle: () => void
179
+ }) {
180
+ const settings = useSettings()
181
+ const selectModel = useSettings((state) => state.selectModel)
182
+ const updateProvider = useSettings((state) => state.updateProvider)
183
+ const saveProviderKey = useSettings((state) => state.saveProviderKey)
184
+ const notify = useUi((state) => state.notify)
185
+ const askConfirm = useUi((state) => state.askConfirm)
186
+ const fetchModels = useModelCatalog((state) => state.fetchModels)
187
+ const models = useModelCatalog((state) => state.byProvider[providerId]) ?? NO_MODELS
188
+ const loading = useModelCatalog((state) => Boolean(state.loading[providerId]))
189
+ const error = useModelCatalog((state) => state.errors[providerId] ?? '')
190
+ const note = useModelCatalog((state) => state.notes[providerId] ?? '')
191
+ const source = useModelCatalog((state) => state.source[providerId] ?? '')
192
+ const [keyDraft, setKeyDraft] = useState('')
193
+ const [manual, setManual] = useState('')
194
+ const [saving, setSaving] = useState(false)
195
+
196
+ const preset = PROVIDER_MAP[providerId]
197
+ const status = settings.providerStatus.find((entry) => entry.id === providerId)
198
+ const configured = providerHasCredentials(settings, providerId)
199
+ const isActive = settings.provider === providerId
200
+ const baseUrl = settings.providers[providerId]?.baseUrl ?? ''
201
+ const keyHint = status?.hint ?? ''
202
+ const activeModel = activeModelId(settings)
203
+ const currentModel = models.find((model) => model.id === activeModel)
204
+
205
+ useEffect(() => {
206
+ if (!open || !configured) return
207
+ const timer = window.setTimeout(() => void fetchModels(providerId), 400)
208
+ return () => window.clearTimeout(timer)
209
+ }, [open, configured, providerId, baseUrl, fetchModels])
210
+
211
+ const saveKey = async () => {
212
+ const key = keyDraft.trim()
213
+ if (!key) {
214
+ notify(`Paste a ${preset.name} API key first.`, 'error')
215
+ return
216
+ }
217
+ const expected = KEY_FORMATS[providerId]
218
+ if (expected && !key.startsWith(expected.prefix)) {
219
+ let hint = ''
220
+ if (providerId === 'openrouter' && key.startsWith('sk-')) {
221
+ hint = ' (looks like a DeepSeek or OpenAI key). If you are using DeepSeek, please paste it under DeepSeek instead.'
222
+ }
223
+ notify(`A key for ${expected.label} must start with "${expected.prefix}"${hint}`, 'error')
224
+ return
225
+ }
226
+ setSaving(true)
227
+ try {
228
+ await saveProviderKey(providerId, key)
229
+ setKeyDraft('')
230
+ notify(`${preset.name} key saved and encrypted.`, 'success')
231
+ void fetchModels(providerId, true)
232
+ } catch (cause) {
233
+ notify(cause instanceof Error ? cause.message : 'Could not save that key.', 'error')
234
+ } finally {
235
+ setSaving(false)
236
+ }
237
+ }
238
+
239
+ const clearKey = async () => {
240
+ const confirmed = await askConfirm({
241
+ title: `Remove the ${preset.name} key?`,
242
+ body: 'The encrypted key is deleted from this computer. You can add it again at any time.',
243
+ confirmLabel: 'Remove key',
244
+ tone: 'danger',
245
+ })
246
+ if (!confirmed) return
247
+ try {
248
+ await saveProviderKey(providerId, '')
249
+ notify(`${preset.name} key removed.`, 'success')
250
+ } catch (cause) {
251
+ notify(cause instanceof Error ? cause.message : 'Could not remove that key.', 'error')
252
+ }
253
+ }
254
+
255
+ const activate = async () => {
256
+ if (models.length === 0) {
257
+ const loaded = await fetchModels(providerId, true)
258
+ if (loaded[0]) selectModel(providerId, loaded[0].id)
259
+ else notify('No usable model was returned by this provider.', 'error')
260
+ return
261
+ }
262
+ selectModel(providerId, models[0].id)
263
+ }
264
+
265
+ return (
266
+ <div className={cn('rounded-xl border transition-colors', open ? 'border-line bg-raised/25' : 'border-line bg-panel')}>
267
+ <button type="button" onClick={onToggle} className="flex w-full items-center gap-3 px-3 py-2.5 text-left">
268
+ <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-line bg-panel font-display text-[12px] font-semibold text-muted">
269
+ {preset.name.slice(0, 1)}
270
+ </span>
271
+ <span className="min-w-0 flex-1">
272
+ <span className="flex items-center gap-2">
273
+ <span className="text-[12px] font-medium text-ink">{preset.name}</span>
274
+ {isActive && (
275
+ <span className="rounded bg-accent-soft px-1.5 py-0.5 text-[8.5px] font-medium text-accent">Active</span>
276
+ )}
277
+ </span>
278
+ <span
279
+ className={cn(
280
+ 'mt-0.5 flex items-center gap-1 text-[9.5px]',
281
+ configured ? 'text-success' : 'text-faint',
282
+ )}
283
+ >
284
+ <span className={cn('h-1 w-1 rounded-full', configured ? 'bg-success' : 'bg-faint')} />
285
+ {configured
286
+ ? providerId === 'custom'
287
+ ? 'Endpoint configured'
288
+ : preset.keyless
289
+ ? 'No key needed'
290
+ : `Connected ${keyHint}`
291
+ : preset.keyless
292
+ ? 'Local endpoint'
293
+ : 'No key'}
294
+ </span>
295
+ </span>
296
+ {!open && (
297
+ <span className="hidden max-w-[170px] truncate text-[9.5px] text-faint sm:block">
298
+ {isActive && activeModel
299
+ ? currentModel?.name ?? activeModel
300
+ : models.length
301
+ ? `${models.length} live models`
302
+ : ''}
303
+ </span>
304
+ )}
305
+ <ChevronDown size={14} className={cn('shrink-0 text-faint transition-transform', open && 'rotate-180')} />
306
+ </button>
307
+
308
+ {open && (
309
+ <div className="border-t border-line px-3 pb-3 pt-3">
310
+ {!preset.keyless && (
311
+ <div className="mb-2.5">
312
+ <Label hint={configured ? 'Saved and encrypted' : 'Required'}>API key</Label>
313
+ <div className="flex items-center gap-1.5">
314
+ <div className="min-w-0 flex-1">
315
+ <PasswordInput
316
+ value={keyDraft}
317
+ onChange={setKeyDraft}
318
+ placeholder={configured ? `Replace key ${keyHint}` : preset.keyHint}
319
+ label={`${preset.name} API key`}
320
+ />
321
+ </div>
322
+ <button
323
+ type="button"
324
+ onClick={() => void saveKey()}
325
+ disabled={!keyDraft.trim() || saving}
326
+ className="inline-flex h-8 shrink-0 items-center gap-1 rounded-lg bg-ink px-2.5 text-[10px] font-medium text-panel disabled:opacity-40"
327
+ >
328
+ {saving ? <Spinner size={10} /> : <LockKeyhole size={10} />}
329
+ Save
330
+ </button>
331
+ </div>
332
+ {configured && (
333
+ <button
334
+ type="button"
335
+ onClick={() => void clearKey()}
336
+ className="mt-1.5 text-[9.5px] font-medium text-faint transition-colors hover:text-danger"
337
+ >
338
+ Remove saved key
339
+ </button>
340
+ )}
341
+ </div>
342
+ )}
343
+
344
+ <div className="mb-2.5">
345
+ <Label hint={providerId === 'custom' ? 'Required' : 'Optional'}>Base URL</Label>
346
+ <input
347
+ value={baseUrl}
348
+ onChange={(event) => updateProvider(providerId, { baseUrl: event.target.value })}
349
+ placeholder={preset.baseUrl || 'https://example.com/v1'}
350
+ spellCheck={false}
351
+ className={cn(fieldClass, 'font-mono text-[11px]')}
352
+ />
353
+ <p className="mt-1 text-[9px] leading-relaxed text-faint">
354
+ {providerId === 'custom'
355
+ ? 'Point this at an OpenAI-compatible endpoint or proxy you control.'
356
+ : `Leave blank for ${preset.baseUrl || 'the provider default'}. Useful for a gateway or local proxy.`}
357
+ </p>
358
+ </div>
359
+
360
+ {preset.note && <p className="mb-2.5 text-[9.5px] leading-relaxed text-faint">{preset.note}</p>}
361
+
362
+ <div>
363
+ <div className="mb-1 flex items-center justify-between gap-2">
364
+ <span className="text-[11px] font-medium text-ink">Model</span>
365
+ <button
366
+ type="button"
367
+ onClick={() => void fetchModels(providerId, true)}
368
+ disabled={!configured || loading}
369
+ className="inline-flex items-center gap-1 text-[9.5px] font-medium text-faint transition-colors hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
370
+ >
371
+ {loading ? <Spinner size={10} /> : <RefreshCw size={10} />}
372
+ {loading ? 'Loading' : 'Refresh live models'}
373
+ </button>
374
+ </div>
375
+
376
+ {error && (
377
+ <p className="mb-1.5 rounded-md border border-danger/20 bg-danger/[0.04] px-2 py-1.5 text-[9.5px] leading-relaxed text-danger">
378
+ {error}
379
+ </p>
380
+ )}
381
+ {note && !error && <p className="mb-1.5 text-[9px] leading-relaxed text-faint">{note}</p>}
382
+
383
+ {models.length > 0 ? (
384
+ <>
385
+ <select
386
+ value={isActive ? activeModel : ''}
387
+ onChange={(event) => {
388
+ if (event.target.value) selectModel(providerId, event.target.value)
389
+ }}
390
+ className={cn(fieldClass, 'font-mono text-[11px]')}
391
+ aria-label={`${preset.name} model`}
392
+ >
393
+ <option value="">Choose a model…</option>
394
+ {isActive && activeModel && !models.some((model) => model.id === activeModel) && (
395
+ <option value={activeModel}>{activeModel} (not listed)</option>
396
+ )}
397
+ {models.map((model) => (
398
+ <option key={model.id} value={model.id}>
399
+ {model.name || model.id}
400
+ {model.context ? ` · ${tokenLabel(model.context)} ctx` : ''}
401
+ </option>
402
+ ))}
403
+ </select>
404
+ <p className="mt-1 text-[9px] text-faint">
405
+ {models.length} usable chat model{models.length === 1 ? '' : 's'}
406
+ {source === 'catalog' ? ' from the models.dev fallback' : ' loaded live from the provider'}.
407
+ </p>
408
+ </>
409
+ ) : (
410
+ <p className="text-[9.5px] leading-relaxed text-faint">
411
+ {loading
412
+ ? 'Loading the provider’s current models…'
413
+ : !configured
414
+ ? providerId === 'custom'
415
+ ? 'Set a base URL, then refresh.'
416
+ : preset.keyless
417
+ ? 'Start the local server, then refresh.'
418
+ : 'Add a key to load the provider’s current models.'
419
+ : 'No usable models were returned. You can still enter an exact model ID.'}
420
+ </p>
421
+ )}
422
+
423
+ <div className="mt-2 flex items-center gap-1.5">
424
+ <input
425
+ value={manual}
426
+ onChange={(event) => setManual(event.target.value)}
427
+ placeholder="or enter a model ID"
428
+ spellCheck={false}
429
+ aria-label={`${preset.name} model ID`}
430
+ className={cn(fieldClass, 'font-mono text-[10.5px]')}
431
+ />
432
+ <button
433
+ type="button"
434
+ onClick={() => {
435
+ const id = manual.trim()
436
+ if (!id) return
437
+ selectModel(providerId, id)
438
+ setManual('')
439
+ }}
440
+ disabled={!manual.trim()}
441
+ className="inline-flex h-8 shrink-0 items-center rounded-lg border border-line px-2.5 text-[10px] font-medium text-ink disabled:opacity-40"
442
+ >
443
+ Use ID
444
+ </button>
445
+ </div>
446
+ </div>
447
+
448
+ <div className="mt-2.5 flex items-center justify-between gap-3">
449
+ <p className="text-[9px] leading-relaxed text-faint">
450
+ {preset.keyless
451
+ ? 'No key is stored for this provider.'
452
+ : configured
453
+ ? 'The daemon decrypts the key only when it calls the provider.'
454
+ : 'The key never enters the page after saving.'}
455
+ </p>
456
+ {configured && !isActive && (
457
+ <button
458
+ type="button"
459
+ onClick={() => void activate()}
460
+ className="shrink-0 text-[9.5px] font-medium text-accent hover:underline"
461
+ >
462
+ Use {preset.name}
463
+ </button>
464
+ )}
465
+ </div>
466
+ </div>
467
+ )}
468
+ </div>
469
+ )
470
+ }
471
+
472
+ function ModelsTab() {
473
+ const settings = useSettings()
474
+ const update = useSettings((state) => state.update)
475
+ const refreshProviders = useSettings((state) => state.refreshProviders)
476
+ const [expanded, setExpanded] = useState<ProviderId | null>(settings.provider)
477
+ const [refreshing, setRefreshing] = useState(false)
478
+ const active = useActiveModel()
479
+ const efforts = effortOptions(active)
480
+ const chosenEffort = efforts.includes(settings.effort) ? settings.effort : efforts[0]
481
+
482
+ return (
483
+ <>
484
+ <SettingsSection
485
+ title="Providers"
486
+ description="Keys and base URLs are stored by the local daemon. Model lists are fetched live from the provider; the bundled catalog is only a fallback when the provider list cannot be reached."
487
+ action={
488
+ <button
489
+ type="button"
490
+ onClick={async () => {
491
+ setRefreshing(true)
492
+ try {
493
+ await refreshProviders()
494
+ } catch (cause) {
495
+ useUi.getState().notify(cause instanceof Error ? cause.message : 'Could not refresh providers.', 'error')
496
+ } finally {
497
+ setRefreshing(false)
498
+ }
499
+ }}
500
+ className="inline-flex h-7 items-center gap-1 rounded-lg border border-line px-2 text-[10px] font-medium text-muted hover:text-ink"
501
+ >
502
+ {refreshing ? <Spinner size={10} /> : <RefreshCw size={10} />}
503
+ Refresh
504
+ </button>
505
+ }
506
+ >
507
+ <div className="mb-3 flex items-start gap-2 rounded-lg border border-line bg-raised/50 px-3 py-2.5">
508
+ <ShieldCheck size={14} className="mt-0.5 shrink-0 text-success" />
509
+ <p className="text-[10.5px] leading-relaxed text-muted">
510
+ Keys are encrypted with AES-256-GCM under <code className="font-mono text-[10px]">~/.maxcode</code>. The
511
+ browser only receives a masked hint; the daemon decrypts a key in memory for the provider request.
512
+ </p>
513
+ </div>
514
+ <div className="space-y-2">
515
+ {PROVIDERS.map((provider) => (
516
+ <ProviderCard
517
+ key={provider.id}
518
+ providerId={provider.id}
519
+ open={expanded === provider.id}
520
+ onToggle={() => setExpanded(expanded === provider.id ? null : provider.id)}
521
+ />
522
+ ))}
523
+ </div>
524
+ </SettingsSection>
525
+
526
+ <SettingsSection
527
+ title="Reasoning defaults"
528
+ description="Only controls advertised by the live model are offered; an unsupported field is never sent."
529
+ >
530
+ {active ? (
531
+ <div className="space-y-2.5">
532
+ {efforts.length > 0 ? (
533
+ <div className="flex items-center justify-between gap-4 rounded-lg border border-line bg-raised/30 px-3 py-2.5">
534
+ <div>
535
+ <p className="text-[11.5px] font-medium text-ink">Effort</p>
536
+ <p className="mt-0.5 text-[9.5px] text-faint">
537
+ Options come from {active.name || active.id} · {active.apiKind}
538
+ </p>
539
+ </div>
540
+ <select
541
+ value={chosenEffort}
542
+ onChange={(event) => update({ effort: event.target.value as Effort })}
543
+ className="h-8 rounded-lg border border-line bg-panel px-2 text-[11px] text-ink outline-none"
544
+ aria-label="Reasoning effort"
545
+ >
546
+ {efforts.map((effort) => (
547
+ <option key={effort} value={effort}>
548
+ {EFFORT_LABELS[effort].label}
549
+ </option>
550
+ ))}
551
+ </select>
552
+ </div>
553
+ ) : (
554
+ <p className="rounded-lg border border-line bg-raised/30 px-3 py-2.5 text-[10px] leading-relaxed text-faint">
555
+ {reasoningSummary(active)}
556
+ </p>
557
+ )}
558
+ </div>
559
+ ) : (
560
+ <p className="rounded-lg border border-line bg-raised/30 px-3 py-3 text-[10.5px] leading-relaxed text-faint">
561
+ Choose a model in the composer to see the reasoning controls this provider actually advertises.
562
+ </p>
563
+ )}
564
+ </SettingsSection>
565
+
566
+ <SettingsSection
567
+ title="Advanced request settings"
568
+ description="Defaults for every provider. The adapter ignores fields the selected model cannot accept."
569
+ >
570
+ <details className="group/advanced rounded-xl border border-line bg-raised/20">
571
+ <summary className="flex cursor-pointer list-none items-center justify-between px-3 py-2.5 text-[11.5px] font-medium text-ink [&::-webkit-details-marker]:hidden">
572
+ Request controls and system prompt
573
+ <ChevronDown size={13} className="text-faint transition-transform group-open/advanced:rotate-180" />
574
+ </summary>
575
+ <div className="border-t border-line px-3 py-3">
576
+ <div className="grid gap-3 sm:grid-cols-2">
577
+ <div>
578
+ <Label hint="0 – 2">Temperature</Label>
579
+ <input
580
+ type="number"
581
+ min={0}
582
+ max={2}
583
+ step={0.1}
584
+ value={settings.temperature}
585
+ onChange={(event) => {
586
+ const value = Number(event.target.value)
587
+ if (Number.isFinite(value)) update({ temperature: Math.min(2, Math.max(0, value)) })
588
+ }}
589
+ className={fieldClass}
590
+ />
591
+ </div>
592
+ <div>
593
+ <Label hint="256 – 200,000">Max output tokens</Label>
594
+ <input
595
+ type="number"
596
+ min={256}
597
+ max={200000}
598
+ step={256}
599
+ value={settings.maxTokens}
600
+ onChange={(event) => {
601
+ const value = Number(event.target.value)
602
+ if (Number.isFinite(value)) {
603
+ update({ maxTokens: Math.min(200000, Math.max(256, Math.round(value))) })
604
+ }
605
+ }}
606
+ className={fieldClass}
607
+ />
608
+ </div>
609
+ </div>
610
+ <div className="mt-3">
611
+ <div className="mb-1 flex items-center justify-between gap-3">
612
+ <span className="text-[11px] font-medium text-ink">System prompt</span>
613
+ <button
614
+ type="button"
615
+ onClick={() => update({ systemPrompt: DEFAULT_SYSTEM_PROMPT })}
616
+ className="inline-flex items-center gap-1 text-[9.5px] font-medium text-faint hover:text-ink"
617
+ >
618
+ <RotateCcw size={10} /> Restore default
619
+ </button>
620
+ </div>
621
+ <textarea
622
+ value={settings.systemPrompt}
623
+ onChange={(event) => update({ systemPrompt: event.target.value })}
624
+ rows={9}
625
+ spellCheck={false}
626
+ className="thin-scroll w-full resize-y rounded-lg border border-line bg-panel px-2.5 py-2 font-mono text-[10.5px] leading-relaxed text-ink outline-none focus:border-accent/50"
627
+ />
628
+ <p className="mt-1 text-[9px] leading-relaxed text-faint">
629
+ Enabled skill instructions and the current workspace/git context are appended after this prompt.
630
+ </p>
631
+ </div>
632
+ </div>
633
+ </details>
634
+ </SettingsSection>
635
+ </>
636
+ )
637
+ }
638
+
639
+ const TOOL_ORDER: Array<{ title: string; tools: ToolName[] }> = [
640
+ {
641
+ title: 'Workspace and shell',
642
+ tools: ['read_file', 'list_files', 'grep', 'write_file', 'edit_file', 'rewrite_file', 'shell', 'git'],
643
+ },
644
+ { title: 'Web', tools: ['web_search', 'read_url'] },
645
+ { title: 'Delegation', tools: ['subagent'] },
646
+ ]
647
+
648
+ const TOOL_ICONS: Record<ToolName, LucideIcon> = {
649
+ shell: SquareTerminal,
650
+ read_file: FileText,
651
+ list_files: FolderTree,
652
+ write_file: FilePlus2,
653
+ edit_file: FilePenLine,
654
+ rewrite_file: FilePenLine,
655
+ grep: FileSearch,
656
+ web_search: Globe2,
657
+ read_url: Link2,
658
+ git: GitBranch,
659
+ subagent: Bot,
660
+ }
661
+
662
+ const TOOL_COPY: Record<ToolName, { label: string; description: string }> = {
663
+ shell: { label: 'Shell', description: 'Run commands in the workspace root. Plan mode allows read-only commands only.' },
664
+ read_file: { label: 'Read file', description: 'Inspect a file on disk before changing it.' },
665
+ list_files: { label: 'List files', description: 'Explore the real workspace tree.' },
666
+ write_file: { label: 'Write file', description: 'Create a file or replace its complete contents.' },
667
+ edit_file: { label: 'Edit file', description: 'Replace an exact passage in a file.' },
668
+ rewrite_file: { label: 'Rewrite file', description: 'Replace the entire content of an existing file.' },
669
+ grep: { label: 'Grep', description: 'Search file contents with a regular expression.' },
670
+ web_search: { label: 'Web search', description: 'Find current pages and cite the sources.' },
671
+ read_url: { label: 'Read URL', description: 'Open a public http(s) page as plain text.' },
672
+ git: { label: 'Git', description: 'Inspect or change repository state. Plan mode is read-only.' },
673
+ subagent: { label: 'Subagent', description: 'Delegate a read-only investigation and return a concise report.' },
674
+ }
675
+
676
+ function ToolsTab() {
677
+ const settings = useSettings()
678
+ const updateTool = useSettings((state) => state.updateTool)
679
+ const openSettings = useUi((state) => state.openSettings)
680
+
681
+ return (
682
+ <SettingsSection
683
+ title="Tool permissions"
684
+ description="Ask adds an approval card to the conversation. Auto runs immediately. Disabled tools are not sent to the model at all. Plan mode always hides write tools."
685
+ >
686
+ <div className="space-y-5">
687
+ {TOOL_ORDER.map((group) => (
688
+ <div key={group.title}>
689
+ <p className="mb-2 text-[10px] font-medium text-faint">{group.title}</p>
690
+ <div className="overflow-hidden rounded-xl border border-line">
691
+ {group.tools.map((tool, index) => {
692
+ const config = settings.tools[tool]
693
+ const Icon = TOOL_ICONS[tool]
694
+ const copy = TOOL_COPY[tool]
695
+ return (
696
+ <div
697
+ key={tool}
698
+ className={cn(
699
+ 'flex flex-col gap-2.5 px-3 py-3 sm:flex-row sm:items-center',
700
+ index !== 0 && 'border-t border-line',
701
+ !config.enabled && 'opacity-60',
702
+ )}
703
+ >
704
+ <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-line bg-panel text-muted">
705
+ <Icon size={13} />
706
+ </span>
707
+ <div className="min-w-0 flex-1">
708
+ <div className="flex items-center gap-2">
709
+ <p className="text-[11.5px] font-medium text-ink">{copy.label}</p>
710
+ <span className="font-mono text-[8.5px] text-faint">{tool}</span>
711
+ </div>
712
+ <p className="mt-0.5 text-[9.5px] leading-relaxed text-muted">{copy.description}</p>
713
+ {group.title === 'Web' && !settings.webSearchEnabled && (
714
+ <button
715
+ type="button"
716
+ onClick={() => openSettings('search')}
717
+ className="mt-1 text-[9.5px] font-medium text-warning hover:underline"
718
+ >
719
+ Web access is off. Turn it on in Search.
720
+ </button>
721
+ )}
722
+ </div>
723
+ <div className="flex shrink-0 items-center gap-2 pl-9 sm:pl-0">
724
+ {config.enabled && (
725
+ <Segmented<'auto' | 'ask'>
726
+ value={config.approval}
727
+ onChange={(approval) => updateTool(tool, { approval })}
728
+ options={[
729
+ { value: 'auto', label: 'Auto', title: 'Run without asking' },
730
+ { value: 'ask', label: 'Ask', title: 'Ask before running' },
731
+ ]}
732
+ />
733
+ )}
734
+ <Switch
735
+ checked={config.enabled}
736
+ onChange={(enabled) => updateTool(tool, { enabled })}
737
+ label={`Enable ${copy.label}`}
738
+ />
739
+ </div>
740
+ </div>
741
+ )
742
+ })}
743
+ </div>
744
+ </div>
745
+ ))}
746
+ </div>
747
+ <div className="mt-3 flex items-start gap-1.5 rounded-lg border border-line bg-raised/40 px-3 py-2.5 text-[9.5px] leading-relaxed text-faint">
748
+ <ShieldCheck size={11} className="mt-0.5 shrink-0" />
749
+ Shell and Git approval are on by default. Approvals apply to the open workspace only; the daemon rejects paths
750
+ outside it with <code className="font-mono">..</code> or absolute escapes.
751
+ </div>
752
+ </SettingsSection>
753
+ )
754
+ }
755
+
756
+ const SEARCH_OPTIONS: Array<{ id: SearchProvider; name: string; description: string; keyless?: boolean }> = [
757
+ {
758
+ id: 'duckduckgo',
759
+ name: 'DuckDuckGo',
760
+ description: 'No key. HTML results with a Wikipedia fallback; coverage is limited.',
761
+ keyless: true,
762
+ },
763
+ { id: 'tavily', name: 'Tavily', description: 'Built for AI agents. Search in, clean results out.' },
764
+ { id: 'brave', name: 'Brave Search', description: 'Independent web index with a free tier.' },
765
+ { id: 'serper', name: 'Serper', description: 'Google results through a simple API.' },
766
+ ]
767
+
768
+ function SearchTab() {
769
+ const settings = useSettings()
770
+ const update = useSettings((state) => state.update)
771
+ const saveSearchKey = useSettings((state) => state.saveSearchKey)
772
+ const notify = useUi((state) => state.notify)
773
+ const [keyDraft, setKeyDraft] = useState('')
774
+ const [saving, setSaving] = useState(false)
775
+ const [testing, setTesting] = useState(false)
776
+ const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null)
777
+ const searchSecret = settings.secretStatus?.search
778
+ const keyless = settings.searchProvider === 'duckduckgo'
779
+ const configured = Boolean(searchSecret?.configured)
780
+
781
+ const save = async () => {
782
+ if (!keyDraft.trim()) {
783
+ notify('Paste a search API key first.', 'error')
784
+ return
785
+ }
786
+ setSaving(true)
787
+ try {
788
+ await saveSearchKey(keyDraft.trim())
789
+ setKeyDraft('')
790
+ setTestResult(null)
791
+ notify('Search key saved and encrypted.', 'success')
792
+ } catch (cause) {
793
+ notify(cause instanceof Error ? cause.message : 'Could not save that key.', 'error')
794
+ } finally {
795
+ setSaving(false)
796
+ }
797
+ }
798
+
799
+ const clear = async () => {
800
+ try {
801
+ await saveSearchKey('')
802
+ setTestResult(null)
803
+ notify('Search key removed.', 'success')
804
+ } catch (cause) {
805
+ notify(cause instanceof Error ? cause.message : 'Could not remove that key.', 'error')
806
+ }
807
+ }
808
+
809
+ const test = async () => {
810
+ setTesting(true)
811
+ setTestResult(null)
812
+ try {
813
+ const response = await executeDaemonTool(
814
+ 'web_search',
815
+ { query: 'OpenSol local coding agent', provider: settings.searchProvider },
816
+ 'build',
817
+ )
818
+ const result = response.result && typeof response.result === 'object' ? (response.result as Record<string, unknown>) : {}
819
+ const results = Array.isArray(result.results) ? result.results : []
820
+ setTestResult({
821
+ ok: results.length > 0,
822
+ message:
823
+ results.length > 0
824
+ ? `${results.length} results via ${String(result.provider ?? settings.searchProvider)}.`
825
+ : 'No results were returned. Try another provider or query.',
826
+ })
827
+ } catch (cause) {
828
+ setTestResult({ ok: false, message: cause instanceof Error ? cause.message : 'Search failed.' })
829
+ } finally {
830
+ setTesting(false)
831
+ }
832
+ }
833
+
834
+ return (
835
+ <>
836
+ <SettingsSection
837
+ title="Web access"
838
+ description="When enabled, the agent may search and read public pages. The daemon performs the requests; this page never sees the raw results."
839
+ >
840
+ <div className="flex items-center justify-between gap-4 rounded-lg border border-line bg-raised/30 px-3 py-2.5">
841
+ <div>
842
+ <p className="text-[11.5px] font-medium text-ink">Allow web tools</p>
843
+ <p className="mt-0.5 text-[9.5px] text-faint">
844
+ Adds web_search and read_url when they are also enabled under Tools.
845
+ </p>
846
+ </div>
847
+ <Switch
848
+ checked={settings.webSearchEnabled}
849
+ onChange={(webSearchEnabled) => update({ webSearchEnabled })}
850
+ label="Allow web tools"
851
+ />
852
+ </div>
853
+ </SettingsSection>
854
+
855
+ <SettingsSection title="Search provider" description="Choose where current results come from. A key is encrypted by the daemon.">
856
+ <div className="grid gap-2 sm:grid-cols-2">
857
+ {SEARCH_OPTIONS.map((option) => {
858
+ const selected = settings.searchProvider === option.id
859
+ return (
860
+ <button
861
+ key={option.id}
862
+ type="button"
863
+ onClick={() => {
864
+ update({ searchProvider: option.id })
865
+ setTestResult(null)
866
+ }}
867
+ className={cn(
868
+ 'rounded-lg border p-3 text-left transition-colors',
869
+ selected ? 'border-accent/35 bg-accent-soft/40' : 'border-line bg-panel hover:bg-raised/40',
870
+ )}
871
+ >
872
+ <span className="flex items-center justify-between gap-2">
873
+ <span className="text-[11.5px] font-medium text-ink">{option.name}</span>
874
+ <span
875
+ className={cn(
876
+ 'flex h-3.5 w-3.5 items-center justify-center rounded-full border',
877
+ selected ? 'border-accent bg-accent text-on-accent' : 'border-line',
878
+ )}
879
+ >
880
+ {selected && <Check size={8} strokeWidth={3} />}
881
+ </span>
882
+ </span>
883
+ <span className="mt-1 block text-[9.5px] leading-relaxed text-muted">{option.description}</span>
884
+ {option.keyless && (
885
+ <span className="mt-1.5 inline-block rounded bg-raised px-1.5 py-0.5 text-[8.5px] font-medium text-faint">
886
+ No key
887
+ </span>
888
+ )}
889
+ </button>
890
+ )
891
+ })}
892
+ </div>
893
+
894
+ {!keyless && (
895
+ <div className="mt-3">
896
+ <Label hint={configured ? `Saved ${searchSecret?.hint ?? ''}` : 'Required'}>
897
+ {settings.searchProvider === 'tavily'
898
+ ? 'Tavily API key'
899
+ : settings.searchProvider === 'brave'
900
+ ? 'Brave Search API key'
901
+ : 'Serper API key'}
902
+ </Label>
903
+ <div className="flex items-center gap-1.5">
904
+ <div className="min-w-0 flex-1">
905
+ <PasswordInput
906
+ value={keyDraft}
907
+ onChange={setKeyDraft}
908
+ placeholder={
909
+ settings.searchProvider === 'tavily' ? 'tvly-…' : settings.searchProvider === 'brave' ? 'BSA…' : '…'
910
+ }
911
+ label="Search API key"
912
+ />
913
+ </div>
914
+ <button
915
+ type="button"
916
+ onClick={() => void save()}
917
+ disabled={!keyDraft.trim() || saving}
918
+ className="inline-flex h-8 shrink-0 items-center gap-1 rounded-lg bg-ink px-2.5 text-[10px] font-medium text-panel disabled:opacity-40"
919
+ >
920
+ {saving ? <Spinner size={10} /> : <LockKeyhole size={10} />}
921
+ Save
922
+ </button>
923
+ </div>
924
+ {configured && (
925
+ <button
926
+ type="button"
927
+ onClick={() => void clear()}
928
+ className="mt-1.5 text-[9.5px] font-medium text-faint hover:text-danger"
929
+ >
930
+ Remove saved key
931
+ </button>
932
+ )}
933
+ <p className="mt-1 text-[9px] leading-relaxed text-faint">
934
+ One key is stored. If you switch providers, replace the key with one issued by the new provider.
935
+ </p>
936
+ </div>
937
+ )}
938
+
939
+ <div className="mt-3 flex flex-wrap items-center gap-2">
940
+ <button
941
+ type="button"
942
+ onClick={() => void test()}
943
+ disabled={testing || (!keyless && !configured)}
944
+ className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-line bg-panel px-2.5 text-[10.5px] font-medium text-ink transition-colors hover:bg-raised disabled:cursor-not-allowed disabled:opacity-40"
945
+ >
946
+ {testing ? <Spinner size={12} /> : <Search size={12} />}
947
+ {testing ? 'Testing…' : 'Test search'}
948
+ </button>
949
+ {!keyless && !configured && <span className="text-[9.5px] text-faint">Add a key to test.</span>}
950
+ </div>
951
+
952
+ {testResult && (
953
+ <div
954
+ className={cn(
955
+ 'mt-2.5 rounded-lg border px-3 py-2 text-[10px] leading-relaxed',
956
+ testResult.ok
957
+ ? 'border-success/25 bg-success/[0.04] text-success'
958
+ : 'border-warning/30 bg-warning/[0.04] text-warning',
959
+ )}
960
+ >
961
+ {testResult.message}
962
+ </div>
963
+ )}
964
+ </SettingsSection>
965
+ </>
966
+ )
967
+ }
968
+
969
+ interface SkillDraft {
970
+ id?: string
971
+ name: string
972
+ description: string
973
+ instructions: string
974
+ }
975
+
976
+ function newSkillId() {
977
+ return typeof crypto !== 'undefined' && 'randomUUID' in crypto ? `skill_${crypto.randomUUID()}` : `skill_${Date.now()}`
978
+ }
979
+
980
+ function SkillsTab() {
981
+ const settings = useSettings()
982
+ const addSkill = useSettings((state) => state.addSkill)
983
+ const updateSkill = useSettings((state) => state.updateSkill)
984
+ const removeSkill = useSettings((state) => state.removeSkill)
985
+ const askConfirm = useUi((state) => state.askConfirm)
986
+ const notify = useUi((state) => state.notify)
987
+ const [draft, setDraft] = useState<SkillDraft | null>(null)
988
+ const fileRef = useRef<HTMLInputElement>(null)
989
+ const active = settings.skills.filter((skill) => skill.enabled).length
990
+
991
+ const loadFile = async (file: File) => {
992
+ try {
993
+ const text = await readFileAsText(file)
994
+ const heading = /^#\s+(.+)$/m.exec(text)
995
+ const name = heading?.[1]?.trim() || file.name.replace(/\.[^.]+$/, '')
996
+ const body = heading ? text.replace(heading[0], '').trim() : text.trim()
997
+ const paragraph = body.split(/\n\s*\n/).find((part) => part.trim() && !part.trim().startsWith('#'))
998
+ setDraft({
999
+ name,
1000
+ description: (paragraph ?? '').replace(/[#>*`]/g, '').replace(/\s+/g, ' ').trim().slice(0, 180),
1001
+ instructions: text.trim(),
1002
+ })
1003
+ notify(`Loaded ${file.name}. Review it, then save the skill.`, 'info')
1004
+ } catch (cause) {
1005
+ notify(cause instanceof Error ? cause.message : 'Could not read that skill.', 'error')
1006
+ }
1007
+ }
1008
+
1009
+ const save = () => {
1010
+ if (!draft) return
1011
+ const name = draft.name.trim()
1012
+ if (!name) {
1013
+ notify('Give the skill a name.', 'error')
1014
+ return
1015
+ }
1016
+ if (!draft.instructions.trim()) {
1017
+ notify('Add instructions for the skill.', 'error')
1018
+ return
1019
+ }
1020
+ if (draft.id) {
1021
+ updateSkill(draft.id, {
1022
+ name,
1023
+ description: draft.description.trim(),
1024
+ instructions: draft.instructions.trim(),
1025
+ })
1026
+ notify('Skill updated.', 'success')
1027
+ } else {
1028
+ addSkill({
1029
+ id: newSkillId(),
1030
+ name,
1031
+ description: draft.description.trim() || 'Custom skill',
1032
+ instructions: draft.instructions.trim(),
1033
+ enabled: true,
1034
+ })
1035
+ notify('Skill saved and enabled.', 'success')
1036
+ }
1037
+ setDraft(null)
1038
+ }
1039
+
1040
+ const remove = async (skill: Skill) => {
1041
+ const confirmed = await askConfirm({
1042
+ title: 'Delete this skill?',
1043
+ body: `“${skill.name}” will be removed from this computer.`,
1044
+ confirmLabel: 'Delete skill',
1045
+ tone: 'danger',
1046
+ })
1047
+ if (confirmed) removeSkill(skill.id)
1048
+ }
1049
+
1050
+ return (
1051
+ <SettingsSection
1052
+ title="Loaded skills"
1053
+ description={`${active} of ${settings.skills.length} enabled. Enabled instructions are appended to the system prompt for every request.`}
1054
+ >
1055
+ <div className="mb-3 flex flex-wrap gap-2">
1056
+ <button
1057
+ type="button"
1058
+ onClick={() => setDraft({ name: '', description: '', instructions: '' })}
1059
+ className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-ink px-2.5 text-[10.5px] font-medium text-panel"
1060
+ >
1061
+ <Plus size={12} /> New skill
1062
+ </button>
1063
+ <button
1064
+ type="button"
1065
+ onClick={() => fileRef.current?.click()}
1066
+ className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-line bg-panel px-2.5 text-[10.5px] font-medium text-ink hover:bg-raised"
1067
+ >
1068
+ <Upload size={12} /> Load skill file
1069
+ </button>
1070
+ <input
1071
+ ref={fileRef}
1072
+ type="file"
1073
+ accept=".md,.markdown,.txt"
1074
+ className="hidden"
1075
+ onChange={(event) => {
1076
+ const file = event.target.files?.[0]
1077
+ if (file) void loadFile(file)
1078
+ event.target.value = ''
1079
+ }}
1080
+ />
1081
+ </div>
1082
+
1083
+ {draft && (
1084
+ <div className="mb-3 rounded-xl border border-accent/30 bg-accent-soft/20 p-3">
1085
+ <div className="mb-2 flex items-center justify-between">
1086
+ <p className="text-[11px] font-medium text-ink">{draft.id ? 'Edit skill' : 'Load a skill'}</p>
1087
+ <button type="button" className="icon-button h-6 w-6" onClick={() => setDraft(null)} aria-label="Close editor">
1088
+ <X size={12} />
1089
+ </button>
1090
+ </div>
1091
+ <div className="grid gap-2 sm:grid-cols-2">
1092
+ <div>
1093
+ <Label>Name</Label>
1094
+ <input
1095
+ value={draft.name}
1096
+ onChange={(event) => setDraft({ ...draft, name: event.target.value })}
1097
+ placeholder="API contract reviewer"
1098
+ className={fieldClass}
1099
+ autoFocus
1100
+ />
1101
+ </div>
1102
+ <div>
1103
+ <Label hint="Shown in the skills list">Description</Label>
1104
+ <input
1105
+ value={draft.description}
1106
+ onChange={(event) => setDraft({ ...draft, description: event.target.value })}
1107
+ placeholder="Checks a diff against an API contract"
1108
+ className={fieldClass}
1109
+ />
1110
+ </div>
1111
+ </div>
1112
+ <div className="mt-2">
1113
+ <Label hint="Markdown">Instructions</Label>
1114
+ <textarea
1115
+ value={draft.instructions}
1116
+ onChange={(event) => setDraft({ ...draft, instructions: event.target.value })}
1117
+ placeholder={STARTER_SKILL}
1118
+ rows={8}
1119
+ className="thin-scroll w-full resize-y rounded-lg border border-line bg-panel px-2.5 py-2 font-mono text-[10.5px] leading-relaxed text-ink outline-none focus:border-accent/50"
1120
+ />
1121
+ </div>
1122
+ <div className="mt-2 flex justify-end gap-2">
1123
+ <button type="button" className="chip h-7 text-[10.5px]" onClick={() => setDraft(null)}>
1124
+ Cancel
1125
+ </button>
1126
+ <button
1127
+ type="button"
1128
+ onClick={save}
1129
+ className="inline-flex h-7 items-center gap-1 rounded-lg bg-accent px-2.5 text-[10.5px] font-medium text-on-accent hover:bg-accent/90"
1130
+ >
1131
+ <Check size={11} /> Save skill
1132
+ </button>
1133
+ </div>
1134
+ </div>
1135
+ )}
1136
+
1137
+ <div className="overflow-hidden rounded-xl border border-line">
1138
+ {settings.skills.map((skill, index) => (
1139
+ <div key={skill.id} className={cn('px-3 py-3', index !== 0 && 'border-t border-line')}>
1140
+ <div className="flex items-start gap-2.5">
1141
+ <span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-accent-soft text-accent">
1142
+ <Sparkles size={11} />
1143
+ </span>
1144
+ <div className="min-w-0 flex-1">
1145
+ <div className="flex flex-wrap items-center gap-1.5">
1146
+ <p className="text-[11.5px] font-medium text-ink">{skill.name}</p>
1147
+ {skill.builtIn && (
1148
+ <span className="rounded bg-raised px-1.5 py-0.5 text-[8px] font-medium text-faint">Built-in</span>
1149
+ )}
1150
+ <span className={cn('text-[8.5px]', skill.enabled ? 'text-success' : 'text-faint')}>
1151
+ {skill.enabled ? 'Enabled' : 'Off'}
1152
+ </span>
1153
+ </div>
1154
+ <p className="mt-0.5 text-[9.5px] leading-relaxed text-muted">{skill.description}</p>
1155
+ <details className="group/skill mt-1">
1156
+ <summary className="inline-flex cursor-pointer list-none items-center gap-1 text-[9px] font-medium text-faint hover:text-muted [&::-webkit-details-marker]:hidden">
1157
+ View instructions
1158
+ <ChevronDown size={9} className="transition-transform group-open/skill:rotate-180" />
1159
+ </summary>
1160
+ <pre className="thin-scroll mt-1.5 max-h-44 overflow-auto whitespace-pre-wrap rounded-lg border border-line bg-raised/40 p-2 font-mono text-[9.5px] leading-relaxed text-muted">
1161
+ {skill.instructions}
1162
+ </pre>
1163
+ </details>
1164
+ </div>
1165
+ <div className="flex shrink-0 items-center gap-1">
1166
+ {!skill.builtIn && (
1167
+ <>
1168
+ <button
1169
+ type="button"
1170
+ onClick={() =>
1171
+ setDraft({
1172
+ id: skill.id,
1173
+ name: skill.name,
1174
+ description: skill.description,
1175
+ instructions: skill.instructions,
1176
+ })
1177
+ }
1178
+ className="icon-button h-7 w-7"
1179
+ aria-label={`Edit ${skill.name}`}
1180
+ title="Edit skill"
1181
+ >
1182
+ <FilePenLine size={12} />
1183
+ </button>
1184
+ <button
1185
+ type="button"
1186
+ onClick={() => void remove(skill)}
1187
+ className="icon-button h-7 w-7 hover:text-danger"
1188
+ aria-label={`Delete ${skill.name}`}
1189
+ title="Delete skill"
1190
+ >
1191
+ <Trash2 size={12} />
1192
+ </button>
1193
+ </>
1194
+ )}
1195
+ <Switch
1196
+ checked={skill.enabled}
1197
+ onChange={(enabled) => updateSkill(skill.id, { enabled })}
1198
+ label={`Enable ${skill.name}`}
1199
+ />
1200
+ </div>
1201
+ </div>
1202
+ </div>
1203
+ ))}
1204
+ </div>
1205
+ </SettingsSection>
1206
+ )
1207
+ }
1208
+
1209
+ const DEPLOY_META: Record<
1210
+ DeployProviderId,
1211
+ {
1212
+ label: string
1213
+ short: string
1214
+ icon: LucideIcon
1215
+ description: string
1216
+ fields: Array<{ key: string; label: string; secret: boolean; hint: string; placeholder: string }>
1217
+ }
1218
+ > = {
1219
+ cloudflare: {
1220
+ label: 'Cloudflare Pages',
1221
+ short: 'Cloudflare',
1222
+ icon: Cloud,
1223
+ description: 'API token + account id. Create a token with the Cloudflare Pages edit permission.',
1224
+ fields: [
1225
+ { key: 'apiToken', label: 'API token', secret: true, hint: 'Required', placeholder: 'Cloudflare API token' },
1226
+ { key: 'accountId', label: 'Account ID', secret: false, hint: 'Required', placeholder: 'a1b2c3…' },
1227
+ ],
1228
+ },
1229
+ netlify: {
1230
+ label: 'Netlify',
1231
+ short: 'Netlify',
1232
+ icon: Globe2,
1233
+ description: 'Personal access token. A new site is created when no site id is set.',
1234
+ fields: [
1235
+ { key: 'authToken', label: 'Personal access token', secret: true, hint: 'Required', placeholder: 'nfp_…' },
1236
+ { key: 'siteId', label: 'Site ID', secret: false, hint: 'Optional', placeholder: 'Existing site id' },
1237
+ ],
1238
+ },
1239
+ vercel: {
1240
+ label: 'Vercel',
1241
+ short: 'Vercel',
1242
+ icon: Cloud,
1243
+ description: 'Access token. The CLI deploys a new production build through npx when needed.',
1244
+ fields: [
1245
+ { key: 'token', label: 'Access token', secret: true, hint: 'Required', placeholder: 'vercel token' },
1246
+ { key: 'teamId', label: 'Team / scope ID', secret: false, hint: 'Optional', placeholder: 'team_…' },
1247
+ ],
1248
+ },
1249
+ }
1250
+
1251
+ function DeployCredentialCard({ providerId }: { providerId: DeployProviderId }) {
1252
+ const meta = DEPLOY_META[providerId]
1253
+ const secret = useSettings((state) => state.deploySecrets?.[providerId])
1254
+ const tool = useSettings((state) => state.deployTools?.[providerId])
1255
+ const save = useSettings((state) => state.saveDeployCredentials)
1256
+ const notify = useUi((state) => state.notify)
1257
+ const askConfirm = useUi((state) => state.askConfirm)
1258
+ const [open, setOpen] = useState(false)
1259
+ const [drafts, setDrafts] = useState<Record<string, string>>({})
1260
+ const [secretDraft, setSecretDraft] = useState('')
1261
+ const [saving, setSaving] = useState(false)
1262
+
1263
+ const nonSecret = meta.fields.filter((field) => !field.secret)
1264
+ const secretField = meta.fields.find((field) => field.secret)
1265
+
1266
+ useEffect(() => {
1267
+ const next: Record<string, string> = {}
1268
+ for (const field of nonSecret) next[field.key] = secret?.fields?.[field.key] ?? ''
1269
+ setDrafts(next)
1270
+ }, [secret, nonSecret.length])
1271
+
1272
+ const saveCreds = async () => {
1273
+ setSaving(true)
1274
+ try {
1275
+ const payload: Record<string, string> = { ...drafts }
1276
+ if (secretDraft.trim() && secretField) payload[secretField.key] = secretDraft.trim()
1277
+ await save(providerId, payload)
1278
+ setSecretDraft('')
1279
+ notify(`${meta.label} credentials saved and encrypted.`, 'success')
1280
+ } catch (cause) {
1281
+ notify(cause instanceof Error ? cause.message : 'Could not save those credentials.', 'error')
1282
+ } finally {
1283
+ setSaving(false)
1284
+ }
1285
+ }
1286
+
1287
+ const clearCreds = async () => {
1288
+ const confirmed = await askConfirm({
1289
+ title: `Remove ${meta.label} credentials?`,
1290
+ body: 'The encrypted token and ids are deleted from this computer. Deployments for this host will stop until you add them again.',
1291
+ confirmLabel: 'Remove credentials',
1292
+ tone: 'danger',
1293
+ })
1294
+ if (!confirmed) return
1295
+ try {
1296
+ const payload: Record<string, string> = {}
1297
+ for (const field of meta.fields) payload[field.key] = ''
1298
+ await save(providerId, payload)
1299
+ setSecretDraft('')
1300
+ notify(`${meta.label} credentials removed.`, 'success')
1301
+ } catch (cause) {
1302
+ notify(cause instanceof Error ? cause.message : 'Could not remove those credentials.', 'error')
1303
+ }
1304
+ }
1305
+
1306
+ const Icon = meta.icon
1307
+
1308
+ return (
1309
+ <div className="rounded-xl border border-line bg-panel">
1310
+ <button
1311
+ type="button"
1312
+ onClick={() => setOpen((value) => !value)}
1313
+ className="flex w-full items-center gap-3 px-3 py-2.5 text-left"
1314
+ >
1315
+ <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-line bg-raised/60 text-muted">
1316
+ <Icon size={13} />
1317
+ </span>
1318
+ <span className="min-w-0 flex-1">
1319
+ <span className="flex items-center gap-2">
1320
+ <span className="text-[11.5px] font-medium text-ink">{meta.label}</span>
1321
+ {secret?.configured ? (
1322
+ <span className="flex items-center gap-1 text-[9px] text-success">
1323
+ <span className="h-1 w-1 rounded-full bg-success" /> configured
1324
+ </span>
1325
+ ) : (
1326
+ <span className="text-[9px] text-faint">not configured</span>
1327
+ )}
1328
+ </span>
1329
+ <span className="mt-0.5 block text-[9px] text-faint">
1330
+ {tool?.installed ? `${tool.label} ${tool.version || 'installed'}` : 'Uses npx on deploy'}
1331
+ {secret?.hint ? ` · token ${secret.hint}` : ''}
1332
+ </span>
1333
+ </span>
1334
+ <ChevronDown size={13} className={cn('shrink-0 text-faint transition-transform', open && 'rotate-180')} />
1335
+ </button>
1336
+ {open && (
1337
+ <div className="border-t border-line px-3 py-3">
1338
+ <p className="mb-2.5 text-[9.5px] leading-relaxed text-faint">{meta.description}</p>
1339
+ {secretField && (
1340
+ <div className="mb-2.5">
1341
+ <Label hint={secret?.configured ? 'Saved; leave blank to keep' : 'Required'}>{secretField.label}</Label>
1342
+ <PasswordInput
1343
+ value={secretDraft}
1344
+ onChange={setSecretDraft}
1345
+ placeholder={secret?.configured ? `Replace token ${secret.hint}` : secretField.placeholder}
1346
+ label={`${meta.label} ${secretField.label}`}
1347
+ />
1348
+ </div>
1349
+ )}
1350
+ <div className="grid gap-2 sm:grid-cols-2">
1351
+ {nonSecret.map((field) => (
1352
+ <div key={field.key}>
1353
+ <Label hint={field.hint}>{field.label}</Label>
1354
+ <input
1355
+ value={drafts[field.key] ?? ''}
1356
+ onChange={(event) => setDrafts((current) => ({ ...current, [field.key]: event.target.value }))}
1357
+ placeholder={field.placeholder}
1358
+ spellCheck={false}
1359
+ className={cn(fieldClass, 'font-mono text-[10.5px]')}
1360
+ />
1361
+ </div>
1362
+ ))}
1363
+ </div>
1364
+ <div className="mt-2.5 flex items-center justify-between gap-2">
1365
+ {secret?.configured ? (
1366
+ <button
1367
+ type="button"
1368
+ onClick={() => void clearCreds()}
1369
+ className="text-[9.5px] font-medium text-faint hover:text-danger"
1370
+ >
1371
+ Remove credentials
1372
+ </button>
1373
+ ) : (
1374
+ <span className="text-[9px] text-faint">Values are encrypted at rest.</span>
1375
+ )}
1376
+ <button
1377
+ type="button"
1378
+ onClick={() => void saveCreds()}
1379
+ disabled={saving}
1380
+ className="inline-flex h-7 items-center gap-1 rounded-lg bg-ink px-2.5 text-[10px] font-medium text-panel disabled:opacity-40"
1381
+ >
1382
+ {saving ? <Spinner size={10} /> : <Check size={10} />}
1383
+ Save
1384
+ </button>
1385
+ </div>
1386
+ </div>
1387
+ )}
1388
+ </div>
1389
+ )
1390
+ }
1391
+
1392
+ function DeployTab() {
1393
+ const deployTools = useSettings((state) => state.deployTools)
1394
+ const secretsError = useSettings((state) => state.secretsError)
1395
+
1396
+ return (
1397
+ <>
1398
+ <SettingsSection
1399
+ title="Hosting credentials"
1400
+ description="Tokens are encrypted by the local daemon and passed to the deploy CLI through environment variables. They are never written to logs or returned to this page."
1401
+ >
1402
+ {secretsError && (
1403
+ <p className="mb-3 rounded-lg border border-warning/30 bg-warning/[0.04] px-3 py-2 text-[10px] text-warning">
1404
+ {secretsError}
1405
+ </p>
1406
+ )}
1407
+ <div className="space-y-2">
1408
+ {(Object.keys(DEPLOY_META) as DeployProviderId[]).map((provider) => (
1409
+ <DeployCredentialCard key={provider} providerId={provider} />
1410
+ ))}
1411
+ </div>
1412
+ </SettingsSection>
1413
+
1414
+ <SettingsSection
1415
+ title="Command-line tools"
1416
+ description="OpenSol uses an installed CLI when available and falls back to npx. Node.js must be on PATH."
1417
+ >
1418
+ <div className="overflow-hidden rounded-xl border border-line">
1419
+ {(Object.keys(DEPLOY_META) as DeployProviderId[]).map((provider, index) => {
1420
+ const tool = deployTools?.[provider]
1421
+ const meta = DEPLOY_META[provider]
1422
+ return (
1423
+ <div
1424
+ key={provider}
1425
+ className={cn('flex items-center gap-3 px-3 py-2.5', index !== 0 && 'border-t border-line')}
1426
+ >
1427
+ <Terminal size={13} className="shrink-0 text-faint" />
1428
+ <div className="min-w-0 flex-1">
1429
+ <p className="text-[11px] font-medium text-ink">{meta.short}</p>
1430
+ <p className="truncate font-mono text-[9px] text-faint">
1431
+ {tool?.bin ?? '—'} {tool?.installed ? `· ${tool.version || 'installed'}` : '· not installed, uses npx'}
1432
+ </p>
1433
+ </div>
1434
+ <span
1435
+ className={cn(
1436
+ 'shrink-0 rounded-full border px-1.5 py-0.5 text-[8.5px] font-medium',
1437
+ tool?.installed ? 'border-success/25 text-success' : 'border-line text-faint',
1438
+ )}
1439
+ >
1440
+ {tool?.installed ? 'installed' : 'npx fallback'}
1441
+ </span>
1442
+ </div>
1443
+ )
1444
+ })}
1445
+ </div>
1446
+ <button
1447
+ type="button"
1448
+ onClick={() => useUi.getState().setDeployOpen(true)}
1449
+ className="mt-3 inline-flex h-8 items-center gap-1.5 rounded-lg bg-accent px-3 text-[10.5px] font-medium text-on-accent hover:bg-accent/90"
1450
+ >
1451
+ <Rocket size={12} /> Open deploy panel
1452
+ </button>
1453
+ </SettingsSection>
1454
+ </>
1455
+ )
1456
+ }
1457
+
1458
+ function PluginsTab() {
1459
+ return (
1460
+ <SettingsSection
1461
+ title="Local plugins"
1462
+ description="OpenSol only installs plugin packages you choose from this machine. A plugin stays sandboxed and disabled until you review its permissions and mark it Safe."
1463
+ >
1464
+ <PluginManager />
1465
+ </SettingsSection>
1466
+ )
1467
+ }
1468
+
1469
+ function GeneralTab() {
1470
+ const settings = useSettings()
1471
+ const update = useSettings((state) => state.update)
1472
+ const openSettings = useUi((state) => state.openSettings)
1473
+ const notify = useUi((state) => state.notify)
1474
+ const permission = permissionMode(settings.tools)
1475
+
1476
+ const quickThemes = [
1477
+ { id: 'light' as const, label: 'Light', icon: Sun },
1478
+ { id: 'dark' as const, label: 'Dark', icon: Moon },
1479
+ { id: 'system' as const, label: 'System', icon: Monitor },
1480
+ ]
1481
+
1482
+ const copyConfig = async () => {
1483
+ const path = settings.daemon?.dataDir ?? '~/.maxcode'
1484
+ try {
1485
+ await navigator.clipboard.writeText(path)
1486
+ notify(`Copied the data folder path: ${path}`, 'success')
1487
+ } catch {
1488
+ notify(`Data folder: ${path}`, 'info')
1489
+ }
1490
+ }
1491
+
1492
+ return (
1493
+ <div className="px-5 py-2 sm:px-7">
1494
+ <div className="settings-row">
1495
+ <div className="min-w-0">
1496
+ <p className="settings-label">Permission</p>
1497
+ <p className="settings-hint">Choose the default permission mode for new sessions.</p>
1498
+ </div>
1499
+ <select
1500
+ value={permission}
1501
+ onChange={(event) =>
1502
+ update({ tools: permissionTools(settings.tools, event.target.value as typeof permission) })
1503
+ }
1504
+ className={cn('settings-select w-full sm:w-[210px]')}
1505
+ aria-label="Default permission mode"
1506
+ >
1507
+ {PERMISSION_MODES.map((entry) => (
1508
+ <option key={entry.value} value={entry.value}>
1509
+ {entry.label}
1510
+ </option>
1511
+ ))}
1512
+ </select>
1513
+ </div>
1514
+
1515
+ <div className="settings-row">
1516
+ <div className="min-w-0">
1517
+ <p className="settings-label">Appearance</p>
1518
+ <p className="settings-hint">Follow the system or pick a fixed theme.</p>
1519
+ </div>
1520
+ <div className="w-full sm:w-[360px]">
1521
+ <div className="grid grid-cols-3 gap-2">
1522
+ {quickThemes.map((theme) => {
1523
+ const Icon = theme.icon
1524
+ const selected = settings.theme === theme.id
1525
+ return (
1526
+ <button
1527
+ key={theme.id}
1528
+ type="button"
1529
+ onClick={() => update({ theme: theme.id })}
1530
+ className={cn(
1531
+ 'flex flex-col items-center gap-1.5 rounded-xl border px-2 py-2.5 transition-colors',
1532
+ selected
1533
+ ? 'border-accent/60 bg-panel text-ink ring-2 ring-accent/10'
1534
+ : 'border-line text-muted hover:border-faint/50 hover:text-ink',
1535
+ )}
1536
+ >
1537
+ <Icon size={16} />
1538
+ <span className="text-[11px] font-medium">{theme.label}</span>
1539
+ </button>
1540
+ )
1541
+ })}
1542
+ </div>
1543
+ <button
1544
+ type="button"
1545
+ onClick={() => openSettings('appearance')}
1546
+ className="mt-2 text-[11px] font-medium text-muted transition-colors hover:text-ink"
1547
+ >
1548
+ More themes and theme previews →
1549
+ </button>
1550
+ </div>
1551
+ </div>
1552
+
1553
+ <div className="settings-row">
1554
+ <div className="min-w-0">
1555
+ <p className="settings-label">Font size</p>
1556
+ <p className="settings-hint">Only affects conversation content.</p>
1557
+ </div>
1558
+ <div className="flex items-center gap-2">
1559
+ <select
1560
+ value={settings.chatFontSize}
1561
+ onChange={(event) => update({ chatFontSize: Number(event.target.value) })}
1562
+ className={cn('settings-select w-[84px] text-center')}
1563
+ aria-label="Conversation font size"
1564
+ >
1565
+ {Array.from({ length: 9 }, (_, index) => index + 12).map((size) => (
1566
+ <option key={size} value={size}>
1567
+ {size}
1568
+ </option>
1569
+ ))}
1570
+ </select>
1571
+ <span className="text-[11px] text-faint">px</span>
1572
+ </div>
1573
+ </div>
1574
+
1575
+ <div className="settings-row">
1576
+ <div className="min-w-0">
1577
+ <p className="settings-label">Work details</p>
1578
+ <p className="settings-hint">Choose how much detail to show for tool calls.</p>
1579
+ </div>
1580
+ <select
1581
+ value={settings.workDetail}
1582
+ onChange={(event) => update({ workDetail: event.target.value === 'concise' ? 'concise' : 'detailed' })}
1583
+ className={cn('settings-select w-full sm:w-[180px]')}
1584
+ aria-label="Work details"
1585
+ >
1586
+ <option value="concise">Concise</option>
1587
+ <option value="detailed">Detailed</option>
1588
+ </select>
1589
+ </div>
1590
+
1591
+ <div className="settings-row">
1592
+ <div className="min-w-0">
1593
+ <p className="settings-label">When to collapse work details</p>
1594
+ <p className="settings-hint">Choose when to automatically collapse work details.</p>
1595
+ </div>
1596
+ <select
1597
+ value={settings.autoCollapseWork ? 'completion' : 'never'}
1598
+ onChange={(event) => update({ autoCollapseWork: event.target.value === 'completion' })}
1599
+ className={cn('settings-select w-full sm:w-[180px]')}
1600
+ aria-label="When to collapse work details"
1601
+ >
1602
+ <option value="completion">On completion</option>
1603
+ <option value="never">Never</option>
1604
+ </select>
1605
+ </div>
1606
+
1607
+ <div className="settings-row">
1608
+ <div className="min-w-0">
1609
+ <p className="settings-label">Show token estimates</p>
1610
+ <p className="settings-hint">When a provider does not report usage, show a rough output estimate.</p>
1611
+ </div>
1612
+ <Switch
1613
+ checked={settings.showTokenEstimate}
1614
+ onChange={(showTokenEstimate) => update({ showTokenEstimate })}
1615
+ label="Show token estimates"
1616
+ />
1617
+ </div>
1618
+
1619
+ <div className="settings-row">
1620
+ <div className="min-w-0">
1621
+ <p className="settings-label">Local configuration</p>
1622
+ <p className="settings-hint">Settings, conversations and encrypted keys live in this folder.</p>
1623
+ </div>
1624
+ <button
1625
+ type="button"
1626
+ onClick={() => void copyConfig()}
1627
+ className="inline-flex h-9 shrink-0 items-center gap-1.5 rounded-lg border border-line bg-panel px-3 text-[11.5px] font-medium text-ink transition-colors hover:bg-raised"
1628
+ title={settings.daemon?.dataDir ?? '~/.maxcode'}
1629
+ >
1630
+ <HardDrive size={13} />
1631
+ Copy data folder path
1632
+ </button>
1633
+ </div>
1634
+
1635
+ <p className="py-4 text-[10.5px] text-faint">Changes are saved automatically to this computer.</p>
1636
+ </div>
1637
+ )
1638
+ }
1639
+
1640
+ function AppearanceTab() {
1641
+ const settings = useSettings()
1642
+ const update = useSettings((state) => state.update)
1643
+ const themes = [
1644
+ { id: 'light' as const, label: 'Light', className: 'bg-[#fcfcfc]', line: 'bg-[#e2e2e5]' },
1645
+ { id: 'dark' as const, label: 'Dark', className: 'bg-[#09090b]', line: 'bg-[#2a2a31]' },
1646
+ { id: 'night-owl' as const, label: 'Night Owl', className: 'bg-[#011627]', line: 'bg-[#1a3e5a]' },
1647
+ { id: 'claude-code' as const, label: 'Claude Code', className: 'bg-[#262624]', line: 'bg-[#42423e]' },
1648
+ { id: 'flame' as const, label: 'Flame', className: 'bg-[#130d0c]', line: 'bg-[#3e2621]' },
1649
+ {
1650
+ id: 'system' as const,
1651
+ label: 'System',
1652
+ className: 'bg-gradient-to-br from-[#fcfcfc] to-[#09090b]',
1653
+ line: 'bg-[#8a8a92]',
1654
+ },
1655
+ // Themes contributed by Safe plugins.
1656
+ ...pluginRuntime().themes().map((theme) => ({
1657
+ id: theme.id,
1658
+ label: theme.label,
1659
+ className: 'bg-gradient-to-br from-accent-soft to-panel',
1660
+ line: 'bg-line',
1661
+ })),
1662
+ ]
1663
+
1664
+ return (
1665
+ <>
1666
+ <SettingsSection title="Theme" description="System follows your OS preference and updates when it changes.">
1667
+ <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
1668
+ {themes.map((theme) => {
1669
+ const selected = settings.theme === theme.id
1670
+ return (
1671
+ <button
1672
+ key={theme.id}
1673
+ type="button"
1674
+ onClick={() => update({ theme: theme.id })}
1675
+ className={cn(
1676
+ 'rounded-xl border p-2 text-left transition-all',
1677
+ selected ? 'border-accent ring-2 ring-accent/10' : 'border-line hover:border-faint/50',
1678
+ )}
1679
+ >
1680
+ <span className={cn('block h-14 w-full overflow-hidden rounded-lg border border-line/60 p-2', theme.className)}>
1681
+ <span className={cn('block h-1.5 w-8 rounded-full', theme.line)} />
1682
+ <span className={cn('mt-1.5 block h-1.5 w-12 rounded-full opacity-60', theme.line)} />
1683
+ <span className="mt-3 block h-2 w-5 rounded-full bg-accent" />
1684
+ </span>
1685
+ <span className="mt-1.5 flex items-center justify-between px-0.5">
1686
+ <span className="text-[10.5px] font-medium text-ink">{theme.label}</span>
1687
+ {selected && <Check size={11} className="text-accent" />}
1688
+ </span>
1689
+ </button>
1690
+ )
1691
+ })}
1692
+ </div>
1693
+ </SettingsSection>
1694
+
1695
+ <SettingsSection title="Interface">
1696
+ <div className="flex items-center justify-between gap-4 rounded-lg border border-line bg-raised/30 px-3 py-2.5">
1697
+ <div>
1698
+ <p className="text-[11.5px] font-medium text-ink">Show token estimates</p>
1699
+ <p className="mt-0.5 text-[9.5px] text-faint">
1700
+ When a provider does not report usage, show a rough output estimate.
1701
+ </p>
1702
+ </div>
1703
+ <Switch
1704
+ checked={settings.showTokenEstimate}
1705
+ onChange={(showTokenEstimate) => update({ showTokenEstimate })}
1706
+ label="Show token estimates"
1707
+ />
1708
+ </div>
1709
+ </SettingsSection>
1710
+
1711
+ <SettingsSection title="Shortcuts">
1712
+ <div className="space-y-1.5 text-[10.5px]">
1713
+ {[
1714
+ ['Search and commands', '⌘ K / Ctrl K'],
1715
+ ['Send message', 'Enter'],
1716
+ ['New line', 'Shift Enter'],
1717
+ ['Save file in the editor', '⌘ S / Ctrl S'],
1718
+ ].map(([label, shortcut]) => (
1719
+ <div key={label} className="flex items-center justify-between rounded-lg px-1 py-1">
1720
+ <span className="text-muted">{label}</span>
1721
+ <kbd className="rounded border border-line bg-raised px-1.5 py-0.5 font-sans text-[9.5px] text-faint">
1722
+ {shortcut}
1723
+ </kbd>
1724
+ </div>
1725
+ ))}
1726
+ </div>
1727
+ </SettingsSection>
1728
+ </>
1729
+ )
1730
+ }
1731
+
1732
+ function AboutTab() {
1733
+ const sessions = useChats((state) => state.sessions)
1734
+ const daemon = useSettings((state) => state.daemon)
1735
+ const secretStatus = useSettings((state) => state.secretStatus)
1736
+ const entries = useWorkspace((state) => state.entries)
1737
+ const workspace = useWorkspace((state) => state.info)
1738
+ const askConfirm = useUi((state) => state.askConfirm)
1739
+ const notify = useUi((state) => state.notify)
1740
+ const importRef = useRef<HTMLInputElement>(null)
1741
+ const [busy, setBusy] = useState('')
1742
+ const messageCount = sessions.reduce((sum, session) => sum + session.messages.length, 0)
1743
+ const fileCount = entries.filter((entry) => entry.type === 'file').length
1744
+
1745
+ const runReset = async (scope: 'history' | 'settings' | 'all', copy: { title: string; body: string; label: string }) => {
1746
+ const confirmed = await askConfirm({ ...copy, tone: 'danger', confirmLabel: copy.label })
1747
+ if (!confirmed) return
1748
+ setBusy(scope)
1749
+ try {
1750
+ stopAllRuns()
1751
+ const snapshot = await resetScope(scope)
1752
+ await applySnapshot(snapshot)
1753
+ notify(
1754
+ scope === 'history'
1755
+ ? 'Conversation history cleared.'
1756
+ : scope === 'settings'
1757
+ ? 'Settings restored to defaults.'
1758
+ : 'OpenSol was reset.',
1759
+ 'success',
1760
+ )
1761
+ } catch (cause) {
1762
+ notify(cause instanceof Error ? cause.message : 'Could not reset that data.', 'error')
1763
+ } finally {
1764
+ setBusy('')
1765
+ }
1766
+ }
1767
+
1768
+ return (
1769
+ <>
1770
+ <SettingsSection title="About">
1771
+ <div className="flex items-start gap-3.5">
1772
+ <LogoMark size={46} />
1773
+ <div>
1774
+ <div className="flex flex-wrap items-baseline gap-2">
1775
+ <h3 className="font-display text-[20px] font-semibold tracking-[-0.03em] text-ink">OpenSol</h3>
1776
+ <span className="text-[10px] text-faint">v{daemon?.version ?? '1.0.0'}</span>
1777
+ </div>
1778
+ <p className="mt-0.5 text-[10.5px] font-medium text-muted">A OpenSoft product</p>
1779
+ <p className="mt-2 max-w-[520px] text-[10.5px] leading-relaxed text-muted">
1780
+ A local-first coding agent with a real workspace, shell, git, web research, subagents and deploy flows.
1781
+ No OpenSol account and no OpenSoft server. Your keys never leave this machine.
1782
+ </p>
1783
+ </div>
1784
+ </div>
1785
+ <div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3">
1786
+ {[
1787
+ ['Conversations', String(sessions.length)],
1788
+ ['Messages', String(messageCount)],
1789
+ ['Workspace files', workspace.available ? String(fileCount) : 'None'],
1790
+ ].map(([label, value]) => (
1791
+ <div key={label} className="rounded-lg border border-line bg-raised/30 px-3 py-2">
1792
+ <p className="text-[9px] text-faint">{label}</p>
1793
+ <p className="mt-0.5 text-[15px] font-semibold tracking-[-0.02em] text-ink">{value}</p>
1794
+ </div>
1795
+ ))}
1796
+ </div>
1797
+ </SettingsSection>
1798
+
1799
+ <SettingsSection title="Local data" description="Everything is stored under ~/.maxcode. Clearing that folder removes OpenSol’s local state.">
1800
+ <div className="space-y-1.5 rounded-lg border border-line bg-raised/30 px-3 py-2.5 text-[10px]">
1801
+ <p className="flex items-center justify-between gap-3">
1802
+ <span className="text-faint">Data folder</span>
1803
+ <span className="min-w-0 truncate font-mono text-ink" title={daemon?.dataDir}>
1804
+ {daemon?.dataDir ?? '~/.maxcode'}
1805
+ </span>
1806
+ </p>
1807
+ <p className="flex items-center justify-between gap-3">
1808
+ <span className="text-faint">Encryption</span>
1809
+ <span className="text-muted">{secretStatus?.encryption ?? 'AES-256-GCM'}</span>
1810
+ </p>
1811
+ <p className="flex items-center justify-between gap-3">
1812
+ <span className="text-faint">Runtime</span>
1813
+ <span className="truncate text-muted">
1814
+ {daemon?.platform ?? '—'} · Node {daemon?.node ?? '—'}
1815
+ </span>
1816
+ </p>
1817
+ <p className="flex items-center justify-between gap-3">
1818
+ <span className="text-faint">Workspace</span>
1819
+ <span className="min-w-0 truncate font-mono text-muted" title={workspace.root}>
1820
+ {workspace.available ? workspace.root : 'No folder open'}
1821
+ </span>
1822
+ </p>
1823
+ </div>
1824
+ <div className="mt-3 flex flex-wrap gap-2">
1825
+ <button
1826
+ type="button"
1827
+ onClick={exportChats}
1828
+ className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-line bg-panel px-2.5 text-[10.5px] font-medium text-ink hover:bg-raised"
1829
+ >
1830
+ <ArrowDownToLine size={12} /> Export conversations
1831
+ </button>
1832
+ <button
1833
+ type="button"
1834
+ onClick={() => importRef.current?.click()}
1835
+ className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-line bg-panel px-2.5 text-[10.5px] font-medium text-ink hover:bg-raised"
1836
+ >
1837
+ <Upload size={12} /> Import conversations
1838
+ </button>
1839
+ <input
1840
+ ref={importRef}
1841
+ type="file"
1842
+ accept="application/json,.json"
1843
+ className="hidden"
1844
+ onChange={(event) => {
1845
+ const file = event.target.files?.[0]
1846
+ if (!file) return
1847
+ importChats(file)
1848
+ .then((count) =>
1849
+ notify(
1850
+ count > 0
1851
+ ? `Imported ${count} conversation${count === 1 ? '' : 's'}.`
1852
+ : 'Those conversations are already in your history.',
1853
+ count > 0 ? 'success' : 'info',
1854
+ ),
1855
+ )
1856
+ .catch((cause: unknown) =>
1857
+ notify(cause instanceof Error ? cause.message : 'Could not import that file.', 'error'),
1858
+ )
1859
+ event.target.value = ''
1860
+ }}
1861
+ />
1862
+ <button
1863
+ type="button"
1864
+ onClick={() =>
1865
+ void runReset('history', {
1866
+ title: 'Clear conversation history?',
1867
+ body: 'Every conversation saved on this computer will be deleted. Keys, skills and workspace files are kept.',
1868
+ label: 'Clear history',
1869
+ })
1870
+ }
1871
+ disabled={busy === 'history'}
1872
+ className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-line bg-panel px-2.5 text-[10.5px] font-medium text-ink hover:bg-raised disabled:opacity-40"
1873
+ >
1874
+ {busy === 'history' ? <Spinner size={12} /> : <Trash2 size={12} />} Clear history
1875
+ </button>
1876
+ </div>
1877
+ <p className="mt-3 flex items-center gap-2 text-[9.5px] text-faint">
1878
+ <HardDrive size={11} />
1879
+ {formatBytes(new Blob([JSON.stringify(sessions)]).size)} of conversation history
1880
+ <span className="text-line">·</span>
1881
+ <LockKeyhole size={10} />
1882
+ secrets encrypted at rest
1883
+ </p>
1884
+ </SettingsSection>
1885
+
1886
+ <SettingsSection title="Danger zone">
1887
+ <div className="space-y-2">
1888
+ <div className="flex flex-col gap-2 rounded-lg border border-line px-3 py-2.5 sm:flex-row sm:items-center sm:justify-between">
1889
+ <div>
1890
+ <p className="text-[11px] font-medium text-ink">Restore default settings</p>
1891
+ <p className="mt-0.5 text-[9.5px] text-muted">Reset prompt, modes, tool permissions and skills. Keys and chats stay.</p>
1892
+ </div>
1893
+ <button
1894
+ type="button"
1895
+ onClick={() =>
1896
+ void runReset('settings', {
1897
+ title: 'Restore default settings?',
1898
+ body: 'Your prompt, tool permissions, skills and interface settings return to their defaults. Conversations and encrypted keys are kept.',
1899
+ label: 'Restore defaults',
1900
+ })
1901
+ }
1902
+ disabled={busy === 'settings'}
1903
+ className="inline-flex h-7 shrink-0 items-center gap-1 self-start rounded-lg border border-line px-2.5 text-[10px] font-medium text-muted hover:border-warning/30 hover:text-warning disabled:opacity-40 sm:self-auto"
1904
+ >
1905
+ {busy === 'settings' ? <Spinner size={11} /> : <RotateCcw size={11} />} Restore defaults
1906
+ </button>
1907
+ </div>
1908
+ <div className="flex flex-col gap-2 rounded-lg border border-danger/20 bg-danger/[0.02] px-3 py-2.5 sm:flex-row sm:items-center sm:justify-between">
1909
+ <div>
1910
+ <p className="text-[11px] font-medium text-ink">Reset OpenSol</p>
1911
+ <p className="mt-0.5 text-[9.5px] text-muted">
1912
+ Delete conversations, encrypted keys, skills, settings and the remembered workspace.
1913
+ </p>
1914
+ </div>
1915
+ <button
1916
+ type="button"
1917
+ onClick={() =>
1918
+ void runReset('all', {
1919
+ title: 'Reset everything?',
1920
+ body: 'Conversations, API keys, hosting credentials, skills, settings and the workspace list will be permanently removed from this computer. Files in your project folder are never deleted.',
1921
+ label: 'Reset everything',
1922
+ })
1923
+ }
1924
+ disabled={busy === 'all'}
1925
+ className="inline-flex h-7 shrink-0 items-center gap-1 self-start rounded-lg bg-danger px-2.5 text-[10px] font-medium text-on-accent hover:bg-danger/90 disabled:opacity-40 sm:self-auto"
1926
+ >
1927
+ {busy === 'all' ? <Spinner size={11} /> : <Trash2 size={11} />} Reset everything
1928
+ </button>
1929
+ </div>
1930
+ </div>
1931
+ </SettingsSection>
1932
+ </>
1933
+ )
1934
+ }
1935
+
1936
+ export function SettingsModal() {
1937
+ const open = useUi((state) => state.settingsOpen)
1938
+ const tab = useUi((state) => state.settingsTab)
1939
+ const close = useUi((state) => state.closeSettings)
1940
+ const openSettings = useUi((state) => state.openSettings)
1941
+ const notify = useUi((state) => state.notify)
1942
+ const daemon = useSettings((state) => state.daemon)
1943
+ const contentRef = useRef<HTMLDivElement>(null)
1944
+
1945
+ useEffect(() => {
1946
+ if (open) contentRef.current?.scrollTo({ top: 0 })
1947
+ }, [open, tab])
1948
+
1949
+ const copyConfig = async () => {
1950
+ const path = daemon?.dataDir ?? '~/.maxcode'
1951
+ try {
1952
+ await navigator.clipboard.writeText(path)
1953
+ notify(`Copied the data folder path: ${path}`, 'success')
1954
+ } catch {
1955
+ notify(`Data folder: ${path}`, 'info')
1956
+ }
1957
+ }
1958
+
1959
+ return (
1960
+ <Modal
1961
+ open={open}
1962
+ onClose={close}
1963
+ labelledBy="settings-title"
1964
+ className="h-[92dvh] p-0 sm:h-[min(88dvh,780px)] sm:w-[min(980px,94vw)]"
1965
+ >
1966
+ <div className="flex min-h-0 flex-1 flex-col">
1967
+ <div className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-line px-4 sm:px-5">
1968
+ <h2 id="settings-title" className="text-[16px] font-semibold tracking-[-0.02em] text-ink">
1969
+ Settings
1970
+ </h2>
1971
+ <div className="flex shrink-0 items-center gap-1.5">
1972
+ <button
1973
+ type="button"
1974
+ onClick={() => void copyConfig()}
1975
+ className="hidden h-8 items-center gap-1.5 rounded-lg border border-line bg-panel px-2.5 text-[11px] font-medium text-ink transition-colors hover:bg-raised sm:inline-flex"
1976
+ title={daemon?.dataDir ?? '~/.maxcode'}
1977
+ >
1978
+ <HardDrive size={12} />
1979
+ Data folder
1980
+ </button>
1981
+ <button type="button" className="icon-button" onClick={close} aria-label="Close settings">
1982
+ <X size={16} />
1983
+ </button>
1984
+ </div>
1985
+ </div>
1986
+
1987
+ <div className="flex min-h-0 flex-1">
1988
+ <aside className="hidden w-[200px] shrink-0 flex-col border-r border-line bg-canvas sm:flex">
1989
+ <nav className="flex-1 space-y-0.5 overflow-y-auto p-2">
1990
+ {TABS.map((item) => {
1991
+ const Icon = item.icon
1992
+ const active = tab === item.id
1993
+ return (
1994
+ <button
1995
+ key={item.id}
1996
+ type="button"
1997
+ onClick={() => openSettings(item.id)}
1998
+ title={item.description}
1999
+ className={cn(
2000
+ 'flex h-9 w-full items-center gap-2.5 rounded-lg px-2.5 text-left text-[12.5px] transition-colors',
2001
+ active ? 'bg-raised font-medium text-ink' : 'text-muted hover:bg-raised/60 hover:text-ink',
2002
+ )}
2003
+ >
2004
+ <Icon size={14} className={cn(active ? 'text-ink' : 'text-faint')} />
2005
+ {item.label}
2006
+ </button>
2007
+ )
2008
+ })}
2009
+ </nav>
2010
+ <div className="border-t border-line p-3">
2011
+ <p className="flex items-center gap-1.5 text-[9px] text-faint">
2012
+ <LockKeyhole size={10} />
2013
+ Keys encrypted on this machine
2014
+ </p>
2015
+ <p className="mt-1 text-[9px] text-faint">OpenSol 1.0 · OpenSoft</p>
2016
+ </div>
2017
+ </aside>
2018
+
2019
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col">
2020
+ <div className="thin-scroll flex shrink-0 gap-1 overflow-x-auto border-b border-line px-3 py-2 sm:hidden">
2021
+ {TABS.map((item) => (
2022
+ <button
2023
+ key={item.id}
2024
+ type="button"
2025
+ onClick={() => openSettings(item.id)}
2026
+ className={cn(
2027
+ 'shrink-0 rounded-full px-2.5 py-1 text-[10.5px] font-medium transition-colors',
2028
+ tab === item.id ? 'bg-ink text-panel' : 'text-muted hover:bg-raised',
2029
+ )}
2030
+ >
2031
+ {item.label}
2032
+ </button>
2033
+ ))}
2034
+ </div>
2035
+
2036
+ <div ref={contentRef} className="thin-scroll min-h-0 flex-1 overflow-y-auto">
2037
+ {tab === 'general' && <GeneralTab />}
2038
+ {tab === 'models' && <ModelsTab />}
2039
+ {tab === 'tools' && <ToolsTab />}
2040
+ {tab === 'search' && <SearchTab />}
2041
+ {tab === 'skills' && <SkillsTab />}
2042
+ {tab === 'plugins' && <PluginsTab />}
2043
+ {tab === 'deploy' && <DeployTab />}
2044
+ {tab === 'appearance' && <AppearanceTab />}
2045
+ {tab === 'about' && <AboutTab />}
2046
+ </div>
2047
+ </div>
2048
+ </div>
2049
+ </div>
2050
+ </Modal>
2051
+ )
2052
+ }