@myrialabs/clopen 0.2.2 → 0.2.4

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 (62) hide show
  1. package/.dockerignore +5 -0
  2. package/.env.example +2 -5
  3. package/CONTRIBUTING.md +4 -0
  4. package/README.md +4 -2
  5. package/backend/database/queries/message-queries.ts +42 -0
  6. package/backend/database/utils/connection.ts +5 -5
  7. package/backend/engine/adapters/claude/environment.ts +3 -4
  8. package/backend/engine/adapters/claude/stream.ts +107 -0
  9. package/backend/engine/adapters/opencode/server.ts +7 -1
  10. package/backend/engine/adapters/opencode/stream.ts +81 -1
  11. package/backend/engine/types.ts +17 -0
  12. package/backend/git/git-executor.ts +2 -1
  13. package/backend/git/git-service.ts +2 -1
  14. package/backend/index.ts +10 -10
  15. package/backend/snapshot/blob-store.ts +2 -2
  16. package/backend/utils/env.ts +13 -15
  17. package/backend/utils/index.ts +4 -1
  18. package/backend/utils/paths.ts +11 -0
  19. package/backend/utils/port-utils.ts +19 -6
  20. package/backend/ws/git/commit-message.ts +108 -0
  21. package/backend/ws/git/index.ts +3 -1
  22. package/backend/ws/messages/crud.ts +52 -0
  23. package/backend/ws/system/index.ts +7 -1
  24. package/backend/ws/system/operations.ts +28 -2
  25. package/bin/clopen.ts +15 -15
  26. package/docker-compose.yml +31 -0
  27. package/frontend/App.svelte +3 -0
  28. package/frontend/components/auth/SetupPage.svelte +45 -13
  29. package/frontend/components/chat/input/ChatInput.svelte +1 -1
  30. package/frontend/components/chat/message/ChatMessage.svelte +64 -16
  31. package/frontend/components/chat/widgets/FloatingTodoList.svelte +124 -10
  32. package/frontend/components/checkpoint/ConflictResolutionModal.svelte +189 -0
  33. package/frontend/components/checkpoint/TimelineModal.svelte +7 -162
  34. package/frontend/components/common/feedback/RestartRequiredModal.svelte +53 -0
  35. package/frontend/components/common/feedback/UpdateBanner.svelte +19 -8
  36. package/frontend/components/git/BranchManager.svelte +143 -155
  37. package/frontend/components/git/CommitForm.svelte +61 -11
  38. package/frontend/components/history/HistoryModal.svelte +30 -78
  39. package/frontend/components/history/HistoryView.svelte +45 -92
  40. package/frontend/components/settings/SettingsModal.svelte +1 -1
  41. package/frontend/components/settings/SettingsView.svelte +1 -1
  42. package/frontend/components/settings/appearance/AppearanceSettings.svelte +2 -2
  43. package/frontend/components/settings/engines/AIEnginesSettings.svelte +2 -2
  44. package/frontend/components/settings/general/UpdateSettings.svelte +10 -3
  45. package/frontend/components/settings/git/GitSettings.svelte +392 -0
  46. package/frontend/components/settings/model/EngineModelPicker.svelte +275 -0
  47. package/frontend/components/settings/model/ModelSettings.svelte +172 -289
  48. package/frontend/components/workspace/PanelHeader.svelte +1 -3
  49. package/frontend/components/workspace/WorkspaceLayout.svelte +11 -1
  50. package/frontend/components/workspace/panels/FilesPanel.svelte +41 -3
  51. package/frontend/components/workspace/panels/GitPanel.svelte +53 -8
  52. package/frontend/main.ts +4 -0
  53. package/frontend/stores/features/auth.svelte.ts +28 -0
  54. package/frontend/stores/features/settings.svelte.ts +13 -2
  55. package/frontend/stores/ui/settings-modal.svelte.ts +9 -9
  56. package/frontend/stores/ui/update.svelte.ts +51 -4
  57. package/package.json +2 -2
  58. package/scripts/dev.ts +3 -2
  59. package/scripts/start.ts +24 -0
  60. package/shared/types/git.ts +15 -0
  61. package/shared/types/stores/settings.ts +12 -0
  62. package/vite.config.ts +2 -2
@@ -6,7 +6,6 @@
6
6
  import { setCurrentView } from '$frontend/stores/core/app.svelte';
7
7
  import ws from '$frontend/utils/ws';
8
8
  import type { ChatSession, Project } from '$shared/types/database/schema';
9
- import type { SDKMessage } from '$shared/types/messaging';
10
9
  import Input from '../common/form/Input.svelte';
11
10
  import Select from '../common/form/Select.svelte';
12
11
  import Icon from '$frontend/components/common/display/Icon.svelte';
@@ -62,7 +61,6 @@
62
61
 
63
62
  // Cache for session data to avoid multiple API calls
64
63
  const sessionDataCache = $state<Record<string, {
65
- messages: SDKMessage[];
66
64
  title: string;
67
65
  summary: string;
68
66
  count: number;
@@ -71,95 +69,30 @@
71
69
  }>>({});
72
70
  let loadingSessionData = $state(true);
73
71
 
74
- // Helper to get session data from cache or API
72
+ // Helper to get session data from cache or API (single session fallback)
75
73
  async function getSessionData(sessionId: string) {
76
74
  if (sessionDataCache[sessionId]) {
77
75
  return sessionDataCache[sessionId];
78
76
  }
79
77
 
80
78
  try {
81
- // Get messages from current HEAD checkpoint (active branch only)
82
- const messages = await ws.http('messages:list', { session_id: sessionId, include_all: true });
83
-
84
- // Get title from first user message in current HEAD
85
- const firstUserMessage = messages.find((m: SDKMessage) => m.type === 'user');
86
- let title = 'New Conversation';
87
- if (firstUserMessage) {
88
- // Handle content properly - it can be string or array of content blocks
89
- let textContent = '';
90
- if (typeof firstUserMessage.message.content === 'string') {
91
- textContent = firstUserMessage.message.content;
92
- } else if (Array.isArray(firstUserMessage.message.content)) {
93
- // Extract text from content blocks
94
- const textBlocks = firstUserMessage.message.content.filter((c: any) => c.type === 'text');
95
- textContent = textBlocks.map((b: any) => 'text' in b ? b.text : '').join(' ');
96
- }
97
-
98
- if (textContent) {
99
- title = textContent.slice(0, 60) + (textContent.length > 60 ? '...' : '');
100
- }
101
- }
102
-
103
- // Get summary from last assistant message in current HEAD checkpoint
104
- const assistantMessages = messages.filter((m: SDKMessage) => m.type === 'assistant');
105
- let summary = 'No messages yet';
106
- if (assistantMessages.length > 0) {
107
- const lastMessage = assistantMessages[assistantMessages.length - 1];
108
- const textBlocks = lastMessage.message.content.filter((c: any) => c.type === 'text');
109
- if (textBlocks.length > 0) {
110
- const fullText = textBlocks.map((b: any) => 'text' in b ? b.text : '').join(' ');
111
- const cleanText = fullText.replace(/```[\s\S]*?```/g, '').trim();
112
- summary = cleanText.slice(0, 150) + (cleanText.length > 150 ? '...' : '');
113
- }
79
+ const previews = await ws.http('sessions:preview', { session_ids: [sessionId] });
80
+ const preview = previews[0];
81
+ if (preview) {
82
+ sessionDataCache[sessionId] = {
83
+ title: preview.title,
84
+ summary: preview.summary,
85
+ count: preview.count,
86
+ userCount: preview.userCount,
87
+ assistantCount: preview.assistantCount
88
+ };
89
+ return sessionDataCache[sessionId];
114
90
  }
115
-
116
- // Count user and assistant messages in current HEAD checkpoint
117
- // Filter out empty user messages (same as ChatInterface.svelte timeline logic)
118
- const userMessages = messages.filter((m: SDKMessage) => {
119
- if (m.type !== 'user') return false;
120
- // Extract text content
121
- let textContent = '';
122
- if (typeof m.message.content === 'string') {
123
- textContent = m.message.content;
124
- } else if (Array.isArray(m.message.content)) {
125
- const textBlocks = m.message.content.filter(c => c.type === 'text');
126
- textContent = textBlocks.map(b => 'text' in b ? b.text : '').join(' ');
127
- }
128
- return textContent.trim().length > 0;
129
- });
130
-
131
- const totalBubbles = userMessages.length + assistantMessages.length; // Total message bubbles in chat
132
-
133
- const data = {
134
- messages,
135
- title,
136
- summary,
137
- count: totalBubbles, // Total bubbles (user + assistant with non-empty content)
138
- userCount: userMessages.length, // Number of chat sessions/exchanges (non-empty user messages)
139
- assistantCount: assistantMessages.length
140
- };
141
-
142
- sessionDataCache[sessionId] = data;
143
- debug.log('session', `Loaded session ${sessionId}:`, {
144
- title,
145
- totalMessages: messages.length,
146
- userCount: userMessages.length,
147
- assistantCount: assistantMessages.length,
148
- totalBubbles: totalBubbles,
149
- summary: summary.substring(0, 50)
150
- });
151
- return data;
152
91
  } catch (error) {
153
92
  debug.error('session', 'Error fetching session data:', error);
154
- return {
155
- messages: [],
156
- title: 'New Conversation',
157
- summary: 'No messages yet',
158
- count: 0,
159
- userCount: 0,
160
- assistantCount: 0
161
- };
162
93
  }
94
+
95
+ return { title: 'New Conversation', summary: 'No messages yet', count: 0, userCount: 0, assistantCount: 0 };
163
96
  }
164
97
 
165
98
  // Helper functions that use cached data
@@ -179,14 +112,23 @@
179
112
  return sessionDataCache[sessionId]?.summary || 'No messages yet';
180
113
  }
181
114
 
182
- // Preload session data for visible sessions
115
+ // Preload session data for visible sessions using a single bulk request
183
116
  async function preloadSessionData() {
184
117
  loadingSessionData = true;
185
118
  try {
186
- // Load all sessions in parallel for better performance
187
- await Promise.all(
188
- sessions.slice(0, 20).map(session => getSessionData(session.id))
189
- );
119
+ const sessionIds = sessions.slice(0, 20).map(s => s.id);
120
+ if (sessionIds.length === 0) return;
121
+
122
+ const previews = await ws.http('sessions:preview', { session_ids: sessionIds });
123
+ for (const preview of previews) {
124
+ sessionDataCache[preview.session_id] = {
125
+ title: preview.title,
126
+ summary: preview.summary,
127
+ count: preview.count,
128
+ userCount: preview.userCount,
129
+ assistantCount: preview.assistantCount
130
+ };
131
+ }
190
132
  } catch (error) {
191
133
  debug.error('session', 'Error preloading session data:', error);
192
134
  } finally {
@@ -199,14 +141,25 @@
199
141
  preloadSessionData();
200
142
  });
201
143
 
202
- // Reload session data when sessions change
144
+ // Reload session data when new sessions arrive
203
145
  $effect(() => {
204
146
  if (sessions.length > 0 && !loadingSessionData) {
205
- // Check if there are sessions without cached data
206
- const uncachedSessions = sessions.filter(s => !sessionDataCache[s.id]);
207
- if (uncachedSessions.length > 0) {
208
- debug.log('session', `Found ${uncachedSessions.length} uncached sessions, loading...`);
209
- preloadSessionData();
147
+ const uncachedIds = sessions.filter(s => !sessionDataCache[s.id]).map(s => s.id);
148
+ if (uncachedIds.length > 0) {
149
+ debug.log('session', `Found ${uncachedIds.length} uncached sessions, loading...`);
150
+ ws.http('sessions:preview', { session_ids: uncachedIds }).then((previews: any[]) => {
151
+ for (const preview of previews) {
152
+ sessionDataCache[preview.session_id] = {
153
+ title: preview.title,
154
+ summary: preview.summary,
155
+ count: preview.count,
156
+ userCount: preview.userCount,
157
+ assistantCount: preview.assistantCount
158
+ };
159
+ }
160
+ }).catch((err: unknown) => {
161
+ debug.error('session', 'Error loading uncached sessions:', err);
162
+ });
210
163
  }
211
164
  }
212
165
  });
@@ -219,7 +219,7 @@
219
219
  <!-- Content Area -->
220
220
  <main class="flex-1 flex flex-col min-w-0 overflow-hidden">
221
221
  <div class="flex-1 overflow-y-auto p-4 md:p-5">
222
- {#if activeSection === 'model'}
222
+ {#if activeSection === 'models'}
223
223
  <div in:fly={{ x: 20, duration: 200 }}>
224
224
  <ModelSettings />
225
225
  </div>
@@ -23,7 +23,7 @@
23
23
  <div class="flex-1 overflow-auto">
24
24
  <div class="space-y-6">
25
25
 
26
- <!-- Model Configuration -->
26
+ <!-- AI Model (Assistant + Commit Message) -->
27
27
  <ModelSettings />
28
28
 
29
29
  <!-- Appearance Configuration -->
@@ -3,8 +3,8 @@
3
3
  import { settings, updateSettings, applyFontSize } from '$frontend/stores/features/settings.svelte';
4
4
  import Icon from '../../common/display/Icon.svelte';
5
5
 
6
- const FONT_SIZE_MIN = 10;
7
- const FONT_SIZE_MAX = 20;
6
+ const FONT_SIZE_MIN = 8;
7
+ const FONT_SIZE_MAX = 24;
8
8
 
9
9
  function handleFontSizeChange(e: Event) {
10
10
  const value = Number((e.target as HTMLInputElement).value);
@@ -383,9 +383,9 @@
383
383
 
384
384
  <div class="space-y-6">
385
385
  <!-- Header -->
386
- <h3 class="text-base font-bold text-slate-900 dark:text-slate-100 mb-1.5">AI Engine</h3>
386
+ <h3 class="text-base font-bold text-slate-900 dark:text-slate-100 mb-1.5">Engines</h3>
387
387
  <p class="text-sm text-slate-600 dark:text-slate-500 mb-4">
388
- Manage AI engine installations and accounts
388
+ Manage engine installations and accounts
389
389
  </p>
390
390
 
391
391
  <!-- Claude Code Card -->
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { systemSettings, updateSystemSettings } from '$frontend/stores/features/settings.svelte';
3
- import { updateState, checkForUpdate, runUpdate } from '$frontend/stores/ui/update.svelte';
3
+ import { updateState, checkForUpdate, runUpdate, showRestartModal } from '$frontend/stores/ui/update.svelte';
4
4
  import Icon from '../../common/display/Icon.svelte';
5
5
 
6
6
  function toggleAutoUpdate() {
@@ -88,10 +88,17 @@
88
88
  </div>
89
89
  {/if}
90
90
 
91
- {#if updateState.updateSuccess}
91
+ {#if updateState.updateSuccess || updateState.pendingRestart}
92
92
  <div class="mt-3 flex items-center gap-2 px-3 py-2 bg-emerald-500/10 border border-emerald-500/20 rounded-lg">
93
93
  <Icon name="lucide:circle-check" class="w-4 h-4 text-emerald-500 shrink-0" />
94
- <span class="text-xs text-emerald-600 dark:text-emerald-400">Updated successfully — restart clopen to apply</span>
94
+ <span class="text-xs text-emerald-600 dark:text-emerald-400">Updated to v{updateState.latestVersion} — restart required</span>
95
+ <button
96
+ type="button"
97
+ onclick={() => showRestartModal()}
98
+ class="text-xs font-semibold text-emerald-600 dark:text-emerald-400 underline underline-offset-2 hover:text-emerald-700 dark:hover:text-emerald-300 transition-colors"
99
+ >
100
+ How to restart
101
+ </button>
95
102
  </div>
96
103
  {/if}
97
104
  </div>
@@ -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>