@chrysb/alphaclaw 0.4.6-beta.4 → 0.4.6-beta.6

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 (52) hide show
  1. package/lib/public/js/app.js +158 -1073
  2. package/lib/public/js/components/envars.js +146 -29
  3. package/lib/public/js/components/features.js +1 -1
  4. package/lib/public/js/components/general/index.js +155 -0
  5. package/lib/public/js/components/icons.js +52 -0
  6. package/lib/public/js/components/info-tooltip.js +4 -7
  7. package/lib/public/js/components/models-tab/index.js +286 -0
  8. package/lib/public/js/components/models-tab/provider-auth-card.js +369 -0
  9. package/lib/public/js/components/models-tab/use-models.js +262 -0
  10. package/lib/public/js/components/models.js +1 -1
  11. package/lib/public/js/components/providers.js +1 -1
  12. package/lib/public/js/components/routes/browse-route.js +35 -0
  13. package/lib/public/js/components/routes/doctor-route.js +21 -0
  14. package/lib/public/js/components/routes/envars-route.js +11 -0
  15. package/lib/public/js/components/routes/general-route.js +45 -0
  16. package/lib/public/js/components/routes/index.js +11 -0
  17. package/lib/public/js/components/routes/models-route.js +11 -0
  18. package/lib/public/js/components/routes/providers-route.js +11 -0
  19. package/lib/public/js/components/routes/route-redirect.js +10 -0
  20. package/lib/public/js/components/routes/telegram-route.js +11 -0
  21. package/lib/public/js/components/routes/usage-route.js +15 -0
  22. package/lib/public/js/components/routes/watchdog-route.js +32 -0
  23. package/lib/public/js/components/routes/webhooks-route.js +43 -0
  24. package/lib/public/js/components/sidebar.js +2 -3
  25. package/lib/public/js/components/tooltip.js +106 -0
  26. package/lib/public/js/components/usage-tab/constants.js +1 -1
  27. package/lib/public/js/components/usage-tab/overview-section.js +124 -50
  28. package/lib/public/js/components/usage-tab/use-usage-tab.js +42 -11
  29. package/lib/public/js/components/welcome.js +1 -1
  30. package/lib/public/js/hooks/use-app-shell-controller.js +230 -0
  31. package/lib/public/js/hooks/use-app-shell-ui.js +112 -0
  32. package/lib/public/js/hooks/use-browse-navigation.js +193 -0
  33. package/lib/public/js/hooks/use-hash-location.js +32 -0
  34. package/lib/public/js/lib/api.js +35 -0
  35. package/lib/public/js/lib/app-navigation.js +39 -0
  36. package/lib/public/js/lib/browse-restart-policy.js +28 -0
  37. package/lib/public/js/lib/browse-route.js +57 -0
  38. package/lib/public/js/lib/format.js +12 -0
  39. package/lib/public/js/lib/model-config.js +1 -0
  40. package/lib/server/auth-profiles.js +291 -53
  41. package/lib/server/constants.js +24 -8
  42. package/lib/server/doctor/service.js +0 -3
  43. package/lib/server/gateway.js +50 -31
  44. package/lib/server/onboarding/index.js +2 -0
  45. package/lib/server/onboarding/validation.js +2 -2
  46. package/lib/server/routes/models.js +214 -2
  47. package/lib/server/routes/onboarding.js +2 -0
  48. package/lib/server/routes/system.js +42 -1
  49. package/lib/server/watchdog.js +14 -1
  50. package/lib/server.js +6 -0
  51. package/lib/setup/env.template +1 -0
  52. package/package.json +1 -1
@@ -0,0 +1,286 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useState, useMemo } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import { PageHeader } from "../page-header.js";
5
+ import { LoadingSpinner } from "../loading-spinner.js";
6
+ import { ActionButton } from "../action-button.js";
7
+ import { Badge } from "../badge.js";
8
+ import { useModels } from "./use-models.js";
9
+ import { ProviderAuthCard } from "./provider-auth-card.js";
10
+ import { getModelProvider, getFeaturedModels } from "../../lib/model-config.js";
11
+
12
+ const html = htm.bind(h);
13
+
14
+ const deriveRequiredProviders = (configuredModels) => {
15
+ const providers = new Set();
16
+ for (const modelKey of Object.keys(configuredModels)) {
17
+ const provider = getModelProvider(modelKey);
18
+ if (provider) providers.add(provider);
19
+ }
20
+ return [...providers];
21
+ };
22
+
23
+ const kProviderDisplayOrder = [
24
+ "anthropic",
25
+ "openai",
26
+ "openai-codex",
27
+ "google",
28
+ ];
29
+
30
+ export const Models = ({ onRestartRequired = () => {} }) => {
31
+ const {
32
+ catalog,
33
+ primary,
34
+ configuredModels,
35
+ authProfiles,
36
+ authOrder,
37
+ codexStatus,
38
+ loading,
39
+ saving,
40
+ ready,
41
+ error,
42
+ isDirty,
43
+ addModel,
44
+ removeModel,
45
+ setPrimaryModel,
46
+ editProfile,
47
+ editAuthOrder,
48
+ getProfileValue,
49
+ getEffectiveOrder,
50
+ cancelChanges,
51
+ saveAll,
52
+ refreshCodexStatus,
53
+ } = useModels();
54
+
55
+ const [showAllModels, setShowAllModels] = useState(false);
56
+
57
+ const configuredKeys = useMemo(
58
+ () => new Set(Object.keys(configuredModels)),
59
+ [configuredModels],
60
+ );
61
+
62
+ const featuredModels = useMemo(() => getFeaturedModels(catalog), [catalog]);
63
+
64
+ 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;
75
+
76
+ const requiredProviders = useMemo(
77
+ () => deriveRequiredProviders(configuredModels),
78
+ [configuredModels],
79
+ );
80
+
81
+ const sortedProviders = useMemo(() => {
82
+ const ordered = [];
83
+ for (const p of kProviderDisplayOrder) {
84
+ if (requiredProviders.includes(p)) ordered.push(p);
85
+ }
86
+ for (const p of requiredProviders) {
87
+ if (!ordered.includes(p)) ordered.push(p);
88
+ }
89
+ return ordered;
90
+ }, [requiredProviders]);
91
+
92
+ const providerHasAuth = useMemo(() => {
93
+ const result = {};
94
+ for (const p of authProfiles) {
95
+ if (p.key || p.token || p.access) {
96
+ result[p.provider] = true;
97
+ }
98
+ }
99
+ if (codexStatus?.connected) {
100
+ result["openai-codex"] = true;
101
+ }
102
+ return result;
103
+ }, [authProfiles, codexStatus]);
104
+
105
+ const configuredModelEntries = useMemo(
106
+ () =>
107
+ Object.keys(configuredModels).map((key) => {
108
+ const catalogEntry = catalog.find((m) => m.key === key);
109
+ const provider = getModelProvider(key);
110
+ const hasAuth = !!providerHasAuth[provider];
111
+ return {
112
+ key,
113
+ label: catalogEntry?.label || key,
114
+ isPrimary: key === primary,
115
+ hasAuth,
116
+ };
117
+ }),
118
+ [configuredModels, catalog, primary, providerHasAuth],
119
+ );
120
+
121
+ if (!ready) {
122
+ return html`
123
+ <div class="space-y-4">
124
+ <${PageHeader}
125
+ title="Models"
126
+ actions=${html`
127
+ <${ActionButton}
128
+ disabled=${true}
129
+ tone="primary"
130
+ size="sm"
131
+ idleLabel="Save changes"
132
+ className="transition-all"
133
+ />
134
+ `}
135
+ />
136
+ <div class="bg-surface border border-border rounded-xl p-4">
137
+ <div class="flex items-center gap-2 text-sm text-gray-400">
138
+ <${LoadingSpinner} className="h-4 w-4" />
139
+ Loading model settings...
140
+ </div>
141
+ </div>
142
+ </div>
143
+ `;
144
+ }
145
+
146
+ return html`
147
+ <div class="space-y-4">
148
+ <${PageHeader}
149
+ title="Models"
150
+ actions=${html`
151
+ <${ActionButton}
152
+ onClick=${cancelChanges}
153
+ disabled=${!isDirty || saving}
154
+ tone="secondary"
155
+ size="sm"
156
+ idleLabel="Cancel"
157
+ className="transition-all"
158
+ />
159
+ <${ActionButton}
160
+ onClick=${saveAll}
161
+ disabled=${!isDirty || saving}
162
+ loading=${saving}
163
+ tone="primary"
164
+ size="sm"
165
+ idleLabel="Save changes"
166
+ loadingLabel="Saving..."
167
+ className="transition-all"
168
+ />
169
+ `}
170
+ />
171
+
172
+ <!-- Configured Models -->
173
+ <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
174
+ <h2 class="font-semibold text-sm">Available Models</h2>
175
+
176
+ ${configuredModelEntries.length === 0
177
+ ? html`<p class="text-xs text-gray-500">
178
+ No models configured. Add a model below.
179
+ </p>`
180
+ : html`
181
+ <div class="space-y-1">
182
+ ${configuredModelEntries.map(
183
+ (entry) => html`
184
+ <div
185
+ class="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-white/5"
186
+ >
187
+ <div class="flex items-center gap-2 min-w-0">
188
+ <span class="text-sm text-gray-200 truncate"
189
+ >${entry.label}</span
190
+ >
191
+ ${entry.isPrimary
192
+ ? html`<${Badge} tone="cyan">Primary</${Badge}>`
193
+ : entry.hasAuth
194
+ ? html`
195
+ <button
196
+ onclick=${() => setPrimaryModel(entry.key)}
197
+ class="text-xs px-2 py-0.5 rounded-full text-gray-500 hover:text-gray-300 hover:bg-white/5"
198
+ >
199
+ Set primary
200
+ </button>
201
+ `
202
+ : html`<${Badge} tone="warning">Needs auth</${Badge}>`}
203
+ </div>
204
+ <button
205
+ onclick=${() => removeModel(entry.key)}
206
+ class="text-xs text-gray-600 hover:text-red-400 shrink-0 px-1"
207
+ >
208
+ Remove
209
+ </button>
210
+ </div>
211
+ `,
212
+ )}
213
+ </div>
214
+ `}
215
+
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 = "";
226
+ }}
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}
248
+ </div>
249
+
250
+ ${loading
251
+ ? html`<p class="text-xs text-gray-600">
252
+ Loading model catalog...
253
+ </p>`
254
+ : error
255
+ ? html`<p class="text-xs text-gray-600">${error}</p>`
256
+ : null}
257
+ </div>
258
+
259
+ <!-- Provider Auth -->
260
+ ${sortedProviders.length > 0
261
+ ? html`
262
+ <div class="space-y-3">
263
+ <h2 class="font-semibold text-sm text-gray-300">
264
+ Provider Authentication
265
+ </h2>
266
+ ${sortedProviders.map(
267
+ (provider) => html`
268
+ <${ProviderAuthCard}
269
+ provider=${provider}
270
+ authProfiles=${authProfiles}
271
+ authOrder=${authOrder}
272
+ codexStatus=${codexStatus}
273
+ onEditProfile=${editProfile}
274
+ onEditAuthOrder=${editAuthOrder}
275
+ getProfileValue=${getProfileValue}
276
+ getEffectiveOrder=${getEffectiveOrder}
277
+ onRefreshCodex=${refreshCodexStatus}
278
+ />
279
+ `,
280
+ )}
281
+ </div>
282
+ `
283
+ : null}
284
+ </div>
285
+ `;
286
+ };
@@ -0,0 +1,369 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useState, useRef, useEffect } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import { Badge } from "../badge.js";
5
+ import { SecretInput } from "../secret-input.js";
6
+ import { ActionButton } from "../action-button.js";
7
+ import { exchangeCodexOAuth, disconnectCodex } from "../../lib/api.js";
8
+ import { showToast } from "../toast.js";
9
+
10
+ const html = htm.bind(h);
11
+
12
+ const kProviderMeta = {
13
+ anthropic: {
14
+ label: "Anthropic",
15
+ modes: [
16
+ {
17
+ id: "api_key",
18
+ label: "API Key",
19
+ profileSuffix: "default",
20
+ placeholder: "sk-ant-api03-...",
21
+ url: "https://console.anthropic.com",
22
+ field: "key",
23
+ },
24
+ {
25
+ id: "token",
26
+ label: "Setup Token",
27
+ profileSuffix: "manual",
28
+ placeholder: "sk-ant-oat01-...",
29
+ hint: "From claude setup-token (uses your Claude subscription)",
30
+ field: "token",
31
+ },
32
+ ],
33
+ },
34
+ openai: {
35
+ label: "OpenAI",
36
+ modes: [
37
+ {
38
+ id: "api_key",
39
+ label: "API Key",
40
+ profileSuffix: "default",
41
+ placeholder: "sk-...",
42
+ url: "https://platform.openai.com",
43
+ field: "key",
44
+ },
45
+ ],
46
+ },
47
+ "openai-codex": {
48
+ label: "OpenAI Codex",
49
+ modes: [{ id: "oauth", label: "Codex OAuth", isCodexOauth: true }],
50
+ },
51
+ google: {
52
+ label: "Gemini",
53
+ modes: [
54
+ {
55
+ id: "api_key",
56
+ label: "API Key",
57
+ profileSuffix: "default",
58
+ placeholder: "AI...",
59
+ url: "https://aistudio.google.com",
60
+ field: "key",
61
+ },
62
+ ],
63
+ },
64
+ };
65
+
66
+ const kDefaultMode = {
67
+ id: "api_key",
68
+ label: "API Key",
69
+ profileSuffix: "default",
70
+ placeholder: "...",
71
+ field: "key",
72
+ };
73
+
74
+ const getProviderMeta = (provider) =>
75
+ kProviderMeta[provider] || {
76
+ label: provider,
77
+ modes: [kDefaultMode],
78
+ };
79
+
80
+ const resolveProfileId = (mode, provider) => {
81
+ const p = mode.provider || provider;
82
+ return `${p}:${mode.profileSuffix || "default"}`;
83
+ };
84
+
85
+ const CodexOAuthSection = ({ codexStatus, onRefreshCodex }) => {
86
+ const [authStarted, setAuthStarted] = useState(false);
87
+ const [authWaiting, setAuthWaiting] = useState(false);
88
+ const [manualInput, setManualInput] = useState("");
89
+ const [exchanging, setExchanging] = useState(false);
90
+ const popupPollRef = useRef(null);
91
+
92
+ useEffect(
93
+ () => () => {
94
+ if (popupPollRef.current) clearInterval(popupPollRef.current);
95
+ },
96
+ [],
97
+ );
98
+
99
+ useEffect(() => {
100
+ const onMessage = async (e) => {
101
+ if (e.data?.codex === "success") {
102
+ showToast("Codex connected", "success");
103
+ setAuthStarted(false);
104
+ setAuthWaiting(false);
105
+ await onRefreshCodex();
106
+ } else if (e.data?.codex === "error") {
107
+ showToast(
108
+ `Codex auth failed: ${e.data.message || "unknown error"}`,
109
+ "error",
110
+ );
111
+ }
112
+ };
113
+ window.addEventListener("message", onMessage);
114
+ return () => window.removeEventListener("message", onMessage);
115
+ }, [onRefreshCodex]);
116
+
117
+ const startAuth = () => {
118
+ setAuthStarted(true);
119
+ setAuthWaiting(true);
120
+ const popup = window.open(
121
+ "/auth/codex/start",
122
+ "codex-auth",
123
+ "popup=yes,width=640,height=780",
124
+ );
125
+ if (!popup || popup.closed) {
126
+ setAuthWaiting(false);
127
+ window.location.href = "/auth/codex/start";
128
+ return;
129
+ }
130
+ if (popupPollRef.current) clearInterval(popupPollRef.current);
131
+ popupPollRef.current = setInterval(() => {
132
+ if (popup.closed) {
133
+ clearInterval(popupPollRef.current);
134
+ popupPollRef.current = null;
135
+ setAuthWaiting(false);
136
+ }
137
+ }, 500);
138
+ };
139
+
140
+ const completeAuth = async () => {
141
+ if (!manualInput.trim() || exchanging) return;
142
+ setExchanging(true);
143
+ try {
144
+ const result = await exchangeCodexOAuth(manualInput.trim());
145
+ if (!result.ok)
146
+ throw new Error(result.error || "Codex OAuth exchange failed");
147
+ setManualInput("");
148
+ showToast("Codex connected", "success");
149
+ setAuthStarted(false);
150
+ setAuthWaiting(false);
151
+ await onRefreshCodex();
152
+ } catch (err) {
153
+ showToast(err.message || "Codex OAuth exchange failed", "error");
154
+ } finally {
155
+ setExchanging(false);
156
+ }
157
+ };
158
+
159
+ const handleDisconnect = async () => {
160
+ const result = await disconnectCodex();
161
+ if (!result.ok) {
162
+ showToast(result.error || "Failed to disconnect Codex", "error");
163
+ return;
164
+ }
165
+ showToast("Codex disconnected", "success");
166
+ setAuthStarted(false);
167
+ setAuthWaiting(false);
168
+ setManualInput("");
169
+ await onRefreshCodex();
170
+ };
171
+
172
+ return html`
173
+ <div class="space-y-2">
174
+ <div class="flex items-center justify-between">
175
+ <span class="text-xs text-gray-400">Codex OAuth</span>
176
+ ${codexStatus.connected
177
+ ? html`<${Badge} tone="success">Connected</${Badge}>`
178
+ : html`<${Badge} tone="warning">Not connected</${Badge}>`}
179
+ </div>
180
+ ${codexStatus.connected
181
+ ? html`
182
+ <div class="flex gap-2">
183
+ <button
184
+ onclick=${startAuth}
185
+ class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-secondary"
186
+ >
187
+ Reconnect
188
+ </button>
189
+ <button
190
+ onclick=${handleDisconnect}
191
+ class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-ghost"
192
+ >
193
+ Disconnect
194
+ </button>
195
+ </div>
196
+ `
197
+ : !authStarted
198
+ ? html`
199
+ <button
200
+ onclick=${startAuth}
201
+ class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-cyan"
202
+ >
203
+ Connect Codex OAuth
204
+ </button>
205
+ `
206
+ : html`
207
+ <div class="flex items-center justify-between gap-2">
208
+ <p class="text-xs text-gray-500">
209
+ ${authWaiting
210
+ ? "Complete login in the popup, then paste the redirect URL."
211
+ : "Paste the redirect URL from your browser to finish connecting."}
212
+ </p>
213
+ <button
214
+ onclick=${startAuth}
215
+ class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-secondary shrink-0"
216
+ >
217
+ Restart
218
+ </button>
219
+ </div>
220
+ `}
221
+ ${!codexStatus.connected && authStarted
222
+ ? html`
223
+ <p class="text-xs text-gray-500">
224
+ After login, copy the full redirect URL (starts with
225
+ <code class="text-xs bg-black/30 px-1 rounded"
226
+ >http://localhost:1455/auth/callback</code
227
+ >) and paste it here.
228
+ </p>
229
+ <input
230
+ type="text"
231
+ value=${manualInput}
232
+ onInput=${(e) => setManualInput(e.target.value)}
233
+ placeholder="http://localhost:1455/auth/callback?code=...&state=..."
234
+ class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-xs text-gray-200 outline-none focus:border-gray-500"
235
+ />
236
+ <${ActionButton}
237
+ onClick=${completeAuth}
238
+ disabled=${!manualInput.trim() || exchanging}
239
+ loading=${exchanging}
240
+ tone="primary"
241
+ size="sm"
242
+ idleLabel="Complete Codex OAuth"
243
+ loadingLabel="Completing..."
244
+ className="text-xs font-medium px-3 py-1.5"
245
+ />
246
+ `
247
+ : null}
248
+ </div>
249
+ `;
250
+ };
251
+
252
+ export const ProviderAuthCard = ({
253
+ provider,
254
+ authProfiles,
255
+ authOrder,
256
+ codexStatus,
257
+ onEditProfile,
258
+ onEditAuthOrder,
259
+ getProfileValue,
260
+ getEffectiveOrder,
261
+ onRefreshCodex,
262
+ }) => {
263
+ const meta = getProviderMeta(provider);
264
+ const credentialModes = meta.modes.filter((m) => !m.isCodexOauth);
265
+ const hasMultipleModes = credentialModes.length > 1;
266
+ const showsInlineOauthStatus = meta.modes.some((m) => m.isCodexOauth);
267
+
268
+ const effectiveOrder = getEffectiveOrder(provider);
269
+ const activeProfileId = effectiveOrder?.[0] || null;
270
+
271
+ const isConnected =
272
+ credentialModes.some((mode) => {
273
+ const profileId = resolveProfileId(mode, provider);
274
+ const val = getProfileValue(profileId);
275
+ return !!(val?.key || val?.token || val?.access);
276
+ }) || (provider === "openai-codex" && !!codexStatus?.connected);
277
+
278
+ const handleSetActive = (mode) => {
279
+ const profileId = resolveProfileId(mode, provider);
280
+ const allIds = credentialModes.map((m) => resolveProfileId(m, provider));
281
+ const ordered = [profileId, ...allIds.filter((id) => id !== profileId)];
282
+ onEditAuthOrder(provider, ordered);
283
+ };
284
+
285
+ return html`
286
+ <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
287
+ <div class="flex items-center justify-between">
288
+ <h3 class="font-semibold text-sm">${meta.label}</h3>
289
+ ${showsInlineOauthStatus && credentialModes.length === 0
290
+ ? null
291
+ : isConnected
292
+ ? html`<${Badge} tone="success">Connected</${Badge}>`
293
+ : html`<${Badge} tone="warning">Not configured</${Badge}>`}
294
+ </div>
295
+ ${credentialModes.map((mode) => {
296
+ const profileId = resolveProfileId(mode, provider);
297
+ const profileProvider = mode.provider || provider;
298
+ const currentValue = getProfileValue(profileId);
299
+ const fieldValue = currentValue?.[mode.field] || "";
300
+ const isActive =
301
+ !hasMultipleModes ||
302
+ activeProfileId === profileId ||
303
+ (!activeProfileId && mode === credentialModes[0]);
304
+
305
+ return html`
306
+ <div class="space-y-1.5">
307
+ <div class="flex items-center gap-2">
308
+ <label class="text-xs font-medium text-gray-400"
309
+ >${mode.label}</label
310
+ >
311
+ ${hasMultipleModes && isActive
312
+ ? html`<${Badge} tone="cyan">Primary</${Badge}>`
313
+ : null}
314
+ ${hasMultipleModes && !isActive && fieldValue
315
+ ? html`<button
316
+ onclick=${() => handleSetActive(mode)}
317
+ class="text-xs px-1.5 py-0.5 rounded-full text-gray-500 hover:text-gray-300 hover:bg-white/5"
318
+ >
319
+ Set primary
320
+ </button>`
321
+ : null}
322
+ ${mode.url && !fieldValue
323
+ ? html`<a
324
+ href=${mode.url}
325
+ target="_blank"
326
+ class="text-xs hover:underline"
327
+ style="color: var(--accent-link)"
328
+ >Get</a
329
+ >`
330
+ : null}
331
+ </div>
332
+ <${SecretInput}
333
+ value=${fieldValue}
334
+ onInput=${(e) => {
335
+ const newVal = e.target.value;
336
+ const cred = {
337
+ type: mode.id,
338
+ provider: profileProvider,
339
+ [mode.field]: newVal,
340
+ };
341
+ if (currentValue?.expires) cred.expires = currentValue.expires;
342
+ onEditProfile(profileId, cred);
343
+ if (hasMultipleModes && newVal && !isActive) {
344
+ handleSetActive(mode);
345
+ }
346
+ }}
347
+ placeholder=${mode.placeholder || ""}
348
+ isSecret=${true}
349
+ inputClass="flex-1 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 font-mono"
350
+ />
351
+ ${mode.hint
352
+ ? html`<p class="text-xs text-gray-600">${mode.hint}</p>`
353
+ : null}
354
+ </div>
355
+ `;
356
+ })}
357
+ ${meta.modes.some((m) => m.isCodexOauth)
358
+ ? html`
359
+ <div class="border border-border rounded-lg p-3">
360
+ <${CodexOAuthSection}
361
+ codexStatus=${codexStatus}
362
+ onRefreshCodex=${onRefreshCodex}
363
+ />
364
+ </div>
365
+ `
366
+ : null}
367
+ </div>
368
+ `;
369
+ };