@chrysb/alphaclaw 0.5.1 → 0.5.3

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.
@@ -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`
@@ -3,6 +3,7 @@ import { useState } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
4
  import { ActionButton } from "../action-button.js";
5
5
  import { LoadingSpinner } from "../loading-spinner.js";
6
+ import { buildApprovedImportSecrets } from "./welcome-secret-review-utils.js";
6
7
 
7
8
  const html = htm.bind(h);
8
9
 
@@ -237,9 +238,10 @@ export const WelcomeImportStep = ({
237
238
  <div
238
239
  class="bg-yellow-900/20 border border-yellow-800/50 rounded-lg p-3 text-xs text-yellow-300"
239
240
  >
240
- AlphaClaw controls deployment env vars
241
+ AlphaClaw controls deployment tokens and env vars
241
242
  (${(scanResult.managedEnvConflicts.vars || []).join(", ")}).
242
- Imported values for these will be normalized during import.
243
+ Imported values for these will be overwritten with AlphaClaw-managed
244
+ env var references during import.
243
245
  </div>
244
246
  `
245
247
  : null}
@@ -266,8 +268,7 @@ export const WelcomeImportStep = ({
266
268
  >
267
269
  <div>
268
270
  <span class="text-xs text-cyan-300 font-medium">
269
- ${secretCount} possible secret${secretCount === 1 ? "" : "s"}
270
- detected
271
+ ${`${secretCount} possible secret${secretCount === 1 ? "" : "s"} detected`}
271
272
  </span>
272
273
  <p class="text-xs text-gray-500 mt-0.5">
273
274
  Review and extract to environment variables
@@ -292,7 +293,7 @@ export const WelcomeImportStep = ({
292
293
  className="w-full"
293
294
  />
294
295
  <${ActionButton}
295
- onClick=${() => onApprove([])}
296
+ onClick=${() => onApprove(buildApprovedImportSecrets(scanResult.secrets))}
296
297
  loading=${scanning}
297
298
  tone="primary"
298
299
  size="md"
@@ -3,6 +3,7 @@ import { useState, useCallback } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
4
  import { ActionButton } from "../action-button.js";
5
5
  import { LoadingSpinner } from "../loading-spinner.js";
6
+ import { buildApprovedImportSecrets } from "./welcome-secret-review-utils.js";
6
7
 
7
8
  const html = htm.bind(h);
8
9
 
@@ -116,13 +117,16 @@ export const WelcomeSecretReviewStep = ({
116
117
  ).length;
117
118
 
118
119
  const handleExtract = () => {
119
- const approved = secrets
120
- .filter((s) => selections[s.configPath]?.selected)
121
- .map((s) => ({
122
- ...s,
120
+ const approved = buildApprovedImportSecrets(
121
+ secrets.map((secret) => ({
122
+ ...secret,
123
+ confidence: selections[secret.configPath]?.selected
124
+ ? "high"
125
+ : "medium",
123
126
  suggestedEnvVar:
124
- selections[s.configPath]?.envVarName || s.suggestedEnvVar,
125
- }));
127
+ selections[secret.configPath]?.envVarName || secret.suggestedEnvVar,
128
+ })),
129
+ );
126
130
  onApprove(approved);
127
131
  };
128
132
 
@@ -174,12 +178,14 @@ export const WelcomeSecretReviewStep = ({
174
178
  <${ActionButton}
175
179
  onClick=${onBack}
176
180
  tone="secondary"
181
+ size="md"
177
182
  idleLabel="Back"
178
183
  className="w-full"
179
184
  />
180
185
  <${ActionButton}
181
186
  onClick=${handleExtract}
182
187
  tone="primary"
188
+ size="md"
183
189
  idleLabel=${selectedCount > 0
184
190
  ? `Extract ${selectedCount} Secret${selectedCount === 1 ? "" : "s"}`
185
191
  : "Skip All"}
@@ -0,0 +1,19 @@
1
+ export const buildApprovedImportSecrets = (secrets = []) =>
2
+ (Array.isArray(secrets) ? secrets : [])
3
+ .filter((secret) => secret?.confidence === "high")
4
+ .map((secret) => ({
5
+ ...secret,
6
+ suggestedEnvVar: secret?.suggestedEnvVar || "",
7
+ }));
8
+
9
+ export const buildApprovedImportVals = (approvedSecrets = []) =>
10
+ (Array.isArray(approvedSecrets) ? approvedSecrets : []).reduce(
11
+ (nextVals, secret) => {
12
+ const envVar = String(secret?.suggestedEnvVar || "").trim();
13
+ const value = String(secret?.value || "");
14
+ if (!envVar || !value) return nextVals;
15
+ nextVals[envVar] = value;
16
+ return nextVals;
17
+ },
18
+ {},
19
+ );
@@ -189,7 +189,10 @@ export const Providers = ({ onRestartRequired = () => {} }) => {
189
189
 
190
190
  const setEnvValue = (key, value) => {
191
191
  setEnvVars((prev) => {
192
- const next = prev.map((v) => (v.key === key ? { ...v, value } : v));
192
+ const existing = prev.some((entry) => entry.key === key);
193
+ const next = existing
194
+ ? prev.map((v) => (v.key === key ? { ...v, value } : v))
195
+ : [...prev, { key, value, editable: true }];
193
196
  kProvidersTabCache = { ...(kProvidersTabCache || {}), envVars: next };
194
197
  return next;
195
198
  });
@@ -224,18 +227,11 @@ export const Providers = ({ onRestartRequired = () => {} }) => {
224
227
  (key) => getKeyVal(envVars, key) !== (savedAiValues[key] || ""),
225
228
  );
226
229
  const hasSelectedProviderAuth =
227
- selectedModelProvider === "anthropic"
228
- ? !!(
229
- getKeyVal(envVars, "ANTHROPIC_API_KEY") ||
230
- getKeyVal(envVars, "ANTHROPIC_TOKEN")
231
- )
232
- : selectedModelProvider === "openai"
233
- ? !!getKeyVal(envVars, "OPENAI_API_KEY")
234
- : selectedModelProvider === "openai-codex"
235
- ? !!codexStatus.connected
236
- : selectedModelProvider === "google"
237
- ? !!getKeyVal(envVars, "GEMINI_API_KEY")
238
- : false;
230
+ selectedModelProvider === "openai-codex"
231
+ ? !!codexStatus.connected
232
+ : (kProviderAuthFields[selectedAuthProvider] || []).some((field) =>
233
+ Boolean(getKeyVal(envVars, field.key)),
234
+ );
239
235
  const canSaveChanges =
240
236
  !savingChanges &&
241
237
  (aiCredentialsDirty || (modelDirty && hasSelectedProviderAuth));
@@ -31,8 +31,9 @@ const getCacheHitRateValueClass = (ratio) => {
31
31
  const getOverviewMetrics = (summary) => {
32
32
  const totals = summary?.totals || {};
33
33
  const cacheReadTokens = Number(totals.cacheReadTokens || 0);
34
+ const cacheWriteTokens = Number(totals.cacheWriteTokens || 0);
34
35
  const inputTokens = Number(totals.inputTokens || 0);
35
- const promptTokens = inputTokens + cacheReadTokens;
36
+ const promptTokens = inputTokens + cacheReadTokens + cacheWriteTokens;
36
37
  const turnCount = Number(totals.turnCount || 0);
37
38
  const totalTokens = Number(totals.totalTokens || 0);
38
39
  const totalCost = Number(totals.totalCost || 0);
@@ -29,6 +29,7 @@ import {
29
29
  } from "../onboarding/use-welcome-storage.js";
30
30
  import { useWelcomeCodex } from "../onboarding/use-welcome-codex.js";
31
31
  import { useWelcomePairing } from "../onboarding/use-welcome-pairing.js";
32
+ import { buildApprovedImportVals } from "../onboarding/welcome-secret-review-utils.js";
32
33
 
33
34
  const kMaxOnboardingVars = 64;
34
35
  const kMaxEnvKeyLength = 128;
@@ -413,6 +414,7 @@ export const useWelcome = ({ onComplete }) => {
413
414
  setImportError(null);
414
415
  try {
415
416
  const skipSecretExtraction = approvedSecrets.length === 0;
417
+ const approvedImportVals = buildApprovedImportVals(approvedSecrets);
416
418
  const result = await applyImport({
417
419
  tempDir: importTempDir,
418
420
  approvedSecrets,
@@ -430,6 +432,7 @@ export const useWelcome = ({ onComplete }) => {
430
432
  );
431
433
  setVals((prev) => ({
432
434
  ...prev,
435
+ ...approvedImportVals,
433
436
  ...(result.preFill || {}),
434
437
  [kImportPlaceholderReviewKey]: nextPlaceholderReview,
435
438
  [kImportPlaceholderSkipConfirmedKey]: false,