@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.
Files changed (127) hide show
  1. package/.github/workflows/build.yml +25 -0
  2. package/README.md +74 -2
  3. package/dist/assets/index-BmN7pBji.css +1 -0
  4. package/dist/assets/index-DgNDjkOz.js +301 -0
  5. package/dist/favicon.svg +15 -0
  6. package/dist/index.html +55 -0
  7. package/dist/preview-host.html +33 -0
  8. package/index.html +54 -0
  9. package/package.json +43 -4
  10. package/postcss.config.js +6 -0
  11. package/public/favicon.svg +15 -0
  12. package/public/preview-host.html +33 -0
  13. package/server/README.md +75 -0
  14. package/server/daemon.mjs +1082 -0
  15. package/server/lib/checkpoints.mjs +124 -0
  16. package/server/lib/checkpoints.test.mjs +110 -0
  17. package/server/lib/deploy.mjs +436 -0
  18. package/server/lib/http.mjs +237 -0
  19. package/server/lib/plugins.mjs +321 -0
  20. package/server/lib/plugins.test.mjs +140 -0
  21. package/server/lib/providers.mjs +594 -0
  22. package/server/lib/store.mjs +491 -0
  23. package/server/lib/tools.mjs +990 -0
  24. package/server/opensol.mjs +202 -0
  25. package/server/package.json +42 -0
  26. package/shared/plugin-builder.test.mjs +43 -0
  27. package/shared/plugin-package.d.mts +34 -0
  28. package/shared/plugin-package.mjs +197 -0
  29. package/shared/plugin-package.test.mjs +94 -0
  30. package/src/App.tsx +152 -0
  31. package/src/agent/adapters/anthropic.ts +233 -0
  32. package/src/agent/adapters/gemini.ts +225 -0
  33. package/src/agent/adapters/openai-responses.ts +215 -0
  34. package/src/agent/adapters/openai.ts +200 -0
  35. package/src/agent/adapters/shared.ts +96 -0
  36. package/src/agent/approval.ts +20 -0
  37. package/src/agent/compact.ts +234 -0
  38. package/src/agent/index.ts +36 -0
  39. package/src/agent/model.ts +20 -0
  40. package/src/agent/prompt.ts +107 -0
  41. package/src/agent/queue.test.mjs +111 -0
  42. package/src/agent/queue.ts +84 -0
  43. package/src/agent/subagent.ts +109 -0
  44. package/src/agent/tools.ts +541 -0
  45. package/src/components/ChatView.tsx +403 -0
  46. package/src/components/CommandPalette.tsx +416 -0
  47. package/src/components/Composer.tsx +678 -0
  48. package/src/components/ContextBar.tsx +58 -0
  49. package/src/components/DeployModal.tsx +512 -0
  50. package/src/components/ErrorBoundary.tsx +75 -0
  51. package/src/components/HomeDashboard.tsx +189 -0
  52. package/src/components/Logo.tsx +41 -0
  53. package/src/components/Markdown.tsx +232 -0
  54. package/src/components/MessageItem.tsx +655 -0
  55. package/src/components/ModelPicker.tsx +487 -0
  56. package/src/components/PixleSol.tsx +243 -0
  57. package/src/components/PluginBuilder.tsx +285 -0
  58. package/src/components/PluginManager.tsx +341 -0
  59. package/src/components/PreviewPane.tsx +166 -0
  60. package/src/components/SettingsModal.tsx +2052 -0
  61. package/src/components/Sidebar.tsx +504 -0
  62. package/src/components/Splash.tsx +104 -0
  63. package/src/components/ToolCallCard.tsx +177 -0
  64. package/src/components/TopBar.tsx +182 -0
  65. package/src/components/TrajectoryView.tsx +178 -0
  66. package/src/components/WorkspacePanel.tsx +710 -0
  67. package/src/components/ui.tsx +311 -0
  68. package/src/core/kernel.ts +45 -0
  69. package/src/core/plugins/tools/fs.ts +219 -0
  70. package/src/core/plugins/tools/git.ts +83 -0
  71. package/src/core/plugins/tools/grep.ts +98 -0
  72. package/src/core/plugins/tools/shell.ts +88 -0
  73. package/src/core/plugins/tools/subagent.ts +36 -0
  74. package/src/core/plugins/tools/web.ts +97 -0
  75. package/src/core/services/agent-loop.ts +417 -0
  76. package/src/core/services/approval.ts +66 -0
  77. package/src/core/services/compaction.ts +45 -0
  78. package/src/core/services/llm.ts +32 -0
  79. package/src/core/services/sessions.ts +47 -0
  80. package/src/core/services/subagents.ts +15 -0
  81. package/src/core/services/system-prompt.ts +177 -0
  82. package/src/core/services/tools.ts +135 -0
  83. package/src/core/services/ui.ts +109 -0
  84. package/src/core/types.ts +139 -0
  85. package/src/data/providers.ts +125 -0
  86. package/src/data/skills.ts +68 -0
  87. package/src/index.css +776 -0
  88. package/src/lib/boot.ts +94 -0
  89. package/src/lib/context.ts +75 -0
  90. package/src/lib/conversation.test.mjs +64 -0
  91. package/src/lib/conversation.ts +59 -0
  92. package/src/lib/daemon.ts +475 -0
  93. package/src/lib/hooks.ts +33 -0
  94. package/src/lib/permissions.ts +60 -0
  95. package/src/lib/pixlesol.test.mjs +51 -0
  96. package/src/lib/pixlesol.ts +55 -0
  97. package/src/lib/plugin-folder.test.mjs +145 -0
  98. package/src/lib/plugin-folder.ts +96 -0
  99. package/src/lib/reasoning.ts +87 -0
  100. package/src/lib/sse.ts +75 -0
  101. package/src/lib/themes.test.mjs +72 -0
  102. package/src/lib/themes.ts +52 -0
  103. package/src/lib/usage.test.mjs +72 -0
  104. package/src/lib/usage.ts +155 -0
  105. package/src/lib/utils.ts +73 -0
  106. package/src/main.tsx +16 -0
  107. package/src/plugins/api.test.mjs +77 -0
  108. package/src/plugins/api.ts +113 -0
  109. package/src/plugins/bridge.d.mts +1 -0
  110. package/src/plugins/bridge.mjs +26 -0
  111. package/src/plugins/bridge.test.mjs +17 -0
  112. package/src/plugins/index.ts +52 -0
  113. package/src/plugins/preview.test.mjs +18 -0
  114. package/src/plugins/preview.ts +34 -0
  115. package/src/plugins/runtime.ts +194 -0
  116. package/src/plugins/types.ts +40 -0
  117. package/src/store/chats.ts +278 -0
  118. package/src/store/models.ts +120 -0
  119. package/src/store/settings.ts +445 -0
  120. package/src/store/ui.ts +132 -0
  121. package/src/store/workspace.ts +299 -0
  122. package/src/types.ts +337 -0
  123. package/src/vite-env.d.ts +1 -0
  124. package/tailwind.config.js +83 -0
  125. package/tsconfig.json +22 -0
  126. package/tsconfig.tsbuildinfo +1 -0
  127. package/vite.config.ts +19 -0
@@ -0,0 +1,445 @@
1
+ import { create } from 'zustand'
2
+ import type {
3
+ AppSettings,
4
+ DaemonSnapshot,
5
+ DeployProviderId,
6
+ DeploySecretEntry,
7
+ DeployToolInfo,
8
+ Effort,
9
+ ProviderId,
10
+ SecretEntry,
11
+ SecretsSummary,
12
+ Skill,
13
+ ToolName,
14
+ ToolSetting,
15
+ } from '../types'
16
+ import { BUILTIN_SKILLS } from '../data/skills'
17
+ import { PROVIDER_MAP } from '../data/providers'
18
+ import {
19
+ patchSettings,
20
+ setDeployCredentials as saveDeployCredentials,
21
+ setProviderKey,
22
+ setSearchKey as saveSearchKey,
23
+ listProviders,
24
+ } from '../lib/daemon'
25
+ import { defaultEffort, normalizeEffort } from '../lib/reasoning'
26
+ import { normalizeTheme } from '../lib/themes'
27
+
28
+ export const DEFAULT_SYSTEM_PROMPT = `You are OpenSol, a senior web engineer and coding agent built by OpenSoft.
29
+
30
+ You work directly in the user's open folder through a local daemon. The shell, file, git, search and deploy tools are real. Never describe tool output you did not receive.
31
+
32
+ Engineering standard:
33
+ - Read every file you change. Match the existing stack, style, naming and conventions. Never invent APIs, versions, flags, package names or paths.
34
+ - Make the smallest correct change. One concern per edit. No speculative abstraction, dead code, placeholder TODOs or swallowed errors.
35
+ - Keep public behavior stable unless the task explicitly changes it.
36
+ - Security: never expose secrets, never weaken auth, CSRF, CSP or validation to make something pass, and validate untrusted input at boundaries.
37
+ - Accessibility: semantic HTML, keyboard paths, visible focus, sufficient contrast.
38
+ - Performance: avoid needless work; measure before optimizing.
39
+ - Every bug fix gets a regression test when a test setup exists. Tests assert behavior, not implementation.
40
+
41
+ Working loop:
42
+ 1. Inspect before editing: list files, read the relevant source, grep for call sites, check git status.
43
+ 2. For non-trivial work, state a short plan.
44
+ 3. Edit in small, coherent steps.
45
+ 4. Verify with the project's real commands (typecheck, lint, tests, build) and read the output.
46
+ 5. Report what changed, which files, and what was verified. If a check fails or cannot run, say so plainly and explain why.
47
+
48
+ Never claim a check passed without running it. Ask only when a decision is genuinely ambiguous or destructive. Answer in the user's language. Cite web sources as links, and only when you actually read them.`
49
+
50
+ const DEFAULT_TOOLS: Record<ToolName, ToolSetting> = {
51
+ shell: { enabled: true, approval: 'ask' },
52
+ read_file: { enabled: true, approval: 'auto' },
53
+ list_files: { enabled: true, approval: 'auto' },
54
+ write_file: { enabled: true, approval: 'auto' },
55
+ edit_file: { enabled: true, approval: 'auto' },
56
+ rewrite_file: { enabled: true, approval: 'auto' },
57
+ grep: { enabled: true, approval: 'auto' },
58
+ web_search: { enabled: true, approval: 'auto' },
59
+ read_url: { enabled: true, approval: 'auto' },
60
+ git: { enabled: true, approval: 'ask' },
61
+ subagent: { enabled: true, approval: 'ask' },
62
+ }
63
+
64
+ const DEFAULTS: AppSettings = {
65
+ provider: 'openai',
66
+ model: '',
67
+ effort: 'medium',
68
+ webSearchEnabled: true,
69
+ temperature: 0.4,
70
+ maxTokens: 8192,
71
+ systemPrompt: DEFAULT_SYSTEM_PROMPT,
72
+ providers: {},
73
+ searchProvider: 'duckduckgo',
74
+ tools: DEFAULT_TOOLS,
75
+ skills: BUILTIN_SKILLS,
76
+ theme: 'system',
77
+ showTokenEstimate: true,
78
+ mode: 'build',
79
+ chatFontSize: 15,
80
+ workDetail: 'detailed',
81
+ autoCollapseWork: true,
82
+ }
83
+
84
+ const EFFORTS = new Set<Effort>(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
85
+
86
+ interface SettingsStore extends AppSettings {
87
+ hydrated: boolean
88
+ daemon: DaemonSnapshot | null
89
+ secretStatus: SecretsSummary | null
90
+ providerStatus: DaemonSnapshot['providers']
91
+ deployTools: DaemonSnapshot['deployTools'] | null
92
+ deploySecrets: DaemonSnapshot['deploySecrets'] | null
93
+ secretsError: string
94
+ hydrate: (snapshot: DaemonSnapshot) => void
95
+ update: (patch: Partial<AppSettings>) => void
96
+ selectModel: (provider: ProviderId, model: string, effort?: Effort) => void
97
+ updateProvider: (provider: ProviderId, patch: { baseUrl?: string }) => void
98
+ saveProviderKey: (provider: ProviderId, apiKey: string) => Promise<void>
99
+ saveSearchKey: (apiKey: string) => Promise<void>
100
+ saveDeployCredentials: (provider: DeployProviderId, fields: Record<string, string>) => Promise<void>
101
+ refreshProviders: () => Promise<void>
102
+ updateTool: (tool: ToolName, patch: Partial<ToolSetting>) => void
103
+ addSkill: (skill: Skill) => void
104
+ updateSkill: (id: string, patch: Partial<Skill>) => void
105
+ removeSkill: (id: string) => void
106
+ setMode: (mode: AppSettings['mode']) => void
107
+ flush: () => Promise<void>
108
+ }
109
+
110
+ function notify(message: string, tone: 'info' | 'success' | 'error' = 'error') {
111
+ if (typeof window === 'undefined') return
112
+ window.dispatchEvent(new CustomEvent('maxcode:notice', { detail: { message, tone } }))
113
+ }
114
+
115
+ function mergeTools(saved?: Partial<Record<ToolName, Partial<ToolSetting>>>) {
116
+ const merged = {} as Record<ToolName, ToolSetting>
117
+ for (const key of Object.keys(DEFAULT_TOOLS) as ToolName[]) {
118
+ merged[key] = { ...DEFAULT_TOOLS[key], ...(saved?.[key] ?? {}) }
119
+ }
120
+ return merged
121
+ }
122
+
123
+ function mergeSkills(saved?: Skill[]) {
124
+ const byId = new Map<string, Skill>()
125
+ for (const skill of BUILTIN_SKILLS) byId.set(skill.id, skill)
126
+ for (const skill of saved ?? []) {
127
+ if (!skill || typeof skill.id !== 'string') continue
128
+ byId.set(skill.id, { ...(byId.get(skill.id) ?? {}), ...skill, builtIn: skill.builtIn ?? byId.has(skill.id) })
129
+ }
130
+ return [...byId.values()]
131
+ }
132
+
133
+ function upgradePrompt(value: unknown) {
134
+ if (typeof value !== 'string' || !value.trim()) return DEFAULT_SYSTEM_PROMPT
135
+ const legacy =
136
+ /sandboxed workspace stored in the user's browser/i.test(value) ||
137
+ /There is no node, npm, git or network in the shell/i.test(value) ||
138
+ /workspace root is \/workspace/i.test(value)
139
+ return legacy ? DEFAULT_SYSTEM_PROMPT : value
140
+ }
141
+
142
+ function sanitizeBaseUrls(saved: AppSettings['providers'] | undefined) {
143
+ const providers: AppSettings['providers'] = {}
144
+ for (const [id, value] of Object.entries(saved ?? {})) {
145
+ if (!(id in PROVIDER_MAP) || !value || typeof value !== 'object') continue
146
+ if (typeof value.baseUrl === 'string' && value.baseUrl.trim()) {
147
+ providers[id] = { baseUrl: value.baseUrl.trim() }
148
+ }
149
+ }
150
+ return providers
151
+ }
152
+
153
+ function buildSettings(persisted?: Partial<AppSettings>): AppSettings {
154
+ const saved = persisted ?? {}
155
+ const provider =
156
+ typeof saved.provider === 'string' && saved.provider in PROVIDER_MAP
157
+ ? (saved.provider as ProviderId)
158
+ : DEFAULTS.provider
159
+ const mode = saved.mode === 'plan' ? 'plan' : saved.mode === 'build' ? 'build' : DEFAULTS.mode
160
+ return {
161
+ ...DEFAULTS,
162
+ ...saved,
163
+ provider,
164
+ model: typeof saved.model === 'string' ? saved.model.trim() : '',
165
+ mode,
166
+ effort: typeof saved.effort === 'string' && EFFORTS.has(saved.effort as Effort)
167
+ ? (saved.effort as Effort)
168
+ : DEFAULTS.effort,
169
+ webSearchEnabled: saved.webSearchEnabled !== false,
170
+ showTokenEstimate: saved.showTokenEstimate !== false,
171
+ temperature:
172
+ typeof saved.temperature === 'number' && Number.isFinite(saved.temperature)
173
+ ? Math.max(0, Math.min(2, saved.temperature))
174
+ : DEFAULTS.temperature,
175
+ maxTokens:
176
+ typeof saved.maxTokens === 'number' && Number.isFinite(saved.maxTokens)
177
+ ? Math.max(256, Math.min(200_000, Math.round(saved.maxTokens)))
178
+ : DEFAULTS.maxTokens,
179
+ systemPrompt: upgradePrompt(saved.systemPrompt),
180
+ providers: sanitizeBaseUrls(saved.providers),
181
+ searchProvider:
182
+ saved.searchProvider && ['duckduckgo', 'tavily', 'brave', 'serper'].includes(saved.searchProvider)
183
+ ? saved.searchProvider
184
+ : DEFAULTS.searchProvider,
185
+ tools: mergeTools(saved.tools),
186
+ skills: mergeSkills(saved.skills),
187
+ theme: normalizeTheme(saved.theme),
188
+ chatFontSize:
189
+ typeof saved.chatFontSize === 'number' && Number.isFinite(saved.chatFontSize)
190
+ ? Math.max(12, Math.min(20, Math.round(saved.chatFontSize)))
191
+ : DEFAULTS.chatFontSize,
192
+ workDetail: saved.workDetail === 'concise' ? 'concise' : 'detailed',
193
+ autoCollapseWork: saved.autoCollapseWork !== false,
194
+ }
195
+ }
196
+
197
+ let persistEnabled = false
198
+ let pending: Partial<AppSettings> = {}
199
+ let saveTimer: number | undefined
200
+ let maxTimer: number | undefined
201
+
202
+ function persistedPatch(patch: Partial<AppSettings>): Partial<AppSettings> {
203
+ const out: Record<string, unknown> = {}
204
+ const keys: Array<keyof AppSettings> = [
205
+ 'provider',
206
+ 'model',
207
+ 'effort',
208
+ 'webSearchEnabled',
209
+ 'temperature',
210
+ 'maxTokens',
211
+ 'systemPrompt',
212
+ 'providers',
213
+ 'searchProvider',
214
+ 'tools',
215
+ 'skills',
216
+ 'theme',
217
+ 'showTokenEstimate',
218
+ 'mode',
219
+ 'chatFontSize',
220
+ 'workDetail',
221
+ 'autoCollapseWork',
222
+ ]
223
+ for (const key of keys) {
224
+ if (key in patch) out[key] = patch[key]
225
+ }
226
+ return out as Partial<AppSettings>
227
+ }
228
+
229
+ function flushSettings() {
230
+ if (!persistEnabled || Object.keys(pending).length === 0) return Promise.resolve()
231
+ const body = persistedPatch(pending)
232
+ pending = {}
233
+ if (saveTimer !== undefined) window.clearTimeout(saveTimer)
234
+ if (maxTimer !== undefined) window.clearTimeout(maxTimer)
235
+ saveTimer = undefined
236
+ maxTimer = undefined
237
+ return patchSettings(body)
238
+ .then(() => undefined)
239
+ .catch((error) => {
240
+ notify(error instanceof Error ? error.message : 'Could not save settings to the local daemon.')
241
+ })
242
+ }
243
+
244
+ function queueSave(patch: Partial<AppSettings>) {
245
+ if (!persistEnabled) return
246
+ pending = { ...pending, ...persistedPatch(patch) }
247
+ if (saveTimer !== undefined) window.clearTimeout(saveTimer)
248
+ saveTimer = window.setTimeout(() => void flushSettings(), 550)
249
+ if (maxTimer === undefined) {
250
+ maxTimer = window.setTimeout(() => void flushSettings(), 4000)
251
+ }
252
+ }
253
+
254
+ export function enableSettingsPersistence() {
255
+ persistEnabled = true
256
+ if (Object.keys(pending).length > 0) queueSave({})
257
+ }
258
+
259
+ export const useSettings = create<SettingsStore>()((set, get) => ({
260
+ ...DEFAULTS,
261
+ hydrated: false,
262
+ daemon: null,
263
+ secretStatus: null,
264
+ providerStatus: [],
265
+ deployTools: null,
266
+ deploySecrets: null,
267
+ secretsError: '',
268
+ hydrate: (snapshot) => {
269
+ const settings = buildSettings(snapshot.settings as Partial<AppSettings>)
270
+ const statuses = snapshot.providers
271
+ const configured = statuses.filter((provider) => provider.configured)
272
+ if (!providerHasCredentialsRaw(settings, statuses, settings.provider) && configured.length > 0) {
273
+ settings.provider = configured[0].id
274
+ settings.model = ''
275
+ }
276
+ if (!providerHasCredentialsRaw(settings, statuses, settings.provider)) settings.model = ''
277
+ set({
278
+ ...settings,
279
+ hydrated: true,
280
+ daemon: snapshot,
281
+ secretStatus: snapshot.secrets,
282
+ providerStatus: statuses,
283
+ deployTools: snapshot.deployTools,
284
+ deploySecrets: snapshot.deploySecrets,
285
+ secretsError: snapshot.secretsError,
286
+ })
287
+ },
288
+ update: (patch) => {
289
+ set(patch)
290
+ queueSave(patch)
291
+ },
292
+ selectModel: (provider, model, effort) => {
293
+ const nextEffort = effort ?? get().effort
294
+ const patch: Partial<AppSettings> = { provider, model: model.trim(), effort: nextEffort }
295
+ set(patch)
296
+ queueSave(patch)
297
+ },
298
+ updateProvider: (provider, patch) => {
299
+ const next = {
300
+ providers: {
301
+ ...get().providers,
302
+ [provider]: { ...(get().providers[provider] ?? {}), ...patch },
303
+ },
304
+ }
305
+ set(next)
306
+ queueSave(next)
307
+ },
308
+ saveProviderKey: async (provider, apiKey) => {
309
+ const result = await setProviderKey(provider, apiKey.trim())
310
+ const wasConfigured = providerHasCredentials(get(), provider)
311
+ set((state) => {
312
+ const secretStatus = state.secretStatus
313
+ ? {
314
+ ...state.secretStatus,
315
+ providers: {
316
+ ...state.secretStatus.providers,
317
+ [provider]: { configured: result.configured, hint: result.hint },
318
+ },
319
+ }
320
+ : state.secretStatus
321
+ const providerStatus = state.providerStatus.map((entry) =>
322
+ entry.id === provider ? { ...entry, configured: result.configured, hint: result.hint } : entry,
323
+ )
324
+ const activate = !result.configured ? false : !hasCredentials(state) || state.provider === provider
325
+ const lost = state.provider === provider && !result.configured
326
+ const patch: Partial<AppSettings> = {}
327
+ if (activate) patch.provider = provider
328
+ if (lost) patch.model = ''
329
+ return {
330
+ secretStatus,
331
+ providerStatus,
332
+ ...patch,
333
+ }
334
+ })
335
+ if (!wasConfigured && result.configured) {
336
+ notify(`${PROVIDER_MAP[provider]?.name ?? provider} is connected.`, 'success')
337
+ }
338
+ },
339
+ saveSearchKey: async (apiKey) => {
340
+ const result = await saveSearchKey(apiKey.trim())
341
+ set((state) => ({
342
+ secretStatus: state.secretStatus
343
+ ? { ...state.secretStatus, search: { configured: result.configured, hint: result.hint } }
344
+ : state.secretStatus,
345
+ }))
346
+ },
347
+ saveDeployCredentials: async (provider, fields) => {
348
+ const result = (await saveDeployCredentials(provider, fields)) as DeploySecretEntry
349
+ set((state) => ({
350
+ secretStatus: state.secretStatus
351
+ ? {
352
+ ...state.secretStatus,
353
+ deploy: { ...state.secretStatus.deploy, [provider]: result },
354
+ }
355
+ : state.secretStatus,
356
+ deploySecrets: state.deploySecrets
357
+ ? { ...state.deploySecrets, [provider]: result }
358
+ : state.deploySecrets,
359
+ }))
360
+ },
361
+ refreshProviders: async () => {
362
+ const result = await listProviders()
363
+ set({ providerStatus: result.providers })
364
+ },
365
+ updateTool: (tool, patch) => {
366
+ const next = { tools: { ...get().tools, [tool]: { ...get().tools[tool], ...patch } } }
367
+ set(next)
368
+ queueSave(next)
369
+ },
370
+ addSkill: (skill) => {
371
+ const next = { skills: [...get().skills, skill] }
372
+ set(next)
373
+ queueSave(next)
374
+ },
375
+ updateSkill: (id, patch) => {
376
+ const next = { skills: get().skills.map((skill) => (skill.id === id ? { ...skill, ...patch } : skill)) }
377
+ set(next)
378
+ queueSave(next)
379
+ },
380
+ removeSkill: (id) => {
381
+ const next = { skills: get().skills.filter((skill) => skill.id !== id) }
382
+ set(next)
383
+ queueSave(next)
384
+ },
385
+ setMode: (mode) => {
386
+ const patch = { mode }
387
+ set(patch)
388
+ queueSave(patch)
389
+ },
390
+ flush: () => flushSettings(),
391
+ }))
392
+
393
+ export function activeModelId(settings: AppSettings): string {
394
+ return settings.model.trim()
395
+ }
396
+
397
+ export function providerHasCredentials(settings: AppSettings, provider: ProviderId): boolean {
398
+ return providerHasCredentialsRaw(settings, useSettings.getState().providerStatus, provider)
399
+ }
400
+
401
+ export function providerStatusFor(provider: ProviderId) {
402
+ return useSettings.getState().providerStatus.find((entry) => entry.id === provider)
403
+ }
404
+
405
+ function providerHasCredentialsRaw(
406
+ settings: AppSettings,
407
+ statuses: DaemonSnapshot['providers'],
408
+ provider: ProviderId,
409
+ ) {
410
+ const preset = PROVIDER_MAP[provider]
411
+ if (!preset) return false
412
+ const status = statuses.find((entry) => entry.id === provider)
413
+ if (status) return status.configured
414
+ if (provider === 'custom') return Boolean(settings.providers.custom?.baseUrl?.trim())
415
+ return Boolean(preset.keyless)
416
+ }
417
+
418
+ export function hasCredentials(settings: AppSettings): boolean {
419
+ return providerHasCredentials(settings, settings.provider)
420
+ }
421
+
422
+ export function resolveBaseUrl(settings: AppSettings): string {
423
+ const preset = PROVIDER_MAP[settings.provider]
424
+ const custom = settings.providers[settings.provider]?.baseUrl?.trim()
425
+ return (custom || preset?.baseUrl || '').replace(/\/+$/, '')
426
+ }
427
+
428
+ export function activeEffort(settings: AppSettings, model?: Parameters<typeof normalizeEffort>[1]) {
429
+ return normalizeEffort(settings.effort, model)
430
+ }
431
+
432
+ export function preferredEffort(model?: Parameters<typeof defaultEffort>[0]) {
433
+ return defaultEffort(model)
434
+ }
435
+
436
+ export type { SecretEntry, DeployToolInfo }
437
+
438
+ if (typeof window !== 'undefined') {
439
+ window.addEventListener('beforeunload', () => {
440
+ void useSettings.getState().flush()
441
+ })
442
+ document.addEventListener('visibilitychange', () => {
443
+ if (document.visibilityState === 'hidden') void useSettings.getState().flush()
444
+ })
445
+ }
@@ -0,0 +1,132 @@
1
+ import { create } from 'zustand'
2
+
3
+ interface RuntimeState {
4
+ streaming: Record<string, boolean>
5
+ setStreaming: (sessionId: string, active: boolean) => void
6
+ }
7
+
8
+ export const useRuntime = create<RuntimeState>()((set) => ({
9
+ streaming: {},
10
+ setStreaming: (sessionId, active) =>
11
+ set((state) => {
12
+ if (Boolean(state.streaming[sessionId]) === active) return state
13
+ const streaming = { ...state.streaming }
14
+ if (active) streaming[sessionId] = true
15
+ else delete streaming[sessionId]
16
+ return { streaming }
17
+ }),
18
+ }))
19
+
20
+ type ToastTone = 'info' | 'success' | 'error'
21
+
22
+ interface Toast {
23
+ id: number
24
+ message: string
25
+ tone: ToastTone
26
+ }
27
+
28
+ export interface ConfirmOptions {
29
+ title: string
30
+ body: string
31
+ confirmLabel?: string
32
+ tone?: 'danger' | 'default'
33
+ }
34
+
35
+ interface PendingConfirm extends ConfirmOptions {
36
+ resolve: (value: boolean) => void
37
+ }
38
+
39
+ interface UiState {
40
+ sidebarCollapsed: boolean
41
+ toggleSidebar: () => void
42
+ mobileSidebarOpen: boolean
43
+ setMobileSidebarOpen: (open: boolean) => void
44
+ workspaceOpen: boolean
45
+ setWorkspaceOpen: (open: boolean) => void
46
+ settingsOpen: boolean
47
+ settingsTab:
48
+ | 'general'
49
+ | 'models'
50
+ | 'tools'
51
+ | 'search'
52
+ | 'skills'
53
+ | 'plugins'
54
+ | 'deploy'
55
+ | 'appearance'
56
+ | 'about'
57
+ openSettings: (tab?: UiState['settingsTab']) => void
58
+ closeSettings: () => void
59
+ deployOpen: boolean
60
+ setDeployOpen: (open: boolean) => void
61
+ paletteOpen: boolean
62
+ setPaletteOpen: (open: boolean) => void
63
+ composerFocus: number
64
+ focusComposer: () => void
65
+ toast: Toast | null
66
+ notify: (message: string, tone?: ToastTone) => void
67
+ dismissToast: (id: number) => void
68
+ confirm: PendingConfirm | null
69
+ askConfirm: (options: ConfirmOptions) => Promise<boolean>
70
+ resolveConfirm: (value: boolean) => void
71
+ }
72
+
73
+ function sidebarWasCollapsed() {
74
+ try {
75
+ return localStorage.getItem('maxcode.sidebar-collapsed') === '1'
76
+ } catch {
77
+ return false
78
+ }
79
+ }
80
+
81
+ function rememberSidebar(collapsed: boolean) {
82
+ try {
83
+ localStorage.setItem('maxcode.sidebar-collapsed', collapsed ? '1' : '0')
84
+ } catch {
85
+ // The preference is only a convenience; the session still works without it.
86
+ }
87
+ }
88
+
89
+ export const useUi = create<UiState>()((set, get) => ({
90
+ sidebarCollapsed: sidebarWasCollapsed(),
91
+ toggleSidebar: () =>
92
+ set((state) => {
93
+ const next = !state.sidebarCollapsed
94
+ rememberSidebar(next)
95
+ return { sidebarCollapsed: next }
96
+ }),
97
+ mobileSidebarOpen: false,
98
+ setMobileSidebarOpen: (open) => set({ mobileSidebarOpen: open }),
99
+ workspaceOpen: false,
100
+ setWorkspaceOpen: (open) => set({ workspaceOpen: open }),
101
+ settingsOpen: false,
102
+ settingsTab: 'general',
103
+ openSettings: (tab = 'general') => set({ settingsOpen: true, settingsTab: tab, mobileSidebarOpen: false }),
104
+ closeSettings: () => set({ settingsOpen: false }),
105
+ deployOpen: false,
106
+ setDeployOpen: (open) => set({ deployOpen: open }),
107
+ paletteOpen: false,
108
+ setPaletteOpen: (open) => set({ paletteOpen: open }),
109
+ composerFocus: 0,
110
+ focusComposer: () => set((state) => ({ composerFocus: state.composerFocus + 1 })),
111
+ toast: null,
112
+ notify: (message, tone = 'info') =>
113
+ set({ toast: { id: Date.now() + Math.random(), message, tone } }),
114
+ dismissToast: (id) =>
115
+ set((state) => (state.toast?.id === id ? { toast: null } : state)),
116
+ confirm: null,
117
+ askConfirm: (options) =>
118
+ new Promise<boolean>((resolve) => {
119
+ if (get().confirm) get().confirm?.resolve(false)
120
+ set({ confirm: { ...options, resolve } })
121
+ }),
122
+ resolveConfirm: (value) => {
123
+ const confirm = get().confirm
124
+ set({ confirm: null })
125
+ confirm?.resolve(value)
126
+ },
127
+ }))
128
+
129
+ window.addEventListener('maxcode:notice', (event) => {
130
+ const detail = (event as CustomEvent<{ message: string; tone?: ToastTone }>).detail
131
+ if (detail?.message) useUi.getState().notify(detail.message, detail.tone ?? 'info')
132
+ })