@myrialabs/clopen 0.2.3 → 0.2.5

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 (53) hide show
  1. package/backend/engine/adapters/claude/stream.ts +107 -0
  2. package/backend/engine/adapters/opencode/message-converter.ts +37 -2
  3. package/backend/engine/adapters/opencode/stream.ts +81 -1
  4. package/backend/engine/types.ts +17 -0
  5. package/backend/git/git-service.ts +2 -1
  6. package/backend/ws/git/commit-message.ts +108 -0
  7. package/backend/ws/git/index.ts +3 -1
  8. package/backend/ws/system/index.ts +7 -1
  9. package/backend/ws/system/operations.ts +28 -2
  10. package/backend/ws/user/crud.ts +6 -3
  11. package/frontend/App.svelte +3 -0
  12. package/frontend/components/auth/SetupPage.svelte +2 -2
  13. package/frontend/components/chat/input/ChatInput.svelte +1 -1
  14. package/frontend/components/chat/message/ChatMessage.svelte +64 -16
  15. package/frontend/components/chat/tools/components/FileHeader.svelte +19 -5
  16. package/frontend/components/chat/widgets/FloatingTodoList.svelte +30 -26
  17. package/frontend/components/checkpoint/ConflictResolutionModal.svelte +189 -0
  18. package/frontend/components/checkpoint/TimelineModal.svelte +7 -162
  19. package/frontend/components/common/feedback/RestartRequiredModal.svelte +53 -0
  20. package/frontend/components/common/feedback/UpdateBanner.svelte +17 -6
  21. package/frontend/components/common/media/MediaPreview.svelte +187 -0
  22. package/frontend/components/files/FileViewer.svelte +11 -143
  23. package/frontend/components/git/BranchManager.svelte +143 -155
  24. package/frontend/components/git/CommitForm.svelte +61 -11
  25. package/frontend/components/git/DiffViewer.svelte +50 -130
  26. package/frontend/components/git/FileChangeItem.svelte +22 -0
  27. package/frontend/components/settings/SettingsModal.svelte +1 -1
  28. package/frontend/components/settings/SettingsView.svelte +1 -1
  29. package/frontend/components/settings/engines/AIEnginesSettings.svelte +2 -2
  30. package/frontend/components/settings/general/UpdateSettings.svelte +10 -3
  31. package/frontend/components/settings/git/GitSettings.svelte +392 -0
  32. package/frontend/components/settings/model/EngineModelPicker.svelte +275 -0
  33. package/frontend/components/settings/model/ModelSettings.svelte +172 -289
  34. package/frontend/components/workspace/DesktopNavigator.svelte +27 -1
  35. package/frontend/components/workspace/PanelHeader.svelte +1 -3
  36. package/frontend/components/workspace/WorkspaceLayout.svelte +14 -2
  37. package/frontend/components/workspace/panels/FilesPanel.svelte +76 -1
  38. package/frontend/components/workspace/panels/GitPanel.svelte +84 -33
  39. package/frontend/main.ts +4 -0
  40. package/frontend/stores/core/files.svelte.ts +15 -1
  41. package/frontend/stores/features/settings.svelte.ts +13 -2
  42. package/frontend/stores/ui/settings-modal.svelte.ts +9 -9
  43. package/frontend/stores/ui/todo-panel.svelte.ts +39 -0
  44. package/frontend/stores/ui/update.svelte.ts +45 -4
  45. package/frontend/utils/file-type.ts +68 -0
  46. package/index.html +1 -0
  47. package/package.json +1 -1
  48. package/shared/constants/binary-extensions.ts +40 -0
  49. package/shared/types/git.ts +15 -0
  50. package/shared/types/messaging/tool.ts +1 -0
  51. package/shared/types/stores/settings.ts +12 -0
  52. package/shared/utils/file-type-detection.ts +9 -1
  53. package/static/manifest.json +16 -0
@@ -0,0 +1,392 @@
1
+ <script lang="ts">
2
+ import Icon from '$frontend/components/common/display/Icon.svelte';
3
+ import { settings, updateSettings } from '$frontend/stores/features/settings.svelte';
4
+ import { modelStore } from '$frontend/stores/features/models.svelte';
5
+ import { ENGINES } from '$shared/constants/engines';
6
+ import type { EngineType } from '$shared/types/engine';
7
+ import type { EngineModel } from '$shared/types/engine';
8
+ import type { CommitMessageFormat } from '$shared/types/git';
9
+ import type { IconName } from '$shared/types/ui/icons';
10
+
11
+ const formatOptions: { id: CommitMessageFormat; label: string; desc: string; icon: IconName }[] = [
12
+ { id: 'single-line', label: 'Single Line', desc: 'type(scope): subject', icon: 'lucide:minus' },
13
+ { id: 'multi-line', label: 'Multi Line', desc: 'Subject + body', icon: 'lucide:align-left' }
14
+ ];
15
+
16
+ let searchQuery = $state('');
17
+ let refreshing = $state(false);
18
+ let collapsedProviders = $state<Set<string>>(new Set());
19
+
20
+ const commitGen = $derived(settings.commitGenerator);
21
+ const useCustomModel = $derived(commitGen.useCustomModel);
22
+
23
+ // Resolve which model is being used for display
24
+ const activeEngine = $derived(useCustomModel ? commitGen.engine : settings.selectedEngine);
25
+ const activeModel = $derived(useCustomModel ? commitGen.model : settings.selectedModel);
26
+ const activeEngineMeta = $derived(ENGINES.find(e => e.type === activeEngine));
27
+ const activeModelMeta = $derived(modelStore.getById(activeModel));
28
+
29
+ // Models for the custom engine, filtered by search
30
+ const filteredModels = $derived.by(() => {
31
+ const models = modelStore.getByEngine(commitGen.engine);
32
+ if (!searchQuery.trim()) return models;
33
+ const q = searchQuery.toLowerCase();
34
+ return models.filter(m =>
35
+ m.name.toLowerCase().includes(q) ||
36
+ m.modelId.toLowerCase().includes(q) ||
37
+ m.provider.toLowerCase().includes(q) ||
38
+ m.capabilities.some(c => c.toLowerCase().includes(q))
39
+ );
40
+ });
41
+
42
+ // Group models by provider
43
+ const groupedModels = $derived.by(() => {
44
+ const groups = new Map<string, EngineModel[]>();
45
+ for (const model of filteredModels) {
46
+ const key = model.provider;
47
+ if (!groups.has(key)) groups.set(key, []);
48
+ groups.get(key)!.push(model);
49
+ }
50
+ return groups;
51
+ });
52
+
53
+ // Fetch models when custom engine changes (for opencode)
54
+ $effect(() => {
55
+ if (useCustomModel && commitGen.engine !== 'claude-code') {
56
+ modelStore.fetchModels(commitGen.engine);
57
+ }
58
+ });
59
+
60
+ // Sync accordion state when search or models change
61
+ $effect(() => {
62
+ if (!useCustomModel) return;
63
+ if (searchQuery.trim()) {
64
+ collapsedProviders = new Set();
65
+ } else if (groupedModels.size > 0) {
66
+ syncAccordionState();
67
+ }
68
+ });
69
+
70
+ function syncAccordionState() {
71
+ const allProviders = [...groupedModels.keys()];
72
+ let selectedProvider: string | null = null;
73
+ for (const [provider, models] of groupedModels) {
74
+ if (models.some(m => m.id === commitGen.model)) {
75
+ selectedProvider = provider;
76
+ break;
77
+ }
78
+ }
79
+ const collapsed = new Set(allProviders);
80
+ if (selectedProvider) collapsed.delete(selectedProvider);
81
+ collapsedProviders = collapsed;
82
+ }
83
+
84
+ function toggleProvider(provider: string) {
85
+ const next = new Set(collapsedProviders);
86
+ if (next.has(provider)) next.delete(provider);
87
+ else next.add(provider);
88
+ collapsedProviders = next;
89
+ }
90
+
91
+ function formatProvider(provider: string): string {
92
+ return provider.split(/[-_]/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
93
+ }
94
+
95
+ function toggleCustomModel() {
96
+ updateSettings({
97
+ commitGenerator: { ...commitGen, useCustomModel: !useCustomModel }
98
+ });
99
+ }
100
+
101
+ async function selectEngine(engineType: EngineType) {
102
+ const models = modelStore.getByEngine(engineType);
103
+ const defaultModel = engineType === 'claude-code'
104
+ ? 'claude-code:haiku'
105
+ : (models[0]?.id || '');
106
+ updateSettings({
107
+ commitGenerator: {
108
+ ...commitGen,
109
+ engine: engineType,
110
+ model: defaultModel
111
+ }
112
+ });
113
+ searchQuery = '';
114
+
115
+ if (engineType !== 'claude-code') {
116
+ const fetched = await modelStore.fetchModels(engineType);
117
+ if (fetched.length > 0) {
118
+ updateSettings({
119
+ commitGenerator: { ...settings.commitGenerator, model: fetched[0].id }
120
+ });
121
+ }
122
+ }
123
+
124
+ syncAccordionState();
125
+ }
126
+
127
+ function selectModel(modelId: string) {
128
+ updateSettings({
129
+ commitGenerator: { ...commitGen, model: modelId }
130
+ });
131
+ }
132
+
133
+ function selectFormat(format: CommitMessageFormat) {
134
+ updateSettings({
135
+ commitGenerator: { ...commitGen, format }
136
+ });
137
+ }
138
+
139
+ async function handleRefresh() {
140
+ refreshing = true;
141
+ try {
142
+ await modelStore.refreshModels(commitGen.engine);
143
+ } finally {
144
+ refreshing = false;
145
+ }
146
+ }
147
+ </script>
148
+
149
+ <div class="py-1">
150
+ <h3 class="text-base font-bold text-slate-900 dark:text-slate-100 mb-1.5">Commit Message</h3>
151
+ <p class="text-sm text-slate-600 dark:text-slate-500 mb-4">
152
+ Generate conventional commit messages from staged changes using AI
153
+ </p>
154
+
155
+ <!-- Current Model Info -->
156
+ <div class="mb-5">
157
+ <label class="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Model</label>
158
+ <div class="flex items-center gap-3 px-3.5 py-2.5 border border-slate-200 dark:border-slate-700 rounded-lg bg-slate-50 dark:bg-slate-800/50">
159
+ {#if activeEngineMeta}
160
+ <div class="flex-shrink-0">
161
+ <div class="flex dark:hidden items-center justify-center w-4 h-4">{@html activeEngineMeta.icon.light}</div>
162
+ <div class="hidden dark:flex items-center justify-center w-4 h-4">{@html activeEngineMeta.icon.dark}</div>
163
+ </div>
164
+ {/if}
165
+ <div class="flex-1 min-w-0">
166
+ <span class="text-sm font-medium text-slate-900 dark:text-slate-100">
167
+ {activeModelMeta?.name || activeModel}
168
+ </span>
169
+ {#if !useCustomModel}
170
+ <span class="text-xs text-slate-500 dark:text-slate-400 ml-1.5">(same as assistant)</span>
171
+ {/if}
172
+ </div>
173
+ </div>
174
+ </div>
175
+
176
+ <!-- Custom Model Toggle -->
177
+ <div class="mb-5">
178
+ <button
179
+ type="button"
180
+ class="flex items-center gap-3 w-full text-left"
181
+ onclick={toggleCustomModel}
182
+ >
183
+ <div class="relative w-9 h-5 rounded-full transition-colors duration-200 flex-shrink-0
184
+ {useCustomModel ? 'bg-violet-600' : 'bg-slate-300 dark:bg-slate-600'}">
185
+ <div class="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform duration-200
186
+ {useCustomModel ? 'translate-x-4.5' : 'translate-x-0.5'}"></div>
187
+ </div>
188
+ <div>
189
+ <span class="text-sm font-medium text-slate-900 dark:text-slate-100">Use custom model</span>
190
+ <p class="text-xs text-slate-500 dark:text-slate-400">Use a different engine and model instead of the assistant model</p>
191
+ </div>
192
+ </button>
193
+ </div>
194
+
195
+ <!-- Custom Engine & Model Selection (only when toggled on) -->
196
+ {#if useCustomModel}
197
+ <!-- Engine Selection -->
198
+ <div class="mb-6">
199
+ <label class="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Engine</label>
200
+ <div class="flex gap-3">
201
+ {#each ENGINES as engine (engine.type)}
202
+ {@const isActive = commitGen.engine === engine.type}
203
+ <button
204
+ type="button"
205
+ class="flex-1 flex items-center gap-3 p-3.5 overflow-hidden border-2 rounded-xl text-left cursor-pointer transition-all duration-200
206
+ {isActive
207
+ ? 'border-violet-600 bg-gradient-to-br from-violet-500/10 to-purple-500/5 dark:from-violet-500/12 dark:to-purple-500/8'
208
+ : 'border-slate-200 dark:border-slate-800 bg-slate-100/80 dark:bg-slate-800/80 hover:border-violet-500/20 dark:hover:border-violet-500/35'}"
209
+ onclick={() => selectEngine(engine.type)}
210
+ >
211
+ <div>
212
+ <div class="flex dark:hidden items-center justify-center w-5 h-5">{@html engine.icon.light}</div>
213
+ <div class="hidden dark:flex items-center justify-center w-5 h-5">{@html engine.icon.dark}</div>
214
+ </div>
215
+ <div>
216
+ <div class="font-bold text-sm text-slate-900 dark:text-slate-100">{engine.name}</div>
217
+ <div class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{engine.description}</div>
218
+ </div>
219
+ {#if isActive}
220
+ <div class="flex items-center justify-center w-5 h-5 bg-gradient-to-br from-violet-600 to-purple-600 rounded-full text-white ml-auto flex-shrink-0">
221
+ <svg viewBox="0 0 24 24" fill="none" class="w-3 h-3" aria-hidden="true">
222
+ <path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
223
+ </svg>
224
+ </div>
225
+ {/if}
226
+ </button>
227
+ {/each}
228
+ </div>
229
+ </div>
230
+
231
+ <!-- Model Selection -->
232
+ <div class="mb-6">
233
+ <div class="flex items-center justify-between mb-1.5">
234
+ <label class="text-sm font-semibold text-slate-700 dark:text-slate-300">Model</label>
235
+ <button
236
+ type="button"
237
+ class="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg transition-colors cursor-pointer
238
+ text-slate-500 hover:text-violet-600 hover:bg-violet-500/10 dark:hover:text-violet-400 dark:hover:bg-violet-500/15
239
+ disabled:opacity-50 disabled:cursor-not-allowed"
240
+ onclick={handleRefresh}
241
+ disabled={refreshing || modelStore.loading}
242
+ >
243
+ <svg viewBox="0 0 24 24" fill="none" class="w-3.5 h-3.5 {refreshing ? 'animate-spin' : ''}" aria-hidden="true">
244
+ <path d="M21 12a9 9 0 11-2.636-6.364M21 3v5h-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
245
+ </svg>
246
+ {refreshing ? 'Refreshing...' : 'Refresh'}
247
+ </button>
248
+ </div>
249
+ <p class="text-sm text-slate-600 dark:text-slate-500 mb-3">
250
+ Select the model for the {ENGINES.find(e => e.type === commitGen.engine)?.name || 'selected'} engine
251
+ </p>
252
+
253
+ <!-- Search -->
254
+ <div class="relative mb-3">
255
+ <svg viewBox="0 0 24 24" fill="none" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" aria-hidden="true">
256
+ <circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2" />
257
+ <path d="M21 21l-4.35-4.35" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
258
+ </svg>
259
+ <input
260
+ type="text"
261
+ bind:value={searchQuery}
262
+ placeholder="Search models..."
263
+ class="w-full pl-9 pr-3 py-2 text-sm bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg outline-none focus:ring-2 focus:ring-violet-500/20 focus:border-violet-600 transition-colors text-slate-900 dark:text-slate-100 placeholder-slate-400"
264
+ />
265
+ </div>
266
+
267
+ <!-- Model List -->
268
+ <div class="flex flex-col gap-1.5">
269
+ {#if modelStore.loading && commitGen.engine !== 'claude-code' && !refreshing}
270
+ <!-- Loading skeleton -->
271
+ <div class="border border-slate-200/80 dark:border-slate-700/50 rounded-lg overflow-hidden">
272
+ <div class="bg-white/80 dark:bg-slate-800/40 px-3 py-3 flex items-center gap-3">
273
+ <div class="w-4 h-4 rounded-full bg-slate-200 dark:bg-slate-700 animate-pulse"></div>
274
+ <div class="h-3.5 w-32 rounded bg-slate-200 dark:bg-slate-700 animate-pulse"></div>
275
+ </div>
276
+ <div class="px-4 py-2.5 space-y-2.5">
277
+ {#each Array(3) as _}
278
+ <div class="flex items-center gap-3 py-2">
279
+ <div class="w-4 h-4 rounded-full bg-slate-200/80 dark:bg-slate-700/60 animate-pulse"></div>
280
+ <div class="flex-1 space-y-1.5">
281
+ <div class="h-3.5 w-40 rounded bg-slate-200/80 dark:bg-slate-700/60 animate-pulse"></div>
282
+ <div class="flex gap-1.5">
283
+ <div class="h-3 w-14 rounded bg-slate-200/60 dark:bg-slate-700/40 animate-pulse"></div>
284
+ <div class="h-3 w-12 rounded bg-slate-200/60 dark:bg-slate-700/40 animate-pulse"></div>
285
+ </div>
286
+ </div>
287
+ </div>
288
+ {/each}
289
+ </div>
290
+ </div>
291
+ {:else if filteredModels.length === 0}
292
+ <div class="py-4 text-sm text-slate-500 text-center">
293
+ {searchQuery ? 'No models matching your search.' : 'No models available for this engine.'}
294
+ </div>
295
+ {:else}
296
+ {#each [...groupedModels.entries()] as [provider, providerModels] (provider)}
297
+ {@const isCollapsed = collapsedProviders.has(provider)}
298
+ {@const hasSelectedModel = providerModels.some(m => m.id === commitGen.model)}
299
+ <div class="border border-slate-200/80 dark:border-slate-700/50 rounded-lg overflow-hidden">
300
+ <!-- Accordion header -->
301
+ <button
302
+ type="button"
303
+ class="flex items-center gap-2.5 w-full px-3 py-2.5 text-left cursor-pointer transition-colors
304
+ bg-white/80 dark:bg-slate-800/40 hover:bg-white dark:hover:bg-slate-800/60"
305
+ onclick={() => toggleProvider(provider)}
306
+ >
307
+ <svg viewBox="0 0 24 24" fill="none"
308
+ class="w-4 h-4 text-slate-400 transition-transform duration-200 flex-shrink-0
309
+ {isCollapsed ? '' : 'rotate-90'}"
310
+ aria-hidden="true">
311
+ <path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
312
+ </svg>
313
+ <span class="text-sm font-semibold text-slate-800 dark:text-slate-200">
314
+ {formatProvider(provider)}
315
+ </span>
316
+ <span class="text-xs text-slate-400 dark:text-slate-500">
317
+ {providerModels.length} {providerModels.length === 1 ? 'model' : 'models'}
318
+ </span>
319
+ {#if hasSelectedModel}
320
+ <div class="w-1.5 h-1.5 rounded-full bg-violet-500 ml-auto flex-shrink-0"></div>
321
+ {/if}
322
+ </button>
323
+
324
+ <!-- Accordion body -->
325
+ {#if !isCollapsed}
326
+ <div class="flex flex-col bg-white/40 dark:bg-slate-800/20">
327
+ {#each providerModels as model (model.id)}
328
+ {@const isSelected = commitGen.model === model.id}
329
+ {@const caps = model.capabilities}
330
+ <button
331
+ type="button"
332
+ class="flex items-start gap-3 px-3 py-2.5 text-left cursor-pointer transition-all duration-150
333
+ {isSelected
334
+ ? 'bg-violet-500/10 dark:bg-violet-500/12'
335
+ : 'hover:bg-slate-100/80 dark:hover:bg-slate-700/30'}"
336
+ onclick={() => selectModel(model.id)}
337
+ >
338
+ <div class="flex-shrink-0 w-4 h-4 rounded-full border-2 flex items-center justify-center mt-0.5
339
+ {isSelected ? 'border-violet-600' : 'border-slate-300 dark:border-slate-600'}">
340
+ {#if isSelected}
341
+ <div class="w-2 h-2 rounded-full bg-violet-600"></div>
342
+ {/if}
343
+ </div>
344
+ <div class="flex-1 min-w-0">
345
+ <div class="flex items-center gap-2">
346
+ <span class="text-sm font-medium text-slate-900 dark:text-slate-100">{model.name}</span>
347
+ </div>
348
+ {#if caps.length > 0}
349
+ <div class="flex flex-wrap gap-1 mt-1.5">
350
+ {#each caps as cap}
351
+ <span class="px-1.5 py-0.5 text-2xs rounded bg-slate-100 dark:bg-slate-700/50 text-slate-500 dark:text-slate-400 leading-none">
352
+ {cap}
353
+ </span>
354
+ {/each}
355
+ </div>
356
+ {/if}
357
+ </div>
358
+ </button>
359
+ {/each}
360
+ </div>
361
+ {/if}
362
+ </div>
363
+ {/each}
364
+ {/if}
365
+ </div>
366
+ </div>
367
+ {/if}
368
+
369
+ <!-- Format Selection -->
370
+ <div class="mb-2">
371
+ <label class="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Message Format</label>
372
+ <div class="flex gap-2">
373
+ {#each formatOptions as fmt (fmt.id)}
374
+ {@const isActive = commitGen.format === fmt.id}
375
+ <button
376
+ type="button"
377
+ class="flex-1 flex items-center gap-2.5 p-3 border-2 rounded-xl text-left cursor-pointer transition-all duration-200
378
+ {isActive
379
+ ? 'border-violet-600 bg-gradient-to-br from-violet-500/10 to-purple-500/5 dark:from-violet-500/12 dark:to-purple-500/8'
380
+ : 'border-slate-200 dark:border-slate-800 bg-slate-100/80 dark:bg-slate-800/80 hover:border-violet-500/20 dark:hover:border-violet-500/35'}"
381
+ onclick={() => selectFormat(fmt.id)}
382
+ >
383
+ <Icon name={fmt.icon} class="w-4 h-4 {isActive ? 'text-violet-600' : 'text-slate-400'}" />
384
+ <div>
385
+ <div class="text-sm font-medium text-slate-900 dark:text-slate-100">{fmt.label}</div>
386
+ <div class="text-xs text-slate-500 dark:text-slate-400 font-mono">{fmt.desc}</div>
387
+ </div>
388
+ </button>
389
+ {/each}
390
+ </div>
391
+ </div>
392
+ </div>
@@ -0,0 +1,275 @@
1
+ <script lang="ts">
2
+ import { modelStore } from '$frontend/stores/features/models.svelte';
3
+ import { ENGINES } from '$shared/constants/engines';
4
+ import type { EngineType } from '$shared/types/engine';
5
+ import type { EngineModel } from '$shared/types/engine';
6
+
7
+ interface Props {
8
+ engine: EngineType;
9
+ model: string;
10
+ onEngineChange: (engine: EngineType) => void;
11
+ onModelChange: (modelId: string) => void;
12
+ }
13
+
14
+ const { engine, model, onEngineChange, onModelChange }: Props = $props();
15
+
16
+ let searchQuery = $state('');
17
+ let refreshing = $state(false);
18
+ let collapsedProviders = $state<Set<string>>(new Set());
19
+
20
+ // Models for the selected engine, filtered by search
21
+ const filteredModels = $derived.by(() => {
22
+ const models = modelStore.getByEngine(engine);
23
+ if (!searchQuery.trim()) return models;
24
+ const q = searchQuery.toLowerCase();
25
+ return models.filter(m =>
26
+ m.name.toLowerCase().includes(q) ||
27
+ m.modelId.toLowerCase().includes(q) ||
28
+ m.provider.toLowerCase().includes(q) ||
29
+ m.capabilities.some(c => c.toLowerCase().includes(q))
30
+ );
31
+ });
32
+
33
+ // Group models by provider
34
+ const groupedModels = $derived.by(() => {
35
+ const groups = new Map<string, EngineModel[]>();
36
+ for (const m of filteredModels) {
37
+ const key = m.provider;
38
+ if (!groups.has(key)) groups.set(key, []);
39
+ groups.get(key)!.push(m);
40
+ }
41
+ return groups;
42
+ });
43
+
44
+ // Fetch models when engine changes (for non-claude-code)
45
+ $effect(() => {
46
+ if (engine !== 'claude-code') {
47
+ modelStore.fetchModels(engine);
48
+ }
49
+ });
50
+
51
+ // Sync accordion state when search or models change
52
+ $effect(() => {
53
+ if (searchQuery.trim()) {
54
+ collapsedProviders = new Set();
55
+ } else if (groupedModels.size > 0) {
56
+ syncAccordionState();
57
+ }
58
+ });
59
+
60
+ function syncAccordionState() {
61
+ const allProviders = [...groupedModels.keys()];
62
+ let selectedProvider: string | null = null;
63
+ for (const [provider, models] of groupedModels) {
64
+ if (models.some(m => m.id === model)) {
65
+ selectedProvider = provider;
66
+ break;
67
+ }
68
+ }
69
+ const collapsed = new Set(allProviders);
70
+ if (selectedProvider) collapsed.delete(selectedProvider);
71
+ collapsedProviders = collapsed;
72
+ }
73
+
74
+ function toggleProvider(provider: string) {
75
+ const next = new Set(collapsedProviders);
76
+ if (next.has(provider)) next.delete(provider);
77
+ else next.add(provider);
78
+ collapsedProviders = next;
79
+ }
80
+
81
+ function formatProvider(provider: string): string {
82
+ return provider.split(/[-_]/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
83
+ }
84
+
85
+ async function handleEngineChange(engineType: EngineType) {
86
+ searchQuery = '';
87
+ onEngineChange(engineType);
88
+
89
+ if (engineType !== 'claude-code') {
90
+ await modelStore.fetchModels(engineType);
91
+ }
92
+
93
+ syncAccordionState();
94
+ }
95
+
96
+ async function handleRefresh() {
97
+ refreshing = true;
98
+ try {
99
+ await modelStore.refreshModels(engine);
100
+ } finally {
101
+ refreshing = false;
102
+ }
103
+ }
104
+ </script>
105
+
106
+ <!-- Engine Selection -->
107
+ <div class="mb-6">
108
+ <label class="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Engine</label>
109
+ <div class="flex gap-3">
110
+ {#each ENGINES as eng (eng.type)}
111
+ {@const isActive = engine === eng.type}
112
+ <button
113
+ type="button"
114
+ class="flex-1 flex items-center gap-3 p-3.5 overflow-hidden border-2 rounded-xl text-left cursor-pointer transition-all duration-200
115
+ {isActive
116
+ ? 'border-violet-600 bg-gradient-to-br from-violet-500/10 to-purple-500/5 dark:from-violet-500/12 dark:to-purple-500/8'
117
+ : 'border-slate-200 dark:border-slate-800 bg-slate-100/80 dark:bg-slate-800/80 hover:border-violet-500/20 dark:hover:border-violet-500/35'}"
118
+ onclick={() => handleEngineChange(eng.type)}
119
+ >
120
+ <div>
121
+ <div class="flex dark:hidden items-center justify-center w-5 h-5">{@html eng.icon.light}</div>
122
+ <div class="hidden dark:flex items-center justify-center w-5 h-5">{@html eng.icon.dark}</div>
123
+ </div>
124
+ <div>
125
+ <div class="font-bold text-sm text-slate-900 dark:text-slate-100">{eng.name}</div>
126
+ <div class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{eng.description}</div>
127
+ </div>
128
+ {#if isActive}
129
+ <div class="flex items-center justify-center w-5 h-5 bg-gradient-to-br from-violet-600 to-purple-600 rounded-full text-white ml-auto flex-shrink-0">
130
+ <svg viewBox="0 0 24 24" fill="none" class="w-3 h-3" aria-hidden="true">
131
+ <path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
132
+ </svg>
133
+ </div>
134
+ {/if}
135
+ </button>
136
+ {/each}
137
+ </div>
138
+ </div>
139
+
140
+ <!-- Model Selection -->
141
+ <div>
142
+ <div class="flex items-center justify-between mb-1.5">
143
+ <label class="text-sm font-semibold text-slate-700 dark:text-slate-300">Model</label>
144
+ <button
145
+ type="button"
146
+ class="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg transition-colors cursor-pointer
147
+ text-slate-500 hover:text-violet-600 hover:bg-violet-500/10 dark:hover:text-violet-400 dark:hover:bg-violet-500/15
148
+ disabled:opacity-50 disabled:cursor-not-allowed"
149
+ onclick={handleRefresh}
150
+ disabled={refreshing || modelStore.loading}
151
+ >
152
+ <svg viewBox="0 0 24 24" fill="none" class="w-3.5 h-3.5 {refreshing ? 'animate-spin' : ''}" aria-hidden="true">
153
+ <path d="M21 12a9 9 0 11-2.636-6.364M21 3v5h-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
154
+ </svg>
155
+ {refreshing ? 'Refreshing...' : 'Refresh'}
156
+ </button>
157
+ </div>
158
+ <p class="text-sm text-slate-600 dark:text-slate-500 mb-3">
159
+ Select the model for the {ENGINES.find(e => e.type === engine)?.name || 'selected'} engine
160
+ </p>
161
+
162
+ <!-- Search -->
163
+ <div class="relative mb-3">
164
+ <svg viewBox="0 0 24 24" fill="none" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" aria-hidden="true">
165
+ <circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2" />
166
+ <path d="M21 21l-4.35-4.35" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
167
+ </svg>
168
+ <input
169
+ type="text"
170
+ bind:value={searchQuery}
171
+ placeholder="Search models..."
172
+ class="w-full pl-9 pr-3 py-2 text-sm bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg outline-none focus:ring-2 focus:ring-violet-500/20 focus:border-violet-600 transition-colors text-slate-900 dark:text-slate-100 placeholder-slate-400"
173
+ />
174
+ </div>
175
+
176
+ <!-- Model List -->
177
+ <div class="flex flex-col gap-1.5">
178
+ {#if modelStore.loading && engine !== 'claude-code' && !refreshing}
179
+ <!-- Loading skeleton -->
180
+ <div class="border border-slate-200/80 dark:border-slate-700/50 rounded-lg overflow-hidden">
181
+ <div class="bg-white/80 dark:bg-slate-800/40 px-3 py-3 flex items-center gap-3">
182
+ <div class="w-4 h-4 rounded-full bg-slate-200 dark:bg-slate-700 animate-pulse"></div>
183
+ <div class="h-3.5 w-32 rounded bg-slate-200 dark:bg-slate-700 animate-pulse"></div>
184
+ </div>
185
+ <div class="px-4 py-2.5 space-y-2.5">
186
+ {#each Array(3) as _}
187
+ <div class="flex items-center gap-3 py-2">
188
+ <div class="w-4 h-4 rounded-full bg-slate-200/80 dark:bg-slate-700/60 animate-pulse"></div>
189
+ <div class="flex-1 space-y-1.5">
190
+ <div class="h-3.5 w-40 rounded bg-slate-200/80 dark:bg-slate-700/60 animate-pulse"></div>
191
+ <div class="flex gap-1.5">
192
+ <div class="h-3 w-14 rounded bg-slate-200/60 dark:bg-slate-700/40 animate-pulse"></div>
193
+ <div class="h-3 w-12 rounded bg-slate-200/60 dark:bg-slate-700/40 animate-pulse"></div>
194
+ </div>
195
+ </div>
196
+ </div>
197
+ {/each}
198
+ </div>
199
+ </div>
200
+ {:else if filteredModels.length === 0}
201
+ <div class="py-4 text-sm text-slate-500 text-center">
202
+ {searchQuery ? 'No models matching your search.' : 'No models available for this engine.'}
203
+ </div>
204
+ {:else}
205
+ {#each [...groupedModels.entries()] as [provider, providerModels] (provider)}
206
+ {@const isCollapsed = collapsedProviders.has(provider)}
207
+ {@const hasSelectedModel = providerModels.some(m => m.id === model)}
208
+ <div class="border border-slate-200/80 dark:border-slate-700/50 rounded-lg overflow-hidden">
209
+ <!-- Accordion header -->
210
+ <button
211
+ type="button"
212
+ class="flex items-center gap-2.5 w-full px-3 py-2.5 text-left cursor-pointer transition-colors
213
+ bg-white/80 dark:bg-slate-800/40 hover:bg-white dark:hover:bg-slate-800/60"
214
+ onclick={() => toggleProvider(provider)}
215
+ >
216
+ <svg viewBox="0 0 24 24" fill="none"
217
+ class="w-4 h-4 text-slate-400 transition-transform duration-200 flex-shrink-0
218
+ {isCollapsed ? '' : 'rotate-90'}"
219
+ aria-hidden="true">
220
+ <path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
221
+ </svg>
222
+ <span class="text-sm font-semibold text-slate-800 dark:text-slate-200">
223
+ {formatProvider(provider)}
224
+ </span>
225
+ <span class="text-xs text-slate-400 dark:text-slate-500">
226
+ {providerModels.length} {providerModels.length === 1 ? 'model' : 'models'}
227
+ </span>
228
+ {#if hasSelectedModel}
229
+ <div class="w-1.5 h-1.5 rounded-full bg-violet-500 ml-auto flex-shrink-0"></div>
230
+ {/if}
231
+ </button>
232
+
233
+ <!-- Accordion body -->
234
+ {#if !isCollapsed}
235
+ <div class="flex flex-col bg-white/40 dark:bg-slate-800/20">
236
+ {#each providerModels as mdl (mdl.id)}
237
+ {@const isSelected = model === mdl.id}
238
+ {@const caps = mdl.capabilities}
239
+ <button
240
+ type="button"
241
+ class="flex items-start gap-3 px-3 py-2.5 text-left cursor-pointer transition-all duration-150
242
+ {isSelected
243
+ ? 'bg-violet-500/10 dark:bg-violet-500/12'
244
+ : 'hover:bg-slate-100/80 dark:hover:bg-slate-700/30'}"
245
+ onclick={() => onModelChange(mdl.id)}
246
+ >
247
+ <div class="flex-shrink-0 w-4 h-4 rounded-full border-2 flex items-center justify-center mt-0.5
248
+ {isSelected ? 'border-violet-600' : 'border-slate-300 dark:border-slate-600'}">
249
+ {#if isSelected}
250
+ <div class="w-2 h-2 rounded-full bg-violet-600"></div>
251
+ {/if}
252
+ </div>
253
+ <div class="flex-1 min-w-0">
254
+ <div class="flex items-center gap-2">
255
+ <span class="text-sm font-medium text-slate-900 dark:text-slate-100">{mdl.name}</span>
256
+ </div>
257
+ {#if caps.length > 0}
258
+ <div class="flex flex-wrap gap-1 mt-1.5">
259
+ {#each caps as cap}
260
+ <span class="px-1.5 py-0.5 text-2xs rounded bg-slate-100 dark:bg-slate-700/50 text-slate-500 dark:text-slate-400 leading-none">
261
+ {cap}
262
+ </span>
263
+ {/each}
264
+ </div>
265
+ {/if}
266
+ </div>
267
+ </button>
268
+ {/each}
269
+ </div>
270
+ {/if}
271
+ </div>
272
+ {/each}
273
+ {/if}
274
+ </div>
275
+ </div>