@chrysb/alphaclaw 0.5.1-beta.0 → 0.5.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 (39) hide show
  1. package/bin/alphaclaw.js +3 -1
  2. package/lib/public/css/theme.css +57 -0
  3. package/lib/public/js/app.js +63 -26
  4. package/lib/public/js/components/models-tab/index.js +203 -48
  5. package/lib/public/js/components/models-tab/provider-auth-card.js +20 -2
  6. package/lib/public/js/components/models.js +8 -9
  7. package/lib/public/js/components/onboarding/use-welcome-storage.js +2 -2
  8. package/lib/public/js/components/onboarding/welcome-config.js +1 -1
  9. package/lib/public/js/components/onboarding/welcome-form-step.js +2 -23
  10. package/lib/public/js/components/onboarding/welcome-header.js +19 -13
  11. package/lib/public/js/components/onboarding/welcome-import-step.js +6 -5
  12. package/lib/public/js/components/onboarding/welcome-pre-step.js +81 -0
  13. package/lib/public/js/components/onboarding/welcome-secret-review-step.js +12 -6
  14. package/lib/public/js/components/onboarding/welcome-secret-review-utils.js +19 -0
  15. package/lib/public/js/components/providers.js +9 -13
  16. package/lib/public/js/components/usage-tab/index.js +0 -43
  17. package/lib/public/js/components/usage-tab/use-usage-tab.js +0 -48
  18. package/lib/public/js/components/welcome/index.js +13 -2
  19. package/lib/public/js/components/welcome/use-welcome.js +19 -4
  20. package/lib/public/js/lib/api.js +0 -14
  21. package/lib/public/js/lib/model-config.js +149 -2
  22. package/lib/server/auth-profiles.js +14 -0
  23. package/lib/server/constants.js +23 -4
  24. package/lib/server/db/usage/index.js +0 -4
  25. package/lib/server/gateway.js +18 -2
  26. package/lib/server/onboarding/import/import-applier.js +127 -0
  27. package/lib/server/onboarding/import/import-scanner.js +8 -1
  28. package/lib/server/onboarding/import/secret-detector.js +52 -6
  29. package/lib/server/onboarding/index.js +126 -0
  30. package/lib/server/onboarding/openclaw.js +88 -5
  31. package/lib/server/onboarding/workspace.js +10 -0
  32. package/lib/server/routes/onboarding.js +12 -3
  33. package/lib/server/routes/proxy.js +7 -4
  34. package/lib/server/routes/system.js +14 -0
  35. package/lib/server/routes/usage.js +0 -80
  36. package/lib/server/webhook-middleware.js +5 -2
  37. package/lib/server.js +6 -11
  38. package/package.json +1 -1
  39. package/lib/server/db/usage/backfill.js +0 -416
package/bin/alphaclaw.js CHANGED
@@ -481,6 +481,8 @@ if (!kSetupPassword) {
481
481
 
482
482
  process.env.OPENCLAW_HOME = rootDir;
483
483
  process.env.OPENCLAW_CONFIG_PATH = path.join(openclawDir, "openclaw.json");
484
+ process.env.GOG_KEYRING_PASSWORD =
485
+ process.env.GOG_KEYRING_PASSWORD || "alphaclaw";
484
486
 
485
487
  // ---------------------------------------------------------------------------
486
488
  // 8. Install gog (Google Workspace CLI) if not present
@@ -516,7 +518,7 @@ if (!gogInstalled) {
516
518
  }
517
519
 
518
520
  // ---------------------------------------------------------------------------
519
- // 7. Install/reconcile system cron entry
521
+ // 9. Install/reconcile system cron entry
520
522
  // ---------------------------------------------------------------------------
521
523
 
522
524
  const packagedHourlyGitSyncPath = path.join(setupDir, "hourly-git-sync.sh");
@@ -246,6 +246,45 @@ textarea:focus {
246
246
  background: rgba(99, 235, 255, 0.08);
247
247
  }
248
248
 
249
+ .ac-path-card {
250
+ border: 1px solid var(--panel-border-contrast);
251
+ background: rgba(0, 0, 0, 0.2);
252
+ transition:
253
+ border-color 0.15s ease,
254
+ background 0.15s ease,
255
+ box-shadow 0.15s ease,
256
+ transform 0.15s ease;
257
+ }
258
+
259
+ .ac-path-card:hover {
260
+ border-color: rgba(99, 235, 255, 0.5);
261
+ background: rgba(99, 235, 255, 0.04);
262
+ box-shadow:
263
+ inset 0 0 0 1px rgba(99, 235, 255, 0.1),
264
+ 0 0 12px rgba(99, 235, 255, 0.08);
265
+ }
266
+
267
+ .ac-path-card:hover .ac-path-icon {
268
+ color: var(--accent);
269
+ border-color: rgba(99, 235, 255, 0.3);
270
+ background: rgba(99, 235, 255, 0.1);
271
+ }
272
+
273
+ .ac-path-card:hover .ac-path-title {
274
+ color: #b9f8ff;
275
+ }
276
+
277
+ .ac-path-card:hover .ac-path-desc {
278
+ color: #94a3b8;
279
+ }
280
+
281
+ .ac-path-icon {
282
+ transition:
283
+ color 0.15s ease,
284
+ border-color 0.15s ease,
285
+ background 0.15s ease;
286
+ }
287
+
249
288
  .ac-btn-secondary {
250
289
  border: 1px solid var(--panel-border-contrast);
251
290
  color: #d1d5db;
@@ -426,6 +465,24 @@ textarea:focus {
426
465
  }
427
466
  }
428
467
 
468
+ @keyframes acStepPillPulse {
469
+ 0%,
470
+ 100% {
471
+ box-shadow:
472
+ 0 0 0 0 rgba(99, 235, 255, 0.14),
473
+ 0 0 5px rgba(99, 235, 255, 0.18);
474
+ }
475
+ 50% {
476
+ box-shadow:
477
+ 0 0 0 3px rgba(99, 235, 255, 0.08),
478
+ 0 0 9px rgba(99, 235, 255, 0.32);
479
+ }
480
+ }
481
+
482
+ .ac-step-pill-pulse {
483
+ animation: acStepPillPulse 2.6s ease-in-out infinite;
484
+ }
485
+
429
486
  .ac-status-dot {
430
487
  width: 8px;
431
488
  height: 8px;
@@ -1,7 +1,12 @@
1
1
  import { h, render } from "https://esm.sh/preact";
2
2
  import { useState, useEffect } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
- import { Router, Route, Switch, useLocation } from "https://esm.sh/wouter-preact";
4
+ import {
5
+ Router,
6
+ Route,
7
+ Switch,
8
+ useLocation,
9
+ } from "https://esm.sh/wouter-preact";
5
10
  import { logout } from "./lib/api.js";
6
11
  import { Welcome } from "./components/welcome/index.js";
7
12
  import { ToastContainer } from "./components/toast.js";
@@ -14,7 +19,6 @@ import {
14
19
  EnvarsRoute,
15
20
  GeneralRoute,
16
21
  ModelsRoute,
17
- ProvidersRoute,
18
22
  RouteRedirect,
19
23
  TelegramRoute,
20
24
  UsageRoute,
@@ -24,34 +28,48 @@ import {
24
28
  import { useAppShellController } from "./hooks/use-app-shell-controller.js";
25
29
  import { useAppShellUi } from "./hooks/use-app-shell-ui.js";
26
30
  import { useBrowseNavigation } from "./hooks/use-browse-navigation.js";
27
- import { getHashRouterPath, useHashLocation } from "./hooks/use-hash-location.js";
31
+ import {
32
+ getHashRouterPath,
33
+ useHashLocation,
34
+ } from "./hooks/use-hash-location.js";
28
35
  import { readUiSettings, writeUiSettings } from "./lib/ui-settings.js";
29
36
 
30
37
  const html = htm.bind(h);
31
- const kDoctorWarningDismissedUntilUiSettingKey = "doctorWarningDismissedUntilMs";
38
+ const kDoctorWarningDismissedUntilUiSettingKey =
39
+ "doctorWarningDismissedUntilMs";
32
40
  const kOneWeekMs = 7 * 24 * 60 * 60 * 1000;
33
41
 
34
42
  const App = () => {
35
43
  const [location, setLocation] = useLocation();
36
- const [doctorWarningDismissedUntilMs, setDoctorWarningDismissedUntilMs] = useState(() => {
37
- const settings = readUiSettings();
38
- return Number(settings[kDoctorWarningDismissedUntilUiSettingKey] || 0);
39
- });
44
+ const [doctorWarningDismissedUntilMs, setDoctorWarningDismissedUntilMs] =
45
+ useState(() => {
46
+ const settings = readUiSettings();
47
+ return Number(settings[kDoctorWarningDismissedUntilUiSettingKey] || 0);
48
+ });
40
49
 
41
- const { state: controllerState, actions: controllerActions } = useAppShellController({
42
- location,
43
- });
44
- const { refs: shellRefs, state: shellState, actions: shellActions } = useAppShellUi();
45
- const { state: browseState, actions: browseActions, constants: browseConstants } =
46
- useBrowseNavigation({
50
+ const { state: controllerState, actions: controllerActions } =
51
+ useAppShellController({
47
52
  location,
48
- setLocation,
49
- onCloseMobileSidebar: shellActions.closeMobileSidebar,
50
53
  });
54
+ const {
55
+ refs: shellRefs,
56
+ state: shellState,
57
+ actions: shellActions,
58
+ } = useAppShellUi();
59
+ const {
60
+ state: browseState,
61
+ actions: browseActions,
62
+ constants: browseConstants,
63
+ } = useBrowseNavigation({
64
+ location,
65
+ setLocation,
66
+ onCloseMobileSidebar: shellActions.closeMobileSidebar,
67
+ });
51
68
 
52
69
  useEffect(() => {
53
70
  const settings = readUiSettings();
54
- settings[kDoctorWarningDismissedUntilUiSettingKey] = doctorWarningDismissedUntilMs;
71
+ settings[kDoctorWarningDismissedUntilUiSettingKey] =
72
+ doctorWarningDismissedUntilMs;
55
73
  writeUiSettings(settings);
56
74
  }, [doctorWarningDismissedUntilMs]);
57
75
 
@@ -83,10 +101,13 @@ const App = () => {
83
101
  if (!controllerState.onboarded) {
84
102
  return html`
85
103
  <div
86
- class="min-h-screen flex justify-center pt-12 pb-8 px-4"
104
+ class="min-h-screen flex flex-col items-center pt-12 pb-8 px-4"
87
105
  style="position: relative; z-index: 1"
88
106
  >
89
- <${Welcome} onComplete=${controllerActions.handleOnboardingComplete} />
107
+ <${Welcome}
108
+ onComplete=${controllerActions.handleOnboardingComplete}
109
+ acVersion=${controllerState.acVersion}
110
+ />
90
111
  </div>
91
112
  <${ToastContainer} />
92
113
  `;
@@ -150,7 +171,9 @@ const App = () => {
150
171
  onNavigateToBrowseFile=${browseActions.navigateToBrowseFile}
151
172
  onEditSelectedBrowseFile=${() =>
152
173
  setLocation(
153
- browseActions.buildBrowseRoute(browseState.selectedBrowsePath, { view: "edit" }),
174
+ browseActions.buildBrowseRoute(browseState.selectedBrowsePath, {
175
+ view: "edit",
176
+ }),
154
177
  )}
155
178
  onClearSelection=${() => {
156
179
  browseActions.clearBrowsePreview();
@@ -163,14 +186,22 @@ const App = () => {
163
186
  onscroll=${shellActions.handlePaneScroll}
164
187
  style=${{ display: browseState.isBrowseRoute ? "none" : "block" }}
165
188
  >
166
- <div class=${`mobile-topbar ${shellState.mobileTopbarScrolled ? "is-scrolled" : ""}`}>
189
+ <div
190
+ class=${`mobile-topbar ${shellState.mobileTopbarScrolled ? "is-scrolled" : ""}`}
191
+ >
167
192
  <button
168
193
  class="mobile-topbar-menu"
169
- onclick=${() => shellActions.setMobileSidebarOpen((open) => !open)}
194
+ onclick=${() =>
195
+ shellActions.setMobileSidebarOpen((open) => !open)}
170
196
  aria-label="Open menu"
171
197
  aria-expanded=${shellState.mobileSidebarOpen ? "true" : "false"}
172
198
  >
173
- <svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
199
+ <svg
200
+ width="18"
201
+ height="18"
202
+ viewBox="0 0 16 16"
203
+ fill="currentColor"
204
+ >
174
205
  <path
175
206
  d="M2 3.75a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H2.75A.75.75 0 012 3.75zm0 4.25a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H2.75A.75.75 0 012 8zm0 4.25a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75z"
176
207
  />
@@ -201,7 +232,9 @@ const App = () => {
201
232
  onOpenclawUpdate=${controllerActions.handleOpenclawUpdate}
202
233
  onRestartRequired=${controllerActions.setRestartRequired}
203
234
  onDismissDoctorWarning=${() =>
204
- setDoctorWarningDismissedUntilMs(Date.now() + kOneWeekMs)}
235
+ setDoctorWarningDismissedUntilMs(
236
+ Date.now() + kOneWeekMs,
237
+ )}
205
238
  />
206
239
  </${Route}>
207
240
  <${Route} path="/doctor">
@@ -232,7 +265,9 @@ const App = () => {
232
265
  <${Route} path="/usage/:sessionId">
233
266
  ${(params) => html`
234
267
  <${UsageRoute}
235
- sessionId=${decodeURIComponent(params.sessionId || "")}
268
+ sessionId=${decodeURIComponent(
269
+ params.sessionId || "",
270
+ )}
236
271
  onSetLocation=${setLocation}
237
272
  />
238
273
  `}
@@ -280,7 +315,9 @@ const App = () => {
280
315
  <div class="app-statusbar">
281
316
  <div class="statusbar-left">
282
317
  ${controllerState.acVersion
283
- ? html`<span style="color: var(--text-muted)">v${controllerState.acVersion}</span>`
318
+ ? html`<span style="color: var(--text-muted)"
319
+ >v${controllerState.acVersion}</span
320
+ >`
284
321
  : null}
285
322
  </div>
286
323
  <div class="statusbar-right">
@@ -1,5 +1,5 @@
1
1
  import { h } from "https://esm.sh/preact";
2
- import { useState, useMemo } from "https://esm.sh/preact/hooks";
2
+ import { useState, useMemo, useRef, useEffect } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
4
  import { PageHeader } from "../page-header.js";
5
5
  import { LoadingSpinner } from "../loading-spinner.js";
@@ -7,14 +7,26 @@ import { ActionButton } from "../action-button.js";
7
7
  import { Badge } from "../badge.js";
8
8
  import { useModels } from "./use-models.js";
9
9
  import { ProviderAuthCard } from "./provider-auth-card.js";
10
- import { getModelProvider, getFeaturedModels } from "../../lib/model-config.js";
10
+ import {
11
+ getModelProvider,
12
+ getAuthProviderFromModelProvider,
13
+ getFeaturedModels,
14
+ kProviderLabels,
15
+ kProviderOrder,
16
+ } from "../../lib/model-config.js";
11
17
 
12
18
  const html = htm.bind(h);
13
19
 
20
+ const getModelsTabAuthProvider = (modelKey) => {
21
+ const provider = getModelProvider(modelKey);
22
+ if (provider === "openai-codex") return "openai-codex";
23
+ return getAuthProviderFromModelProvider(provider);
24
+ };
25
+
14
26
  const deriveRequiredProviders = (configuredModels) => {
15
27
  const providers = new Set();
16
28
  for (const modelKey of Object.keys(configuredModels)) {
17
- const provider = getModelProvider(modelKey);
29
+ const provider = getModelsTabAuthProvider(modelKey);
18
30
  if (provider) providers.add(provider);
19
31
  }
20
32
  return [...providers];
@@ -24,9 +36,172 @@ const kProviderDisplayOrder = [
24
36
  "anthropic",
25
37
  "openai",
26
38
  "openai-codex",
27
- "google",
39
+ ...kProviderOrder.filter((provider) => !["anthropic", "openai"].includes(provider)),
28
40
  ];
29
41
 
42
+ const getModelCatalogProvider = (model) =>
43
+ String(model?.provider || getModelProvider(model?.key)).trim();
44
+
45
+ const getProviderSortIndex = (provider) => {
46
+ const index = kProviderDisplayOrder.indexOf(provider);
47
+ return index >= 0 ? index : Number.MAX_SAFE_INTEGER;
48
+ };
49
+
50
+ const formatProviderSectionLabel = (provider) =>
51
+ String(kProviderLabels[provider] || provider).toUpperCase();
52
+
53
+ const normalizeSearch = (value) => String(value || "").trim().toLowerCase();
54
+ const buildModelSearchText = (model) =>
55
+ [
56
+ model?.label || "",
57
+ model?.key || "",
58
+ model?.provider || getModelProvider(model?.key),
59
+ ]
60
+ .join(" ")
61
+ .toLowerCase();
62
+
63
+ const SearchableModelPicker = ({
64
+ options = [],
65
+ popularModels = [],
66
+ placeholder = "Add model...",
67
+ onSelect = () => {},
68
+ }) => {
69
+ const [query, setQuery] = useState("");
70
+ const [open, setOpen] = useState(false);
71
+ const rootRef = useRef(null);
72
+ const normalizedQuery = normalizeSearch(query);
73
+ const filteredOptions = useMemo(
74
+ () =>
75
+ normalizedQuery
76
+ ? options.filter((option) =>
77
+ buildModelSearchText(option).includes(normalizedQuery),
78
+ )
79
+ : options,
80
+ [options, normalizedQuery],
81
+ );
82
+ const groupedOptions = useMemo(() => {
83
+ const groups = [];
84
+ const showPopularGroup = !normalizedQuery;
85
+ const visibleOptionKeys = new Set(filteredOptions.map((option) => option.key));
86
+ const visiblePopularModels = popularModels.filter((model) =>
87
+ visibleOptionKeys.has(model.key),
88
+ );
89
+ if (showPopularGroup && visiblePopularModels.length > 0) {
90
+ groups.push({
91
+ provider: "popular",
92
+ label: "POPULAR",
93
+ options: visiblePopularModels,
94
+ });
95
+ }
96
+ for (const option of filteredOptions) {
97
+ const provider = getModelCatalogProvider(option);
98
+ const label = formatProviderSectionLabel(provider);
99
+ const currentGroup = groups[groups.length - 1];
100
+ if (!currentGroup || currentGroup.provider !== provider) {
101
+ groups.push({ provider, label, options: [option] });
102
+ continue;
103
+ }
104
+ currentGroup.options.push(option);
105
+ }
106
+ return groups;
107
+ }, [filteredOptions, popularModels, normalizedQuery]);
108
+
109
+ useEffect(() => {
110
+ const handleOutsidePointer = (event) => {
111
+ if (!rootRef.current?.contains(event.target)) {
112
+ setOpen(false);
113
+ }
114
+ };
115
+ document.addEventListener("mousedown", handleOutsidePointer);
116
+ return () => document.removeEventListener("mousedown", handleOutsidePointer);
117
+ }, []);
118
+
119
+ const handleSelect = (modelKey) => {
120
+ if (!modelKey) return;
121
+ onSelect(modelKey);
122
+ setQuery("");
123
+ setOpen(false);
124
+ };
125
+
126
+ const handleKeyDown = (event) => {
127
+ const firstVisibleOption = groupedOptions[0]?.options?.[0];
128
+ if (event.key === "Escape") {
129
+ setOpen(false);
130
+ return;
131
+ }
132
+ if (event.key === "Enter" && firstVisibleOption?.key) {
133
+ event.preventDefault();
134
+ handleSelect(firstVisibleOption.key);
135
+ }
136
+ };
137
+
138
+ return html`
139
+ <div class="relative" ref=${rootRef}>
140
+ <input
141
+ type="text"
142
+ value=${query}
143
+ placeholder=${placeholder}
144
+ onFocus=${() => setOpen(true)}
145
+ onInput=${(event) => {
146
+ setQuery(event.target.value);
147
+ setOpen(true);
148
+ }}
149
+ onKeyDown=${handleKeyDown}
150
+ class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-gray-500"
151
+ />
152
+ ${open
153
+ ? html`
154
+ <div
155
+ class="absolute left-0 right-0 top-full mt-2 z-20 bg-modal border border-border rounded-xl shadow-2xl overflow-hidden"
156
+ >
157
+ <div class="max-h-80 overflow-y-auto">
158
+ ${filteredOptions.length > 0
159
+ ? groupedOptions.map(
160
+ (group, index) => html`
161
+ <div key=${group.provider}>
162
+ <div
163
+ class=${`sticky top-0 z-10 h-[22px] px-3 text-[12px] font-semibold tracking-wide text-gray-400 bg-[#151922] border-b border-border flex items-center ${
164
+ index > 0 ? "border-t border-border" : ""
165
+ }`}
166
+ >
167
+ ${group.label}
168
+ </div>
169
+ ${group.options.map(
170
+ (model) => html`
171
+ <button
172
+ key=${model.key}
173
+ type="button"
174
+ onMouseDown=${(event) => event.preventDefault()}
175
+ onClick=${() => handleSelect(model.key)}
176
+ class="w-full text-left px-3 py-2 hover:bg-white/5 border-b border-border last:border-b-0"
177
+ >
178
+ <div class="flex flex-col gap-1">
179
+ <div class="text-sm text-gray-200">
180
+ ${model.label || model.key}
181
+ </div>
182
+ <div class="text-xs text-gray-500 font-mono">
183
+ ${model.key}
184
+ </div>
185
+ </div>
186
+ </button>
187
+ `,
188
+ )}
189
+ </div>
190
+ `,
191
+ )
192
+ : html`
193
+ <div class="px-3 py-3 text-xs text-gray-500">
194
+ No models match that search.
195
+ </div>
196
+ `}
197
+ </div>
198
+ </div>
199
+ `
200
+ : null}
201
+ </div>
202
+ `;
203
+ };
204
+
30
205
  export const Models = ({ onRestartRequired = () => {} }) => {
31
206
  const {
32
207
  catalog,
@@ -52,26 +227,28 @@ export const Models = ({ onRestartRequired = () => {} }) => {
52
227
  refreshCodexStatus,
53
228
  } = useModels();
54
229
 
55
- const [showAllModels, setShowAllModels] = useState(false);
56
-
57
230
  const configuredKeys = useMemo(
58
231
  () => new Set(Object.keys(configuredModels)),
59
232
  [configuredModels],
60
233
  );
61
234
 
62
235
  const featuredModels = useMemo(() => getFeaturedModels(catalog), [catalog]);
236
+ const popularPickerModels = useMemo(
237
+ () => featuredModels.filter((model) => !configuredKeys.has(model.key)),
238
+ [featuredModels, configuredKeys],
239
+ );
63
240
 
64
241
  const pickerModels = useMemo(() => {
65
- const base = showAllModels
66
- ? catalog
67
- : featuredModels.length > 0
68
- ? featuredModels
69
- : catalog;
70
- return base.filter((m) => !configuredKeys.has(m.key));
71
- }, [catalog, featuredModels, showAllModels, configuredKeys]);
72
-
73
- const canToggleFullCatalog =
74
- featuredModels.length > 0 && catalog.length > featuredModels.length;
242
+ return [...catalog]
243
+ .filter((model) => !configuredKeys.has(model.key))
244
+ .sort((a, b) => {
245
+ const providerCompare =
246
+ getProviderSortIndex(getModelCatalogProvider(a)) -
247
+ getProviderSortIndex(getModelCatalogProvider(b));
248
+ if (providerCompare !== 0) return providerCompare;
249
+ return String(a.label || a.key).localeCompare(String(b.label || b.key));
250
+ });
251
+ }, [catalog, configuredKeys]);
75
252
 
76
253
  const requiredProviders = useMemo(
77
254
  () => deriveRequiredProviders(configuredModels),
@@ -106,7 +283,7 @@ export const Models = ({ onRestartRequired = () => {} }) => {
106
283
  () =>
107
284
  Object.keys(configuredModels).map((key) => {
108
285
  const catalogEntry = catalog.find((m) => m.key === key);
109
- const provider = getModelProvider(key);
286
+ const provider = getModelsTabAuthProvider(key);
110
287
  const hasAuth = !!providerHasAuth[provider];
111
288
  return {
112
289
  key,
@@ -213,38 +390,16 @@ export const Models = ({ onRestartRequired = () => {} }) => {
213
390
  </div>
214
391
  `}
215
392
 
216
- <div class="pt-2 border-t border-border space-y-2">
217
- <label class="text-xs font-medium text-gray-400">Add model</label>
218
- <select
219
- onInput=${(e) => {
220
- const val = e.target.value;
221
- if (val) {
222
- addModel(val);
223
- if (!primary) setPrimaryModel(val);
224
- }
225
- e.target.value = "";
393
+ <div class="space-y-2">
394
+ <${SearchableModelPicker}
395
+ options=${pickerModels}
396
+ popularModels=${popularPickerModels}
397
+ placeholder="Add model..."
398
+ onSelect=${(modelKey) => {
399
+ addModel(modelKey);
400
+ if (!primary) setPrimaryModel(modelKey);
226
401
  }}
227
- class="w-full bg-black/30 border border-border rounded-lg pl-3 pr-8 py-2 text-sm text-gray-200 outline-none focus:border-gray-500"
228
- >
229
- <option value="">Select a model to add...</option>
230
- ${pickerModels.map(
231
- (m) =>
232
- html`<option value=${m.key}>${m.label || m.key}</option>`,
233
- )}
234
- </select>
235
- ${canToggleFullCatalog
236
- ? html`
237
- <button
238
- type="button"
239
- onclick=${() => setShowAllModels((prev) => !prev)}
240
- class="text-xs text-gray-500 hover:text-gray-300"
241
- >
242
- ${showAllModels
243
- ? "Show recommended models"
244
- : "Show full model catalog"}
245
- </button>
246
- `
247
- : null}
402
+ />
248
403
  </div>
249
404
 
250
405
  ${loading
@@ -6,6 +6,10 @@ import { SecretInput } from "../secret-input.js";
6
6
  import { ActionButton } from "../action-button.js";
7
7
  import { exchangeCodexOAuth, disconnectCodex } from "../../lib/api.js";
8
8
  import { showToast } from "../toast.js";
9
+ import {
10
+ kProviderAuthFields,
11
+ kProviderLabels,
12
+ } from "../../lib/model-config.js";
9
13
 
10
14
  const html = htm.bind(h);
11
15
 
@@ -71,10 +75,24 @@ const kDefaultMode = {
71
75
  field: "key",
72
76
  };
73
77
 
78
+ const buildDefaultProviderModes = (provider) => {
79
+ const fields = kProviderAuthFields[provider] || [];
80
+ if (fields.length === 0) return [kDefaultMode];
81
+ return fields.map((fieldDef) => ({
82
+ id: "api_key",
83
+ label: fieldDef.label || "API Key",
84
+ profileSuffix: "default",
85
+ placeholder: fieldDef.placeholder || "...",
86
+ hint: fieldDef.hint,
87
+ url: fieldDef.url,
88
+ field: "key",
89
+ }));
90
+ };
91
+
74
92
  const getProviderMeta = (provider) =>
75
93
  kProviderMeta[provider] || {
76
- label: provider,
77
- modes: [kDefaultMode],
94
+ label: kProviderLabels[provider] || provider,
95
+ modes: buildDefaultProviderModes(provider),
78
96
  };
79
97
 
80
98
  const resolveProfileId = (mode, provider) => {
@@ -137,7 +137,10 @@ export const Models = () => {
137
137
 
138
138
  const setEnvValue = (key, value) => {
139
139
  setEnvVars((prev) => {
140
- const next = prev.map((v) => (v.key === key ? { ...v, value } : v));
140
+ const existing = prev.some((entry) => entry.key === key);
141
+ const next = existing
142
+ ? prev.map((v) => (v.key === key ? { ...v, value } : v))
143
+ : [...prev, { key, value, editable: true }];
141
144
  kModelsTabCache = { ...(kModelsTabCache || {}), envVars: next };
142
145
  return next;
143
146
  });
@@ -263,15 +266,11 @@ export const Models = () => {
263
266
  (key) => getKeyVal(envVars, key) !== (savedAiValues[key] || ""),
264
267
  );
265
268
  const hasSelectedProviderAuth =
266
- selectedModelProvider === "anthropic"
267
- ? !!(getKeyVal(envVars, "ANTHROPIC_API_KEY") || getKeyVal(envVars, "ANTHROPIC_TOKEN"))
268
- : selectedModelProvider === "openai"
269
- ? !!getKeyVal(envVars, "OPENAI_API_KEY")
270
- : selectedModelProvider === "openai-codex"
269
+ selectedModelProvider === "openai-codex"
271
270
  ? !!codexStatus.connected
272
- : selectedModelProvider === "google"
273
- ? !!getKeyVal(envVars, "GEMINI_API_KEY")
274
- : false;
271
+ : (kProviderAuthFields[selectedAuthProvider] || []).some((field) =>
272
+ Boolean(getKeyVal(envVars, field.key)),
273
+ );
275
274
  const canSaveChanges = !savingChanges && (aiCredentialsDirty || (modelDirty && hasSelectedProviderAuth));
276
275
 
277
276
  const renderCredentialField = (field) => html`
@@ -30,8 +30,8 @@ export const useWelcomeStorage = ({
30
30
  String(initialSetupState?.[kOnboardingStepKey] || ""),
31
31
  10,
32
32
  );
33
- if (!Number.isFinite(parsedStep)) return 0;
34
- const clampedStep = Math.max(0, Math.min(kPairingStepIndex, parsedStep));
33
+ if (!Number.isFinite(parsedStep)) return -1;
34
+ const clampedStep = Math.max(-1, Math.min(kPairingStepIndex, parsedStep));
35
35
  if (clampedStep === kSetupStepIndex && shouldRecoverFromSetupState) return 0;
36
36
  return clampedStep;
37
37
  });
@@ -29,7 +29,7 @@ export const kWelcomeGroups = [
29
29
  {
30
30
  id: "github",
31
31
  title: "GitHub",
32
- description: "Backs up your agent's config and workspace",
32
+ description: "Auto-backup your config and workspace",
33
33
  fields: [
34
34
  {
35
35
  key: "_GITHUB_SOURCE_REPO",