groove-dev 0.27.70 → 0.27.71

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 (41) hide show
  1. package/CLAUDE.md +0 -7
  2. package/MOE_TRAINING_PIPELINE.md +720 -0
  3. package/node_modules/@groove-dev/cli/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/package.json +1 -1
  5. package/node_modules/@groove-dev/daemon/src/api.js +272 -2
  6. package/node_modules/@groove-dev/daemon/src/index.js +3 -0
  7. package/node_modules/@groove-dev/daemon/src/providers/base.js +8 -0
  8. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +52 -0
  9. package/node_modules/@groove-dev/daemon/src/providers/codex.js +15 -0
  10. package/node_modules/@groove-dev/daemon/src/providers/gemini.js +14 -0
  11. package/node_modules/@groove-dev/daemon/src/providers/index.js +36 -0
  12. package/node_modules/@groove-dev/gui/dist/assets/index-74E3YTkT.css +1 -0
  13. package/node_modules/@groove-dev/gui/dist/assets/{index-D5BpdcWS.js → index-BK6tvmxx.js} +1736 -1735
  14. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  15. package/node_modules/@groove-dev/gui/package.json +1 -1
  16. package/node_modules/@groove-dev/gui/src/components/editor/code-editor.jsx +5 -5
  17. package/node_modules/@groove-dev/gui/src/components/editor/editor-tabs.jsx +4 -4
  18. package/node_modules/@groove-dev/gui/src/components/settings/ProviderSetupWizard.jsx +480 -0
  19. package/node_modules/@groove-dev/gui/src/stores/groove.js +107 -2
  20. package/node_modules/@groove-dev/gui/src/views/settings.jsx +258 -84
  21. package/package.json +1 -1
  22. package/packages/cli/package.json +1 -1
  23. package/packages/daemon/package.json +1 -1
  24. package/packages/daemon/src/api.js +272 -2
  25. package/packages/daemon/src/index.js +3 -0
  26. package/packages/daemon/src/providers/base.js +8 -0
  27. package/packages/daemon/src/providers/claude-code.js +52 -0
  28. package/packages/daemon/src/providers/codex.js +15 -0
  29. package/packages/daemon/src/providers/gemini.js +14 -0
  30. package/packages/daemon/src/providers/index.js +36 -0
  31. package/packages/gui/dist/assets/index-74E3YTkT.css +1 -0
  32. package/packages/gui/dist/assets/{index-D5BpdcWS.js → index-BK6tvmxx.js} +1736 -1735
  33. package/packages/gui/dist/index.html +2 -2
  34. package/packages/gui/package.json +1 -1
  35. package/packages/gui/src/components/editor/code-editor.jsx +5 -5
  36. package/packages/gui/src/components/editor/editor-tabs.jsx +4 -4
  37. package/packages/gui/src/components/settings/ProviderSetupWizard.jsx +480 -0
  38. package/packages/gui/src/stores/groove.js +107 -2
  39. package/packages/gui/src/views/settings.jsx +258 -84
  40. package/node_modules/@groove-dev/gui/dist/assets/index-oQ0ejlfH.css +0 -1
  41. package/packages/gui/dist/assets/index-oQ0ejlfH.css +0 -1
@@ -43,6 +43,9 @@ export const useGrooveStore = create((set, get) => ({
43
43
  // ── Gateways ──────────────────────────────────────────────
44
44
  gateways: [],
45
45
 
46
+ // ── Providers ────────────────────────────────────────────
47
+ _providerRefreshTick: 0,
48
+
46
49
  // ── Federation ────────────────────────────────────────────
47
50
  federation: {
48
51
  peers: [],
@@ -585,6 +588,10 @@ export const useGrooveStore = create((set, get) => ({
585
588
  set({ gateways: msg.data || [] });
586
589
  break;
587
590
 
591
+ case 'provider:status-changed':
592
+ set({ _providerRefreshTick: Date.now() });
593
+ break;
594
+
588
595
  case 'federation:whitelist':
589
596
  set((s) => ({ federation: { ...s.federation, whitelist: msg.data || [] } }));
590
597
  break;
@@ -1640,17 +1647,115 @@ export const useGrooveStore = create((set, get) => ({
1640
1647
  api.post('/onboarding/dismiss').catch(() => {});
1641
1648
  },
1642
1649
 
1650
+ // ── Provider Setup (Settings) ──────────────────────────────
1651
+
1652
+ providerInstallProgress: {},
1653
+
1643
1654
  async installProvider(providerId) {
1655
+ set((s) => ({
1656
+ providerInstallProgress: {
1657
+ ...s.providerInstallProgress,
1658
+ [providerId]: { installing: true, percent: 0, message: 'Starting install...', error: null, done: false },
1659
+ },
1660
+ }));
1644
1661
  try {
1645
- const data = await api.post('/onboarding/install-provider', { provider: providerId });
1662
+ const res = await fetch(`/api/providers/${encodeURIComponent(providerId)}/install`, {
1663
+ method: 'POST',
1664
+ headers: { 'Content-Type': 'application/json' },
1665
+ });
1666
+ if (!res.ok) {
1667
+ const err = await res.text();
1668
+ throw new Error(err || `Install failed (${res.status})`);
1669
+ }
1670
+ const ct = res.headers.get('content-type') || '';
1671
+ if (ct.includes('ndjson') || ct.includes('octet-stream') || ct.includes('chunked')) {
1672
+ const reader = res.body.getReader();
1673
+ const decoder = new TextDecoder();
1674
+ let buf = '';
1675
+ let lastError = null;
1676
+ while (true) {
1677
+ const { done, value } = await reader.read();
1678
+ if (done) break;
1679
+ buf += decoder.decode(value, { stream: true });
1680
+ const lines = buf.split('\n');
1681
+ buf = lines.pop();
1682
+ for (const line of lines) {
1683
+ if (!line.trim()) continue;
1684
+ try {
1685
+ const ev = JSON.parse(line);
1686
+ const isError = ev.status === 'error';
1687
+ const isDone = ev.status === 'complete';
1688
+ if (isError) lastError = ev.output || 'Install failed';
1689
+ set((s) => ({
1690
+ providerInstallProgress: {
1691
+ ...s.providerInstallProgress,
1692
+ [providerId]: {
1693
+ ...s.providerInstallProgress[providerId],
1694
+ percent: ev.progress ?? s.providerInstallProgress[providerId]?.percent ?? 0,
1695
+ message: ev.output || s.providerInstallProgress[providerId]?.message,
1696
+ error: isError ? (ev.output || 'Install failed') : null,
1697
+ done: isDone,
1698
+ installing: !isDone && !isError,
1699
+ },
1700
+ },
1701
+ }));
1702
+ } catch { /* skip malformed NDJSON line */ }
1703
+ }
1704
+ }
1705
+ if (lastError) throw new Error(lastError);
1706
+ }
1707
+ const progress = get().providerInstallProgress[providerId];
1708
+ if (progress?.error) throw new Error(progress.error);
1709
+ set((s) => ({
1710
+ providerInstallProgress: {
1711
+ ...s.providerInstallProgress,
1712
+ [providerId]: { installing: false, percent: 100, message: 'Installed', error: null, done: true },
1713
+ },
1714
+ }));
1646
1715
  get().addToast('success', `${providerId} installed`);
1647
- return data;
1648
1716
  } catch (err) {
1717
+ set((s) => ({
1718
+ providerInstallProgress: {
1719
+ ...s.providerInstallProgress,
1720
+ [providerId]: { installing: false, percent: 0, message: null, error: err.message, done: false },
1721
+ },
1722
+ }));
1649
1723
  get().addToast('error', `Install failed: ${providerId}`, err.message);
1650
1724
  throw err;
1651
1725
  }
1652
1726
  },
1653
1727
 
1728
+ async loginProvider(providerId, body) {
1729
+ try {
1730
+ const data = await api.post(`/providers/${encodeURIComponent(providerId)}/login`, body);
1731
+ if (data?.url) window.open(data.url, '_blank');
1732
+ return data;
1733
+ } catch (err) {
1734
+ get().addToast('error', `Login failed`, err.message);
1735
+ throw err;
1736
+ }
1737
+ },
1738
+
1739
+ async setProviderPath(providerId, path) {
1740
+ try {
1741
+ await api.post(`/providers/${encodeURIComponent(providerId)}/set-path`, { path });
1742
+ get().addToast('success', `Custom path set for ${providerId}`);
1743
+ } catch (err) {
1744
+ get().addToast('error', 'Failed to set path', err.message);
1745
+ throw err;
1746
+ }
1747
+ },
1748
+
1749
+ async verifyProvider(providerId) {
1750
+ try {
1751
+ const data = await api.post(`/providers/${encodeURIComponent(providerId)}/verify`);
1752
+ return data;
1753
+ } catch (err) {
1754
+ get().addToast('error', `Verification failed`, err.message);
1755
+ throw err;
1756
+ }
1757
+ },
1758
+
1654
1759
  async setDefaultProvider(provider, model) {
1655
1760
  try {
1656
1761
  await api.post('/onboarding/set-default', { provider, model });
@@ -8,13 +8,14 @@ import { Skeleton } from '../components/ui/skeleton';
8
8
  import { StatusDot } from '../components/ui/status-dot';
9
9
  import { OllamaSetup } from '../components/agents/ollama-setup';
10
10
  import { FolderBrowser } from '../components/agents/folder-browser';
11
+ import { ProviderSetupWizard } from '../components/settings/ProviderSetupWizard';
11
12
  import { Sheet, SheetContent } from '../components/ui/sheet';
12
13
  import { api } from '../lib/api';
13
14
  import { cn } from '../lib/cn';
14
15
  import { fmtUptime } from '../lib/format';
15
16
  import {
16
- Key, Eye, EyeOff, Check, Cpu,
17
- FolderOpen, FolderSearch, Users, Gauge,
17
+ Key, Eye, EyeOff, Check, Cpu, Download, Loader2,
18
+ FolderOpen, FolderSearch, Users, Gauge, ChevronRight,
18
19
  ShieldCheck, Settings, Lock,
19
20
  Newspaper, Radio, Send, MessageSquare, MessageCircle,
20
21
  Plus, Trash2, Plug, PlugZap, TestTube, X, HelpCircle, ExternalLink,
@@ -41,12 +42,26 @@ function Toggle({ value, onChange }) {
41
42
 
42
43
  /* ── Provider Card ─────────────────────────────────────────── */
43
44
 
45
+ const KEY_PLACEHOLDERS = {
46
+ 'claude-code': 'sk-ant-...',
47
+ codex: 'Paste from platform.openai.com',
48
+ gemini: 'Paste from aistudio.google.com',
49
+ };
50
+
44
51
  function ProviderCard({ provider, onKeyChange }) {
45
52
  const [settingKey, setSettingKey] = useState(false);
46
53
  const [keyInput, setKeyInput] = useState('');
47
54
  const [showKey, setShowKey] = useState(false);
48
55
  const [ollamaOpen, setOllamaOpen] = useState(false);
56
+ const [wizardOpen, setWizardOpen] = useState(false);
57
+ const [wizardStep, setWizardStep] = useState(0);
58
+ const [customPathOpen, setCustomPathOpen] = useState(false);
59
+ const [customPath, setCustomPath] = useState('');
60
+ const [savingPath, setSavingPath] = useState(false);
49
61
  const addToast = useGrooveStore((s) => s.addToast);
62
+ const installProgress = useGrooveStore((s) => s.providerInstallProgress[provider.id]);
63
+ const loginProvider = useGrooveStore((s) => s.loginProvider);
64
+ const setProviderPath = useGrooveStore((s) => s.setProviderPath);
50
65
 
51
66
  const isLocal = provider.authType === 'local';
52
67
  const isSubscription = provider.authType === 'subscription';
@@ -77,6 +92,28 @@ function ProviderCard({ provider, onKeyChange }) {
77
92
  }
78
93
  }
79
94
 
95
+ async function handleLogin(body) {
96
+ try {
97
+ await loginProvider(provider.id, body);
98
+ } catch { /* handled in store */ }
99
+ }
100
+
101
+ async function handleSavePath() {
102
+ if (!customPath.trim()) return;
103
+ setSavingPath(true);
104
+ try {
105
+ await setProviderPath(provider.id, customPath.trim());
106
+ setCustomPathOpen(false);
107
+ if (onKeyChange) onKeyChange();
108
+ } catch { /* handled in store */ }
109
+ setSavingPath(false);
110
+ }
111
+
112
+ function openWizard(step = 0) {
113
+ setWizardStep(step);
114
+ setWizardOpen(true);
115
+ }
116
+
80
117
  // Local models card
81
118
  if (isLocal) {
82
119
  const installedCount = provider.models?.filter(m => !m.disabled)?.length || 0;
@@ -138,102 +175,234 @@ function ProviderCard({ provider, onKeyChange }) {
138
175
  );
139
176
  }
140
177
 
178
+ const isInstalling = installProgress?.installing;
179
+
141
180
  // Standard provider card (Claude, Codex, Gemini)
142
181
  return (
143
- <div className="flex flex-col rounded-lg border border-border-subtle bg-surface-1 overflow-hidden min-w-[220px]">
144
- {/* Header */}
145
- <div className="flex items-center gap-2.5 px-4 py-3 border-b border-border-subtle">
146
- <StatusDot status={isReady ? 'running' : 'crashed'} size="sm" />
147
- <span className="text-[13px] font-semibold text-text-0 font-sans">{provider.name}</span>
148
- <div className="flex-1" />
149
- {isReady ? (
150
- <Badge variant="success" className="text-2xs gap-1"><Check size={8} /> Ready</Badge>
151
- ) : (
152
- <Badge variant="default" className="text-2xs">{!provider.installed ? 'Not installed' : isSubscription ? 'Not authenticated' : 'No key'}</Badge>
153
- )}
154
- </div>
182
+ <>
183
+ <div className="flex flex-col rounded-lg border border-border-subtle bg-surface-1 overflow-hidden min-w-[220px]">
184
+ {/* Header */}
185
+ <div className="flex items-center gap-2.5 px-4 py-3 border-b border-border-subtle">
186
+ <StatusDot status={isReady ? 'running' : isInstalling ? 'idle' : 'crashed'} size="sm" />
187
+ <span className="text-[13px] font-semibold text-text-0 font-sans">{provider.name}</span>
188
+ <div className="flex-1" />
189
+ {isReady ? (
190
+ <Badge variant="success" className="text-2xs gap-1"><Check size={8} /> Ready</Badge>
191
+ ) : isInstalling ? (
192
+ <Badge variant="default" className="text-2xs gap-1"><Loader2 size={8} className="animate-spin" /> Installing</Badge>
193
+ ) : (
194
+ <Badge variant="default" className="text-2xs">{!provider.installed ? 'Not installed' : isSubscription ? 'Not signed in' : 'No key'}</Badge>
195
+ )}
196
+ </div>
155
197
 
156
- {/* Body */}
157
- <div className="flex-1 flex flex-col px-4 py-3 min-h-[120px]">
158
- {/* Models */}
159
- {provider.models?.length > 0 && (
160
- <div className="flex flex-wrap gap-1 mb-3">
161
- {provider.models.map((m) => (
162
- <span key={m.id} className="px-1.5 py-0.5 rounded bg-surface-4 text-2xs font-mono text-text-3">
163
- {m.name || m.id}
164
- </span>
165
- ))}
166
- </div>
167
- )}
198
+ {/* Body */}
199
+ <div className="flex-1 flex flex-col px-4 py-3 min-h-[120px]">
200
+ {/* Models */}
201
+ {provider.models?.length > 0 && (
202
+ <div className="flex flex-wrap gap-1 mb-3">
203
+ {provider.models.map((m) => (
204
+ <span key={m.id} className="px-1.5 py-0.5 rounded bg-surface-4 text-2xs font-mono text-text-3">
205
+ {m.name || m.id}
206
+ </span>
207
+ ))}
208
+ </div>
209
+ )}
168
210
 
169
- {/* Subscription info for Claude */}
170
- {isSubscription && isReady && !provider.hasKey && !settingKey && (
171
- <div className="flex items-center gap-1.5 h-8 px-2.5 bg-accent/8 border border-accent/20 rounded-md text-2xs font-sans text-accent mb-3">
172
- <Check size={10} /> Subscription active
173
- </div>
174
- )}
211
+ {/* Installing progress bar */}
212
+ {isInstalling && (
213
+ <div className="space-y-2 mb-3">
214
+ <div className="h-1.5 bg-surface-4 rounded-full overflow-hidden">
215
+ <div
216
+ className="h-full bg-gradient-to-r from-accent to-accent/60 rounded-full transition-all duration-500 ease-out"
217
+ style={{ width: `${Math.max(installProgress?.percent || 0, 5)}%` }}
218
+ />
219
+ </div>
220
+ <p className="text-2xs text-text-3 font-sans">{installProgress?.message || 'Installing...'}</p>
221
+ </div>
222
+ )}
175
223
 
176
- {/* Connected state */}
177
- {provider.hasKey && !settingKey && (
178
- <div className="flex items-center gap-2 mb-3">
179
- <div className="flex-1 flex items-center gap-1.5 h-8 px-2.5 bg-success/8 border border-success/20 rounded-md text-2xs font-sans text-success">
180
- <Check size={10} /> API Connected
224
+ {/* Not installed — prominent Install button */}
225
+ {!provider.installed && !isInstalling && !settingKey && (
226
+ <div className="flex flex-col items-center justify-center flex-1 gap-3 py-2">
227
+ <p className="text-xs text-text-3 font-sans text-center">
228
+ {provider.name} is not installed on this machine.
229
+ </p>
230
+ <Button
231
+ variant="primary"
232
+ size="sm"
233
+ onClick={() => openWizard(0)}
234
+ className="h-8 px-4 text-xs gap-1.5"
235
+ >
236
+ <Download size={12} /> Install {provider.name}
237
+ </Button>
181
238
  </div>
182
- <button onClick={() => { setSettingKey(true); setShowKey(false); setKeyInput(''); }} className="text-2xs text-text-4 hover:text-accent cursor-pointer font-sans">Edit</button>
183
- <button onClick={handleDeleteKey} className="text-2xs text-text-4 hover:text-danger cursor-pointer font-sans">Remove</button>
184
- </div>
185
- )}
239
+ )}
186
240
 
187
- {/* Spacer */}
188
- <div className="flex-1" />
241
+ {/* Installed but needs auth */}
242
+ {provider.installed && !isReady && !settingKey && !isInstalling && (
243
+ <div className="flex flex-col gap-2.5 flex-1">
244
+ {isSubscription ? (
245
+ <>
246
+ <Button
247
+ variant="primary"
248
+ size="sm"
249
+ onClick={handleLogin}
250
+ className="w-full h-8 text-2xs gap-1.5"
251
+ >
252
+ <ExternalLink size={11} /> Sign In with Anthropic
253
+ </Button>
254
+ <Button
255
+ variant="secondary"
256
+ size="sm"
257
+ onClick={() => { setSettingKey(true); setShowKey(false); setKeyInput(''); }}
258
+ className="w-full h-8 text-2xs gap-1.5"
259
+ >
260
+ <Key size={11} /> Add API Key Instead
261
+ </Button>
262
+ </>
263
+ ) : provider.id === 'codex' ? (
264
+ <>
265
+ <Button
266
+ variant="primary"
267
+ size="sm"
268
+ onClick={() => { setSettingKey(true); setShowKey(false); setKeyInput(''); }}
269
+ className="w-full h-8 text-2xs gap-1.5"
270
+ >
271
+ <Key size={11} /> Add API Key
272
+ </Button>
273
+ <Button
274
+ variant="secondary"
275
+ size="sm"
276
+ onClick={() => handleLogin({ method: 'chatgpt-plus' })}
277
+ className="w-full h-8 text-2xs gap-1.5"
278
+ >
279
+ <ExternalLink size={11} /> Sign in with ChatGPT Plus
280
+ </Button>
281
+ </>
282
+ ) : (
283
+ <Button
284
+ variant="primary"
285
+ size="sm"
286
+ onClick={() => { setSettingKey(true); setShowKey(false); setKeyInput(''); }}
287
+ className="w-full h-8 text-2xs gap-1.5"
288
+ >
289
+ <Key size={11} /> Add API Key
290
+ </Button>
291
+ )}
292
+ </div>
293
+ )}
189
294
 
190
- {/* Key input form takes over the bottom area */}
191
- {settingKey && (
192
- <div className="space-y-2.5 pt-1">
193
- <div>
194
- <label className="text-2xs font-semibold text-text-2 font-sans mb-1.5 block">
195
- {provider.hasKey ? 'Update API Key' : `${provider.name} API Key`}
196
- </label>
197
- <div className="relative">
198
- <input
199
- value={keyInput}
200
- onChange={(e) => setKeyInput(e.target.value)}
201
- onKeyDown={(e) => e.key === 'Enter' && handleSetKey()}
202
- type={showKey ? 'text' : 'password'}
203
- placeholder="sk-..."
204
- className="w-full h-9 px-3 pr-9 text-xs bg-surface-0 border border-border rounded-md text-text-0 font-mono placeholder:text-text-4 focus:outline-none focus:ring-1 focus:ring-accent"
205
- autoFocus
206
- />
207
- <button onClick={() => setShowKey(!showKey)} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-text-4 hover:text-text-2 cursor-pointer">
208
- {showKey ? <EyeOff size={12} /> : <Eye size={12} />}
209
- </button>
295
+ {/* Subscription info for Claude */}
296
+ {isSubscription && isReady && !provider.hasKey && !settingKey && (
297
+ <div className="flex items-center gap-1.5 h-8 px-2.5 bg-accent/8 border border-accent/20 rounded-md text-2xs font-sans text-accent mb-3">
298
+ <Check size={10} /> Subscription active
299
+ </div>
300
+ )}
301
+
302
+ {/* Connected state */}
303
+ {provider.hasKey && !settingKey && (
304
+ <div className="flex items-center gap-2 mb-3">
305
+ <div className="flex-1 flex items-center gap-1.5 h-8 px-2.5 bg-success/8 border border-success/20 rounded-md text-2xs font-sans text-success">
306
+ <Check size={10} /> API Connected
210
307
  </div>
308
+ <button onClick={() => { setSettingKey(true); setShowKey(false); setKeyInput(''); }} className="text-2xs text-text-4 hover:text-accent cursor-pointer font-sans">Edit</button>
309
+ <button onClick={handleDeleteKey} className="text-2xs text-text-4 hover:text-danger cursor-pointer font-sans">Remove</button>
211
310
  </div>
212
- <div className="flex gap-2">
213
- <Button variant="primary" size="sm" onClick={handleSetKey} disabled={!keyInput.trim()} className="flex-1 h-8 text-xs">
214
- Save Key
215
- </Button>
216
- <Button variant="ghost" size="sm" onClick={() => { setSettingKey(false); setKeyInput(''); }} className="h-8 text-xs px-3">
217
- Cancel
218
- </Button>
311
+ )}
312
+
313
+ {/* Spacer */}
314
+ <div className="flex-1" />
315
+
316
+ {/* Key input form */}
317
+ {settingKey && (
318
+ <div className="space-y-2.5 pt-1">
319
+ <div>
320
+ <label className="text-2xs font-semibold text-text-2 font-sans mb-1.5 block">
321
+ {provider.hasKey ? 'Update API Key' : `${provider.name} API Key`}
322
+ </label>
323
+ <div className="relative">
324
+ <input
325
+ value={keyInput}
326
+ onChange={(e) => setKeyInput(e.target.value)}
327
+ onKeyDown={(e) => e.key === 'Enter' && handleSetKey()}
328
+ type={showKey ? 'text' : 'password'}
329
+ placeholder={KEY_PLACEHOLDERS[provider.id] || 'sk-...'}
330
+ className="w-full h-9 px-3 pr-9 text-xs bg-surface-0 border border-border rounded-md text-text-0 font-mono placeholder:text-text-4 focus:outline-none focus:ring-1 focus:ring-accent"
331
+ autoFocus
332
+ />
333
+ <button onClick={() => setShowKey(!showKey)} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-text-4 hover:text-text-2 cursor-pointer">
334
+ {showKey ? <EyeOff size={12} /> : <Eye size={12} />}
335
+ </button>
336
+ </div>
337
+ </div>
338
+ <div className="flex gap-2">
339
+ <Button variant="primary" size="sm" onClick={handleSetKey} disabled={!keyInput.trim()} className="flex-1 h-8 text-xs">
340
+ Save Key
341
+ </Button>
342
+ <Button variant="ghost" size="sm" onClick={() => { setSettingKey(false); setKeyInput(''); }} className="h-8 text-xs px-3">
343
+ Cancel
344
+ </Button>
345
+ </div>
219
346
  </div>
220
- </div>
221
- )}
347
+ )}
222
348
 
223
- {/* Bottom action — always at card bottom */}
224
- {!settingKey && !provider.hasKey && (
225
- <Button
226
- variant={isSubscription ? 'secondary' : 'primary'}
227
- size="sm"
228
- onClick={() => { setSettingKey(true); setShowKey(false); setKeyInput(''); }}
229
- className="w-full h-8 text-2xs gap-1.5 mt-2"
230
- >
231
- <Key size={11} />
232
- {isSubscription ? 'Add API key for headless mode' : 'Add API Key'}
233
- </Button>
349
+ {/* Bottom action for ready cards add key for headless */}
350
+ {isReady && !settingKey && !provider.hasKey && isSubscription && (
351
+ <Button
352
+ variant="secondary"
353
+ size="sm"
354
+ onClick={() => { setSettingKey(true); setShowKey(false); setKeyInput(''); }}
355
+ className="w-full h-8 text-2xs gap-1.5 mt-2"
356
+ >
357
+ <Key size={11} />
358
+ Add API key for headless mode
359
+ </Button>
360
+ )}
361
+ </div>
362
+
363
+ {/* Custom path section */}
364
+ {provider.installed && (
365
+ <div className="border-t border-border-subtle">
366
+ <button
367
+ onClick={() => setCustomPathOpen(!customPathOpen)}
368
+ className="w-full flex items-center gap-2 px-4 py-2 text-left cursor-pointer hover:bg-surface-5/30 transition-colors"
369
+ >
370
+ <ChevronRight
371
+ size={10}
372
+ className={cn('text-text-4 transition-transform duration-200', customPathOpen && 'rotate-90')}
373
+ />
374
+ <span className="text-2xs text-text-4 font-sans">Set custom path</span>
375
+ </button>
376
+ {customPathOpen && (
377
+ <div className="px-4 pb-3 space-y-2">
378
+ <div className="flex gap-2">
379
+ <input
380
+ value={customPath}
381
+ onChange={(e) => setCustomPath(e.target.value)}
382
+ onKeyDown={(e) => e.key === 'Enter' && handleSavePath()}
383
+ placeholder={`/path/to/${provider.id}`}
384
+ className="flex-1 h-7 px-2 text-2xs bg-surface-0 border border-border-subtle rounded-md text-text-0 font-mono placeholder:text-text-4 focus:outline-none focus:ring-1 focus:ring-accent"
385
+ />
386
+ <Button variant="primary" size="sm" onClick={handleSavePath} disabled={!customPath.trim() || savingPath} className="h-7 text-2xs px-2.5">
387
+ {savingPath ? '...' : 'Save'}
388
+ </Button>
389
+ </div>
390
+ <p className="text-2xs text-text-4 font-sans">For non-standard install locations</p>
391
+ </div>
392
+ )}
393
+ </div>
234
394
  )}
235
395
  </div>
236
- </div>
396
+
397
+ {/* Setup wizard modal */}
398
+ <ProviderSetupWizard
399
+ open={wizardOpen}
400
+ onOpenChange={setWizardOpen}
401
+ providerId={provider.id}
402
+ initialStep={wizardStep}
403
+ onComplete={onKeyChange}
404
+ />
405
+ </>
237
406
  );
238
407
  }
239
408
 
@@ -1036,6 +1205,7 @@ export default function SettingsView() {
1036
1205
  const [folderBrowserOpen, setFolderBrowserOpen] = useState(false);
1037
1206
  const addToast = useGrooveStore((s) => s.addToast);
1038
1207
  const remoteHomedir = useGrooveStore((s) => s.remoteHomedir);
1208
+ const providerRefreshTick = useGrooveStore((s) => s._providerRefreshTick);
1039
1209
 
1040
1210
  function loadProviders() {
1041
1211
  api.get('/providers').then((d) => setProviders(Array.isArray(d) ? d : [])).catch(() => {});
@@ -1051,6 +1221,10 @@ export default function SettingsView() {
1051
1221
  .catch(() => setLoading(false));
1052
1222
  }, []);
1053
1223
 
1224
+ useEffect(() => {
1225
+ if (providerRefreshTick) loadProviders();
1226
+ }, [providerRefreshTick]);
1227
+
1054
1228
  async function addGateway(type) {
1055
1229
  try {
1056
1230
  await api.post('/gateways', { type });