@bibobonking/opensol 0.0.0-stage → 3.0.2
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.
- package/.github/workflows/build.yml +25 -0
- package/README.md +74 -2
- package/dist/assets/index-BmN7pBji.css +1 -0
- package/dist/assets/index-DgNDjkOz.js +301 -0
- package/dist/favicon.svg +15 -0
- package/dist/index.html +55 -0
- package/dist/preview-host.html +33 -0
- package/index.html +54 -0
- package/package.json +43 -4
- package/postcss.config.js +6 -0
- package/public/favicon.svg +15 -0
- package/public/preview-host.html +33 -0
- package/server/README.md +75 -0
- package/server/daemon.mjs +1082 -0
- package/server/lib/checkpoints.mjs +124 -0
- package/server/lib/checkpoints.test.mjs +110 -0
- package/server/lib/deploy.mjs +436 -0
- package/server/lib/http.mjs +237 -0
- package/server/lib/plugins.mjs +321 -0
- package/server/lib/plugins.test.mjs +140 -0
- package/server/lib/providers.mjs +594 -0
- package/server/lib/store.mjs +491 -0
- package/server/lib/tools.mjs +990 -0
- package/server/opensol.mjs +202 -0
- package/server/package.json +42 -0
- package/shared/plugin-builder.test.mjs +43 -0
- package/shared/plugin-package.d.mts +34 -0
- package/shared/plugin-package.mjs +197 -0
- package/shared/plugin-package.test.mjs +94 -0
- package/src/App.tsx +152 -0
- package/src/agent/adapters/anthropic.ts +233 -0
- package/src/agent/adapters/gemini.ts +225 -0
- package/src/agent/adapters/openai-responses.ts +215 -0
- package/src/agent/adapters/openai.ts +200 -0
- package/src/agent/adapters/shared.ts +96 -0
- package/src/agent/approval.ts +20 -0
- package/src/agent/compact.ts +234 -0
- package/src/agent/index.ts +36 -0
- package/src/agent/model.ts +20 -0
- package/src/agent/prompt.ts +107 -0
- package/src/agent/queue.test.mjs +111 -0
- package/src/agent/queue.ts +84 -0
- package/src/agent/subagent.ts +109 -0
- package/src/agent/tools.ts +541 -0
- package/src/components/ChatView.tsx +403 -0
- package/src/components/CommandPalette.tsx +416 -0
- package/src/components/Composer.tsx +678 -0
- package/src/components/ContextBar.tsx +58 -0
- package/src/components/DeployModal.tsx +512 -0
- package/src/components/ErrorBoundary.tsx +75 -0
- package/src/components/HomeDashboard.tsx +189 -0
- package/src/components/Logo.tsx +41 -0
- package/src/components/Markdown.tsx +232 -0
- package/src/components/MessageItem.tsx +655 -0
- package/src/components/ModelPicker.tsx +487 -0
- package/src/components/PixleSol.tsx +243 -0
- package/src/components/PluginBuilder.tsx +285 -0
- package/src/components/PluginManager.tsx +341 -0
- package/src/components/PreviewPane.tsx +166 -0
- package/src/components/SettingsModal.tsx +2052 -0
- package/src/components/Sidebar.tsx +504 -0
- package/src/components/Splash.tsx +104 -0
- package/src/components/ToolCallCard.tsx +177 -0
- package/src/components/TopBar.tsx +182 -0
- package/src/components/TrajectoryView.tsx +178 -0
- package/src/components/WorkspacePanel.tsx +710 -0
- package/src/components/ui.tsx +311 -0
- package/src/core/kernel.ts +45 -0
- package/src/core/plugins/tools/fs.ts +219 -0
- package/src/core/plugins/tools/git.ts +83 -0
- package/src/core/plugins/tools/grep.ts +98 -0
- package/src/core/plugins/tools/shell.ts +88 -0
- package/src/core/plugins/tools/subagent.ts +36 -0
- package/src/core/plugins/tools/web.ts +97 -0
- package/src/core/services/agent-loop.ts +417 -0
- package/src/core/services/approval.ts +66 -0
- package/src/core/services/compaction.ts +45 -0
- package/src/core/services/llm.ts +32 -0
- package/src/core/services/sessions.ts +47 -0
- package/src/core/services/subagents.ts +15 -0
- package/src/core/services/system-prompt.ts +177 -0
- package/src/core/services/tools.ts +135 -0
- package/src/core/services/ui.ts +109 -0
- package/src/core/types.ts +139 -0
- package/src/data/providers.ts +125 -0
- package/src/data/skills.ts +68 -0
- package/src/index.css +776 -0
- package/src/lib/boot.ts +94 -0
- package/src/lib/context.ts +75 -0
- package/src/lib/conversation.test.mjs +64 -0
- package/src/lib/conversation.ts +59 -0
- package/src/lib/daemon.ts +475 -0
- package/src/lib/hooks.ts +33 -0
- package/src/lib/permissions.ts +60 -0
- package/src/lib/pixlesol.test.mjs +51 -0
- package/src/lib/pixlesol.ts +55 -0
- package/src/lib/plugin-folder.test.mjs +145 -0
- package/src/lib/plugin-folder.ts +96 -0
- package/src/lib/reasoning.ts +87 -0
- package/src/lib/sse.ts +75 -0
- package/src/lib/themes.test.mjs +72 -0
- package/src/lib/themes.ts +52 -0
- package/src/lib/usage.test.mjs +72 -0
- package/src/lib/usage.ts +155 -0
- package/src/lib/utils.ts +73 -0
- package/src/main.tsx +16 -0
- package/src/plugins/api.test.mjs +77 -0
- package/src/plugins/api.ts +113 -0
- package/src/plugins/bridge.d.mts +1 -0
- package/src/plugins/bridge.mjs +26 -0
- package/src/plugins/bridge.test.mjs +17 -0
- package/src/plugins/index.ts +52 -0
- package/src/plugins/preview.test.mjs +18 -0
- package/src/plugins/preview.ts +34 -0
- package/src/plugins/runtime.ts +194 -0
- package/src/plugins/types.ts +40 -0
- package/src/store/chats.ts +278 -0
- package/src/store/models.ts +120 -0
- package/src/store/settings.ts +445 -0
- package/src/store/ui.ts +132 -0
- package/src/store/workspace.ts +299 -0
- package/src/types.ts +337 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.js +83 -0
- package/tsconfig.json +22 -0
- package/tsconfig.tsbuildinfo +1 -0
- 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
|
+
}
|