@spinabot/brigade 1.2.2 → 1.3.1

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 (67) hide show
  1. package/README.md +18 -4
  2. package/convex/schema.d.ts +6 -6
  3. package/convex/sessions.d.ts +4 -4
  4. package/convex/subagents.d.ts +4 -4
  5. package/dist/agents/agent-loop.d.ts.map +1 -1
  6. package/dist/agents/agent-loop.js +70 -10
  7. package/dist/agents/agent-loop.js.map +1 -1
  8. package/dist/agents/tools/manage-agent-tool.d.ts.map +1 -1
  9. package/dist/agents/tools/manage-agent-tool.js +2 -1
  10. package/dist/agents/tools/manage-agent-tool.js.map +1 -1
  11. package/dist/agents/tools/manage-provider-tool.d.ts.map +1 -1
  12. package/dist/agents/tools/manage-provider-tool.js +2 -1
  13. package/dist/agents/tools/manage-provider-tool.js.map +1 -1
  14. package/dist/agents/tools/org-tool.d.ts +15 -0
  15. package/dist/agents/tools/org-tool.d.ts.map +1 -1
  16. package/dist/agents/tools/org-tool.js +53 -6
  17. package/dist/agents/tools/org-tool.js.map +1 -1
  18. package/dist/agents/tools/registry.d.ts.map +1 -1
  19. package/dist/agents/tools/registry.js +3 -0
  20. package/dist/agents/tools/registry.js.map +1 -1
  21. package/dist/buildstamp.json +1 -1
  22. package/dist/cli/commands/doctor.d.ts.map +1 -1
  23. package/dist/cli/commands/doctor.js +25 -6
  24. package/dist/cli/commands/doctor.js.map +1 -1
  25. package/dist/cli/commands/login.d.ts +27 -0
  26. package/dist/cli/commands/login.d.ts.map +1 -0
  27. package/dist/cli/commands/login.js +142 -0
  28. package/dist/cli/commands/login.js.map +1 -0
  29. package/dist/cli/flows/web-setup.d.ts +2 -2
  30. package/dist/cli/flows/web-setup.js +2 -2
  31. package/dist/cli/program/build-program.d.ts.map +1 -1
  32. package/dist/cli/program/build-program.js +8 -0
  33. package/dist/cli/program/build-program.js.map +1 -1
  34. package/dist/core/auth-bridge.d.ts.map +1 -1
  35. package/dist/core/auth-bridge.js +75 -25
  36. package/dist/core/auth-bridge.js.map +1 -1
  37. package/dist/integrations/cli-login.d.ts +50 -0
  38. package/dist/integrations/cli-login.d.ts.map +1 -0
  39. package/dist/integrations/cli-login.js +114 -0
  40. package/dist/integrations/cli-login.js.map +1 -0
  41. package/dist/integrations/custom-provider.d.ts +21 -0
  42. package/dist/integrations/custom-provider.d.ts.map +1 -0
  43. package/dist/integrations/custom-provider.js +65 -0
  44. package/dist/integrations/custom-provider.js.map +1 -0
  45. package/dist/integrations/provider-discovery.d.ts +30 -0
  46. package/dist/integrations/provider-discovery.d.ts.map +1 -1
  47. package/dist/integrations/provider-discovery.js +155 -0
  48. package/dist/integrations/provider-discovery.js.map +1 -1
  49. package/dist/providers/catalog.d.ts +33 -0
  50. package/dist/providers/catalog.d.ts.map +1 -1
  51. package/dist/providers/catalog.js +91 -5
  52. package/dist/providers/catalog.js.map +1 -1
  53. package/dist/providers/validate-key.d.ts.map +1 -1
  54. package/dist/providers/validate-key.js +20 -6
  55. package/dist/providers/validate-key.js.map +1 -1
  56. package/dist/system-prompt/org/render-org-block.js +2 -2
  57. package/dist/system-prompt/org/render-org-block.js.map +1 -1
  58. package/dist/ui/onboard-storage-mode.js +24 -21
  59. package/dist/ui/onboard-storage-mode.js.map +1 -1
  60. package/dist/ui/onboarding.d.ts +18 -0
  61. package/dist/ui/onboarding.d.ts.map +1 -1
  62. package/dist/ui/onboarding.js +587 -71
  63. package/dist/ui/onboarding.js.map +1 -1
  64. package/package.json +6 -2
  65. package/scripts/assets/brigade-favicon.ico +0 -0
  66. package/scripts/assets/brigade-logo.webp +0 -0
  67. package/scripts/brand-oauth-page.mjs +92 -0
@@ -12,11 +12,20 @@
12
12
  * Uses Pi-TUI components — same components the chat UI uses, so the visual
13
13
  * language is consistent across the app.
14
14
  */
15
+ import { spawn } from "node:child_process";
15
16
  import { getModels } from "@earendil-works/pi-ai";
17
+ // `getOAuthProvider` lives ONLY on the "./oauth" subpath export — the package's
18
+ // main "." entry (base + register-builtins) does NOT re-export the OAuth
19
+ // registry, so importing it from "@earendil-works/pi-ai" resolves to undefined.
20
+ // The package.json "exports" map exposes "./oauth", so this is the supported
21
+ // path (verified against node_modules @ pi-ai 0.79.9).
22
+ import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
16
23
  import { CancellableLoader, Input, SelectList, Text } from "@earendil-works/pi-tui";
17
- import { upsertApiKeyProfile, upsertApiKeyRefProfile } from "../auth/profiles.js";
24
+ import { upsertApiKeyProfile, upsertApiKeyRefProfile, upsertOAuthProfile, upsertTokenProfile, } from "../auth/profiles.js";
18
25
  import { DEFAULT_AGENT_ID, resolveAuthProfilesPath, resolveModelsPath } from "../config/paths.js";
19
26
  import { saveConfig } from "../core/config.js";
27
+ import { readClaudeCliLogin, readCodexCliLogin } from "../integrations/cli-login.js";
28
+ import { writeCustomProviderToModelsJson } from "../integrations/custom-provider.js";
20
29
  import { discoverOllamaModels, writeOllamaToModelsJson } from "../integrations/ollama.js";
21
30
  import { findProvider, PROVIDERS, readProviderEnvKey, resolveProviderEnvVarSource, } from "../providers/catalog.js";
22
31
  import { validateApiKeyOnline } from "../providers/validate-key.js";
@@ -24,8 +33,26 @@ import { renderBrandHeader } from "./brand.js";
24
33
  import { brand, selectListTheme } from "./theme.js";
25
34
  import { pickStorageMode } from "./onboard-storage-mode.js";
26
35
  import { SearchableSelectList } from "./searchable-select.js";
27
- import { listOpenRouterModels } from "../integrations/provider-discovery.js";
36
+ import { getCachedSubscriptionModels, listOpenRouterModels, prefetchSubscriptionModels, } from "../integrations/provider-discovery.js";
28
37
  /* ────────────────────────────── public API ────────────────────────────── */
38
+ /**
39
+ * Strip terminal paste artifacts (bracketed-paste markers + stray control chars)
40
+ * from a typed/pasted value before use. `String.trim()` does NOT remove the
41
+ * `ESC[200~ … ESC[201~` wrapper some terminals add to a paste, so a valid key
42
+ * arrives as `<ESC>[200~sk-…` and fails validation. Keys / URLs / model ids are
43
+ * printable, so dropping control chars is safe.
44
+ */
45
+ function sanitizePastedValue(value) {
46
+ const esc = String.fromCharCode(27); // ESC (0x1b) from a code point — no control byte in source
47
+ const unwrapped = value.split(`${esc}[200~`).join("").split(`${esc}[201~`).join("");
48
+ let cleaned = "";
49
+ for (const ch of unwrapped) {
50
+ const code = ch.codePointAt(0) ?? 0;
51
+ if (code >= 0x20 && code !== 0x7f)
52
+ cleaned += ch; // drop C0 control chars + DEL
53
+ }
54
+ return cleaned.trim();
55
+ }
29
56
  /**
30
57
  * Resolve the model list for a provider. Pi's static `getModels()` only knows
31
58
  * built-in catalogs (Anthropic, OpenAI, Google, etc.); custom providers we
@@ -36,6 +63,34 @@ import { listOpenRouterModels } from "../integrations/provider-discovery.js";
36
63
  */
37
64
  async function getProviderModels(modelRegistry, providerId) {
38
65
  const staticModels = (() => {
66
+ // Subscription providers (e.g. GitHub Copilot) are filtered to the
67
+ // account's enabled models by the registry's `modifyModels` after login —
68
+ // prefer the refreshed registry so the picker shows exactly what the plan
69
+ // allows. Falls through to the static catalog when the registry is empty
70
+ // (e.g. pre-login).
71
+ if (findProvider(providerId)?.subscription || findProvider(providerId)?.custom) {
72
+ // Live fetch (warmed at login by `prefetchSubscriptionModels`) is
73
+ // authoritative for the SET of models the account can use. Join the
74
+ // static catalog by id for richer metadata (cost, context window) where
75
+ // Pi knows the model; live-only ids pass through as the loose live shape.
76
+ const live = getCachedSubscriptionModels(providerId);
77
+ if (live && live.length > 0) {
78
+ let catalog = [];
79
+ try {
80
+ catalog = getModels(providerId);
81
+ }
82
+ catch {
83
+ /* unknown provider — no catalog to join against */
84
+ }
85
+ const byId = new Map(catalog.map((m) => [m.id, m]));
86
+ return live.map((lm) => byId.get(lm.id) ?? lm);
87
+ }
88
+ const fromRegistry = modelRegistry
89
+ .getAll()
90
+ .filter((m) => m.provider === providerId);
91
+ if (fromRegistry.length > 0)
92
+ return fromRegistry;
93
+ }
39
94
  try {
40
95
  const fromCatalog = getModels(providerId);
41
96
  if (fromCatalog && fromCatalog.length > 0)
@@ -112,7 +167,7 @@ export async function runOnboarding(tui, authStorage, modelRegistry, opts = {})
112
167
  let modelId = "";
113
168
  while (true) {
114
169
  if (step === "provider") {
115
- renderScreen(tui, "Step 1 of 5 · Pick a provider");
170
+ renderScreen(tui, "Step 2 of 5 · Pick a provider");
116
171
  provider = await pickProvider(tui); // throws "onboarding-cancelled" on Esc
117
172
  step = "key";
118
173
  continue;
@@ -131,6 +186,54 @@ export async function runOnboarding(tui, authStorage, modelRegistry, opts = {})
131
186
  step = "model";
132
187
  continue;
133
188
  }
189
+ // CLI-login reuse — if the provider can adopt an already-logged-in
190
+ // vendor CLI's token on this machine (Claude Code, Codex), offer the
191
+ // one-keystroke "reuse this login" path FIRST. "other" means no CLI
192
+ // login present (or the user opted for a key / fresh login), so we
193
+ // fall through to the subscription / key path below.
194
+ if (providerInfo?.cliLogin) {
195
+ const r = await ensureCliLogin(tui, authStorage, providerInfo);
196
+ if (r === "ok") {
197
+ modelRegistry.refresh();
198
+ step = "model";
199
+ continue;
200
+ }
201
+ if (r === "back") {
202
+ step = "provider";
203
+ continue;
204
+ }
205
+ // r === "other" → fall through to the subscription/key path below
206
+ }
207
+ // Subscription providers (Claude Pro/Max, ChatGPT Plus/Pro, GitHub
208
+ // Copilot) log in through a browser OAuth flow instead of pasting an
209
+ // API key. The credential lands under the catalog `id` (which equals
210
+ // the oauthProviderId for these), so Pi routes their models to the
211
+ // right provider.
212
+ // Codex + Copilot model menus come from Pi's bundled catalog
213
+ // automatically; Copilot is further filtered to the account's enabled
214
+ // models via the `availableModelIds` persisted on login.
215
+ if (providerInfo?.subscription) {
216
+ const result = await ensureSubscriptionLogin(tui, authStorage, providerInfo);
217
+ if (result === "back") {
218
+ step = "provider";
219
+ continue;
220
+ }
221
+ modelRegistry.refresh();
222
+ step = "model";
223
+ continue;
224
+ }
225
+ // Custom (catalog-defined) providers — a key + a known
226
+ // Anthropic-compatible endpoint (GLM, Kimi, Qwen, MiniMax, DeepSeek).
227
+ // Paste the key, register the endpoint + models into models.json, done.
228
+ if (providerInfo?.custom && providerInfo.baseUrl) {
229
+ const r = await ensureCustomProvider(tui, authStorage, modelRegistry, providerInfo);
230
+ if (r === "back") {
231
+ step = "provider";
232
+ continue;
233
+ }
234
+ step = "model";
235
+ continue;
236
+ }
134
237
  const result = await ensureApiKey(tui, authStorage, provider, {
135
238
  noEnvDetect: opts.noEnvDetect,
136
239
  secretInputMode: opts.secretInputMode,
@@ -144,8 +247,8 @@ export async function runOnboarding(tui, authStorage, modelRegistry, opts = {})
144
247
  continue;
145
248
  }
146
249
  // step === "model"
147
- renderScreen(tui, "Step 3 of 5 · Default model");
148
- const result = await pickModel(tui, modelRegistry, provider);
250
+ renderScreen(tui, "Step 4 of 5 · Default model");
251
+ const result = await pickModel(tui, modelRegistry, findProvider(provider)?.providerId ?? provider);
149
252
  if (result === "back") {
150
253
  step = "provider"; // go all the way back so they can change provider too
151
254
  continue;
@@ -157,13 +260,17 @@ export async function runOnboarding(tui, authStorage, modelRegistry, opts = {})
157
260
  // agent's identity is left for the agent itself to discover via
158
261
  // BOOTSTRAP.md on first turn. Workspace scaffolding still happens at
159
262
  // agent boot via `buildAgent → seedDefaultPrompts`.
160
- await saveConfig({ defaultProvider: provider, defaultModelId: modelId });
161
- // Step 4 of 5 web-search backend. Same Pi-TUI components, same brand
263
+ // A picker entry may resolve to a different Pi provider for routing — e.g.
264
+ // "Claude Code" (subscription) stores under and routes through "anthropic".
265
+ // Persist the REAL provider id so the runtime resolves the model + credential.
266
+ const effectiveProvider = findProvider(provider)?.providerId ?? provider;
267
+ await saveConfig({ defaultProvider: effectiveProvider, defaultModelId: modelId });
268
+ // Step 5 of 5 — web-search backend. Same Pi-TUI components, same brand
162
269
  // header. Re-runnable via `brigade onboard web`.
163
270
  try {
164
271
  const { runWebSetupStep } = await import("../cli/flows/web-setup.js");
165
272
  await runWebSetupStep(tui, {
166
- stepLabel: "Step 4 of 5 · Web search",
273
+ stepLabel: "Step 5 of 5 · Web search",
167
274
  secretInputMode: opts.secretInputMode,
168
275
  });
169
276
  }
@@ -175,7 +282,7 @@ export async function runOnboarding(tui, authStorage, modelRegistry, opts = {})
175
282
  renderDone(tui, provider, modelId);
176
283
  await delay(900);
177
284
  clear(tui);
178
- return { provider, modelId, storage };
285
+ return { provider: effectiveProvider, modelId, storage };
179
286
  }
180
287
  /* ────────────────────────── screen scaffolding ────────────────────────── */
181
288
  /** Wipe everything, render the chunky brand header, then a sub-header line. */
@@ -192,34 +299,81 @@ function renderScreen(tui, subheader) {
192
299
  }
193
300
  /* ────────────────────────────── steps ─────────────────────────────────── */
194
301
  async function pickProvider(tui) {
195
- // Re-order providers so any with a credential the user already has —
196
- // either an env var Pi can read, OR a noAuth provider like Ollama —
197
- // floats to the top. Without this, PROVIDERS[0] = anthropic always wins
198
- // the highlight, and a user with only OPENROUTER_API_KEY exported picks
199
- // anthropic by reflex (then fails when they send a message).
200
- const detected = [];
201
- const undetected = [];
302
+ // Build the picker LOGIN-FIRST. The ranking surfaces, in order:
303
+ // 0. Already connected a vendor CLI login already on this machine
304
+ // (Claude Code / Codex) the user can reuse with no browser, no key.
305
+ // 1. Already connected a key already in the user's environment.
306
+ // 2. Log in with a subscription (browser approval) Claude Pro/Max,
307
+ // ChatGPT Plus/Pro, GitHub Copilot.
308
+ // 3. Use a coding-plan subscription key — GLM, Kimi, Qwen, MiniMax, DeepSeek.
309
+ // 4. Standard API-key providers.
310
+ // 5. Local (Ollama) and 6. bring-your-own endpoint.
311
+ // A type-to-filter box sits on top so the full list (18+) is searchable and
312
+ // nothing — including Anthropic at the top — ever hides below the fold.
313
+ const ranked = [];
202
314
  for (const p of PROVIDERS) {
203
- // `readProviderEnvKey` checks `envVar` AND any `envVarFallbacks`
204
- // Anthropic users with `ANTHROPIC_OAUTH_TOKEN` set get the detected
205
- // badge alongside the standard `ANTHROPIC_API_KEY` path.
315
+ // A login the matching vendor CLI already minted on this machine?
316
+ let cliReady = false;
317
+ if (p.cliLogin) {
318
+ const cred = p.cliLogin.read === "claude" ? readClaudeCliLogin() : readCodexCliLogin();
319
+ cliReady = cred !== null;
320
+ }
206
321
  const hasEnvKey = !!readProviderEnvKey(p);
207
- const noAuth = p.noAuth === true;
208
- const item = {
209
- value: p.id,
210
- label: p.name,
211
- description: hasEnvKey
212
- ? `${p.description} · detected ${p.envVar ?? "env var"}`
213
- : noAuth
214
- ? `${p.description} · no auth required`
215
- : p.description,
216
- };
217
- (hasEnvKey || noAuth ? detected : undetected).push(item);
322
+ const isSubscription = !!p.subscription;
323
+ const isCodingPlan = !!(p.custom && p.baseUrl);
324
+ const isLocal = p.local === true || p.noAuth === true;
325
+ const isBYO = p.custom === true && !p.baseUrl;
326
+ let rank;
327
+ let badge;
328
+ // A subscription provider stays "log in" (browser-first, multi-account) even
329
+ // when a CLI login is on disk — reuse is offered as a secondary option inside
330
+ // the flow. Only a pure CLI-login provider gets the rank-0 "reuse" treatment.
331
+ if (cliReady && !isSubscription) {
332
+ rank = 0;
333
+ badge = "logged in — reuse, no key";
334
+ }
335
+ else if (hasEnvKey) {
336
+ rank = 1;
337
+ badge = "detected — ready to use";
338
+ }
339
+ else if (isSubscription) {
340
+ rank = 2;
341
+ badge = "log in with your subscription";
342
+ }
343
+ else if (isCodingPlan) {
344
+ rank = 3;
345
+ badge = "use your coding-plan key";
346
+ }
347
+ else if (isLocal) {
348
+ rank = 5;
349
+ badge = "runs locally — no key";
350
+ }
351
+ else if (isBYO) {
352
+ rank = 6;
353
+ badge = "bring your own endpoint";
354
+ }
355
+ else {
356
+ rank = 4;
357
+ badge = ""; // standard API-key provider — its description says enough
358
+ }
359
+ ranked.push({
360
+ rank,
361
+ item: {
362
+ value: p.id,
363
+ label: p.name,
364
+ description: badge ? `${p.description} · ${badge}` : p.description,
365
+ },
366
+ });
218
367
  }
219
- const items = [...detected, ...undetected];
220
- const list = new SelectList(items, Math.min(items.length, 9), selectListTheme, {
368
+ // Stable sort (V8) keeps catalog order within a rank.
369
+ ranked.sort((a, b) => a.rank - b.rank);
370
+ const items = ranked.map((r) => r.item);
371
+ const list = new SearchableSelectList(items, 12, selectListTheme, {
221
372
  minPrimaryColumnWidth: 18,
222
- maxPrimaryColumnWidth: 22,
373
+ maxPrimaryColumnWidth: 24,
374
+ formatHeader: (q, matchCount, total) => brand.dim(q.length > 0
375
+ ? ` search: ${q}▌ (${matchCount}/${total} match${matchCount === 1 ? "" : "es"})`
376
+ : ` ${total} providers · type to filter · ↑↓ move · Enter select · Esc back`),
223
377
  });
224
378
  tui.addChild(list);
225
379
  tui.setFocus(list);
@@ -285,8 +439,8 @@ export async function ensureApiKey(tui, authStorage, providerId, opts = {}) {
285
439
  // OPENROUTER_API_KEY, sk-o…52b5)?`. Single line, default = Yes.
286
440
  // No explanatory paragraphs.
287
441
  const envVar = provider.envVar ?? "the env var";
288
- renderScreen(tui, `Step 2 of 5 · ${provider.name}`);
289
- tui.addChild(new Text(` ${brand.amber("?")} Use existing ${envVar} (env: ${envVar}, ${formatApiKeyPreview(envKey)})?`, 0, 0));
442
+ renderScreen(tui, `Step 3 of 5 · ${provider.name}`);
443
+ tui.addChild(new Text(` ${brand.amber("?")} We found a saved ${provider.name} key on this computer (${formatApiKeyPreview(envKey)}). Use it?`, 0, 0));
290
444
  tui.addChild(new Text("", 0, 0));
291
445
  const confirmList = new SelectList([
292
446
  { value: "yes", label: "Yes" },
@@ -313,13 +467,13 @@ export async function ensureApiKey(tui, authStorage, providerId, opts = {}) {
313
467
  // and let them paste their own. The typed-key loop below treats
314
468
  // `lastError === null` as a clean first iteration, so no stale
315
469
  // error text leaks in.
316
- renderScreen(tui, `Step 2 of 5 · ${provider.name}`);
470
+ renderScreen(tui, `Step 3 of 5 · ${provider.name}`);
317
471
  // Fall through to typed-key loop without `lastError` set.
318
472
  // (Variable declared just below the env block.)
319
473
  return await promptTypedKey(tui, authStorage, provider, providerId, null);
320
474
  }
321
475
  // User confirmed — verify the env key actually works before accepting.
322
- tui.addChild(new Text(` ${brand.dim(`Verifying ${envVar} with ${provider.name}…`)}`, 0, 0));
476
+ tui.addChild(new Text(` ${brand.dim(`Checking your ${provider.name} key…`)}`, 0, 0));
323
477
  const envLoader = new CancellableLoader(tui, (s) => brand.amber(s), (s) => brand.dim(s), `Verifying ${provider.name}…`);
324
478
  tui.addChild(envLoader);
325
479
  tui.requestRender();
@@ -372,7 +526,7 @@ export async function ensureApiKey(tui, authStorage, providerId, opts = {}) {
372
526
  });
373
527
  authStorage.set(providerId, { type: "api_key", key: envKey });
374
528
  }
375
- const pinShape = mode === "ref" ? "your environment (kept as a reference)" : "your environment";
529
+ const pinShape = mode === "ref" ? "the key already on this computer" : "your saved key";
376
530
  tui.addChild(new Text(` ${brand.amber("✓")} ${provider.name} is already connected (using ${brand.white(pinShape)}).`, 0, 0));
377
531
  tui.requestRender();
378
532
  await delay(600);
@@ -382,7 +536,7 @@ export async function ensureApiKey(tui, authStorage, providerId, opts = {}) {
382
536
  // skip — drop into the typed-key path with the failure seeded so the
383
537
  // user immediately sees WHY their env key didn't work and can paste a
384
538
  // fresh one.
385
- const staleReason = `The ${provider.envVar ?? "env var"} for ${provider.name} doesn't work: ${envCheck.reason}`;
539
+ const staleReason = `That saved ${provider.name} key didn't work: ${envCheck.reason}`;
386
540
  return await promptTypedKey(tui, authStorage, provider, providerId, staleReason);
387
541
  }
388
542
  return await promptTypedKey(tui, authStorage, provider, providerId, null);
@@ -404,7 +558,7 @@ async function promptTypedKey(tui, authStorage, provider, providerId, seedError)
404
558
  // stale loader frames don't pile up vertically.
405
559
  let lastError = seedError;
406
560
  while (true) {
407
- renderScreen(tui, `Step 2 of 5 · ${provider.name}`);
561
+ renderScreen(tui, `Step 3 of 5 · ${provider.name}`);
408
562
  if (lastError) {
409
563
  tui.addChild(new Text(` ${brand.error("✗")} ${brand.error(lastError)}`, 0, 0));
410
564
  tui.addChild(new Text(brand.dim(" Press Enter to try again, or Esc to choose a different provider."), 0, 0));
@@ -421,15 +575,15 @@ async function promptTypedKey(tui, authStorage, provider, providerId, seedError)
421
575
  let key;
422
576
  try {
423
577
  key = await new Promise((resolve, reject) => {
424
- input.onSubmit = (value) => resolve(value.trim());
578
+ input.onSubmit = (value) => resolve(sanitizePastedValue(value));
425
579
  input.onEscape = () => reject(new Error("back"));
426
580
  });
427
581
  }
428
582
  catch {
429
583
  return "back"; // user pressed Esc — caller rewinds to provider picker
430
584
  }
431
- // Step 1: cheap local format check (length, whitespace, prefix).
432
- const localCheck = validateApiKey(providerId, key);
585
+ // Step 1: cheap, format-agnostic sanity check (length, whitespace only).
586
+ const localCheck = validateApiKey(key);
433
587
  if (!localCheck.ok) {
434
588
  lastError = localCheck.reason;
435
589
  continue;
@@ -473,39 +627,22 @@ async function promptTypedKey(tui, authStorage, provider, providerId, seedError)
473
627
  }
474
628
  }
475
629
  /**
476
- * Cheap, provider-aware sanity check on the pasted key.
477
- * Catches obvious mistakes (empty, accidental newline, wrong provider's key)
478
- * BEFORE we persist garbage to disk. Doesn't validate against the real API —
479
- * that happens implicitly on the first model call.
630
+ * Cheap, provider-AGNOSTIC sanity check on the pasted key.
631
+ * Catches only the universal mistakes (empty, obviously truncated, stray
632
+ * whitespace/newline) BEFORE we persist garbage to disk. It deliberately does
633
+ * NOT guess at per-provider key prefixes: key formats change over time (e.g.
634
+ * Google now issues both "AIza…" and "AQ.…" Gemini keys), and hard-coding the
635
+ * expected letters wrongly rejects perfectly valid keys. Whether the key is
636
+ * actually accepted is decided *dynamically* by `validateApiKeyOnline`, which
637
+ * fires a real request at the provider and judges by the live response.
480
638
  */
481
- function validateApiKey(providerId, key) {
639
+ function validateApiKey(key) {
482
640
  if (!key)
483
641
  return { ok: false, reason: "Please enter an API key." };
484
642
  if (key.length < 16)
485
643
  return { ok: false, reason: `That looks incomplete (only ${key.length} characters). Try copying the key again.` };
486
644
  if (/\s/.test(key))
487
645
  return { ok: false, reason: "The key has extra spaces or line breaks. Copy just the key value." };
488
- // Provider-specific prefix hints. Hard reject only when we have a stable, well-known prefix
489
- // for that provider — we never want to block someone with a freshly-rotated format.
490
- // For providers without a stable prefix (cerebras, mistral) we fall through to length+whitespace
491
- // only, which is intentional.
492
- const prefixHints = {
493
- anthropic: "sk-ant-",
494
- openai: "sk-",
495
- google: "AIza", // Google API keys (Gemini Studio) all start with AIza
496
- groq: "gsk_",
497
- openrouter: "sk-or-",
498
- xai: "xai-",
499
- deepseek: "sk-", // DeepSeek mirrors OpenAI's prefix convention
500
- };
501
- const expected = prefixHints[providerId];
502
- const providerName = findProvider(providerId)?.name ?? providerId;
503
- if (expected && !key.startsWith(expected)) {
504
- return {
505
- ok: false,
506
- reason: `That doesn't look like a ${providerName} key (${providerName} keys start with "${expected}"). Make sure you picked the right provider.`,
507
- };
508
- }
509
646
  return { ok: true };
510
647
  }
511
648
  /**
@@ -522,7 +659,7 @@ function validateApiKey(providerId, key) {
522
659
  async function ensureLocalOllama(tui, modelRegistry, baseUrl) {
523
660
  let lastError = null;
524
661
  while (true) {
525
- renderScreen(tui, "Step 2 of 5 · Connect Ollama");
662
+ renderScreen(tui, "Step 3 of 5 · Connect Ollama");
526
663
  if (lastError) {
527
664
  tui.addChild(new Text(` ${brand.error("✗")} ${brand.error(lastError)}`, 0, 0));
528
665
  tui.addChild(new Text(brand.dim(" Press Enter to try again, or Esc to choose a different provider."), 0, 0));
@@ -576,6 +713,385 @@ async function ensureLocalOllama(tui, modelRegistry, baseUrl) {
576
713
  return "ok";
577
714
  }
578
715
  }
716
+ /**
717
+ * Subscription-login variant of `ensureApiKey`. For providers that carry a
718
+ * `subscription` descriptor (Anthropic, OpenAI Codex, GitHub Copilot) we run
719
+ * Pi's OAuth login flow instead of asking for an API key:
720
+ * 1. Confirm with the user (Enter to start the browser flow, Esc to go back).
721
+ * 2. Drive `oauthProvider.login(...)` — Pi does NOT open the browser, so our
722
+ * `onAuth` callback does (best-effort, per-platform).
723
+ * 3. On success, persist the returned credential to BOTH credential stores
724
+ * (auth-profiles.json via `upsertOAuthProfile` + auth.json via
725
+ * `authStorage.set`) so the wizard process and every future boot can use it.
726
+ *
727
+ * Modeled on `ensureLocalOllama`: a retry `while(true)` loop with a `lastError`
728
+ * line and Esc → "back". Any thrown error (including the user aborting the
729
+ * flow) is caught and surfaced inline so the user can retry or pick a different
730
+ * provider.
731
+ */
732
+ export async function ensureSubscriptionLogin(tui, authStorage, provider) {
733
+ const sub = provider.subscription;
734
+ const oauthProvider = getOAuthProvider(sub.oauthProviderId);
735
+ if (!oauthProvider) {
736
+ // Pi build doesn't know this provider — fail cleanly back to the picker
737
+ // rather than crashing the wizard.
738
+ renderScreen(tui, `Step 3 of 5 · ${provider.name}`);
739
+ tui.addChild(new Text(` ${brand.error("✗")} ${brand.error(`${provider.name} sign-in isn't supported yet.`)}`, 0, 0));
740
+ tui.addChild(new Text(brand.dim(" Taking you back to choose another provider…"), 0, 0));
741
+ tui.requestRender();
742
+ await delay(900);
743
+ return "back";
744
+ }
745
+ let lastError = null;
746
+ while (true) {
747
+ renderScreen(tui, `Step 3 of 5 · ${provider.name}`);
748
+ if (lastError) {
749
+ tui.addChild(new Text(` ${brand.error("✗")} ${brand.error(lastError)}`, 0, 0));
750
+ tui.addChild(new Text(brand.dim(" Press Enter to try again, or Esc to choose a different provider."), 0, 0));
751
+ tui.addChild(new Text("", 0, 0));
752
+ }
753
+ tui.addChild(new Text(` ${brand.white(sub.label)}`, 0, 0));
754
+ tui.addChild(new Text(brand.dim(" We'll open your browser to sign in. Approve it there — we'll wait."), 0, 0));
755
+ tui.addChild(new Text(brand.dim(" Enter to start · Esc to go back"), 0, 0));
756
+ // Confirm-gate (mirrors ensureLocalOllama) so the user can Esc out BEFORE
757
+ // the browser opens. Just hits Enter to proceed, or Esc to rewind.
758
+ const confirm = new Input();
759
+ tui.addChild(confirm);
760
+ tui.setFocus(confirm);
761
+ tui.requestRender();
762
+ try {
763
+ await new Promise((resolve, reject) => {
764
+ confirm.onSubmit = () => resolve();
765
+ confirm.onEscape = () => reject(new Error("back"));
766
+ });
767
+ }
768
+ catch {
769
+ return "back";
770
+ }
771
+ tui.removeChild(confirm);
772
+ // Drive the OAuth flow. `controller` lets the callbacks (and an Esc) abort
773
+ // the in-flight login; Pi honors `signal` across its loopback wait.
774
+ const controller = new AbortController();
775
+ let creds;
776
+ try {
777
+ creds = await oauthProvider.login({
778
+ // Pi does NOT open the browser — we do. onAuth is fire-and-forget
779
+ // (void), so don't await here; just kick the browser + show the URL
780
+ // and a waiting spinner.
781
+ onAuth: (info) => {
782
+ tui.addChild(new Text(` ${brand.amber("→")} Opening your browser to sign in…`, 0, 0));
783
+ openSubscriptionBrowser(info.url);
784
+ tui.addChild(new Text("", 0, 0));
785
+ tui.addChild(new Text(" " + brand.amber(info.url), 0, 0));
786
+ tui.addChild(new Text(brand.dim(" If your browser didn't open, copy the link above. Paste the code here if asked."), 0, 0));
787
+ if (info.instructions)
788
+ tui.addChild(new Text(brand.dim(" " + info.instructions), 0, 0));
789
+ // Wire Escape to abort the in-flight login. The loader only
790
+ // receives key input (handleInput) while it holds focus, so set
791
+ // focus AND point onAbort at the login controller.
792
+ const waitLoaderAuth = new CancellableLoader(tui, (s) => brand.amber(s), (s) => brand.dim(s), "Waiting for you to authorize…");
793
+ waitLoaderAuth.onAbort = () => controller.abort();
794
+ tui.addChild(waitLoaderAuth);
795
+ tui.setFocus(waitLoaderAuth);
796
+ tui.requestRender();
797
+ },
798
+ // Loopback-callback providers (anthropic, openai-codex) also let the
799
+ // user paste the redirect URL / code by hand — this races the local
800
+ // callback server internally. Resolve from an Input; Esc rejects to
801
+ // abort the whole login.
802
+ onManualCodeInput: () => new Promise((resolve, reject) => {
803
+ tui.addChild(new Text("", 0, 0));
804
+ tui.addChild(new Text(brand.dim(" Paste the code or redirect URL, then press Enter · Esc to cancel"), 0, 0));
805
+ const input = new Input();
806
+ tui.addChild(input);
807
+ tui.setFocus(input);
808
+ tui.requestRender();
809
+ input.onSubmit = (value) => resolve(sanitizePastedValue(value));
810
+ input.onEscape = () => reject(new Error("cancelled"));
811
+ }),
812
+ // Device-code providers (github-copilot): show the verification URL +
813
+ // user code, plus a waiting spinner while Pi polls for completion.
814
+ onDeviceCode: (info) => {
815
+ openSubscriptionBrowser(info.verificationUri);
816
+ tui.addChild(new Text("", 0, 0));
817
+ tui.addChild(new Text(` Go to ${brand.amber(info.verificationUri)} and enter code: ${brand.amber(info.userCode)}`, 0, 0));
818
+ tui.addChild(new Text(brand.dim(" If your browser didn't open, copy the link above."), 0, 0));
819
+ // Wire Escape to abort the in-flight login (device-code path). The
820
+ // loader only receives key input while focused, so set focus AND
821
+ // point onAbort at the login controller.
822
+ const waitLoaderDevice = new CancellableLoader(tui, (s) => brand.amber(s), (s) => brand.dim(s), "Waiting for you to authorize…");
823
+ waitLoaderDevice.onAbort = () => controller.abort();
824
+ tui.addChild(waitLoaderDevice);
825
+ tui.setFocus(waitLoaderDevice);
826
+ tui.requestRender();
827
+ },
828
+ // Free-form prompt (rare). Render the message + an Input; honor
829
+ // allowEmpty so a blank submit is accepted when the provider allows it.
830
+ onPrompt: (p) => new Promise((resolve, reject) => {
831
+ tui.addChild(new Text("", 0, 0));
832
+ tui.addChild(new Text(` ${p.message}`, 0, 0));
833
+ const input = new Input();
834
+ tui.addChild(input);
835
+ tui.setFocus(input);
836
+ tui.requestRender();
837
+ input.onSubmit = (value) => {
838
+ const v = value.trim();
839
+ if (!v && !p.allowEmpty)
840
+ return; // keep waiting for a value
841
+ resolve(v);
842
+ };
843
+ input.onEscape = () => reject(new Error("cancelled"));
844
+ }),
845
+ // Best-effort progress line.
846
+ onProgress: (msg) => {
847
+ tui.addChild(new Text(brand.dim(" " + msg), 0, 0));
848
+ tui.requestRender();
849
+ },
850
+ // REQUIRED for openai-codex: it calls onSelect FIRST (browser vs
851
+ // device-code) and throws if absent. Render a SelectList; resolve the
852
+ // chosen id, or undefined on cancel.
853
+ onSelect: (p) => new Promise((resolve) => {
854
+ tui.addChild(new Text("", 0, 0));
855
+ tui.addChild(new Text(` ${p.message}`, 0, 0));
856
+ const list = new SelectList(p.options.map((o) => ({ value: o.id, label: o.label })), Math.min(p.options.length, 6), selectListTheme, { minPrimaryColumnWidth: 12, maxPrimaryColumnWidth: 28 });
857
+ tui.addChild(list);
858
+ tui.setFocus(list);
859
+ tui.requestRender();
860
+ list.onSelect = (item) => resolve(item.value);
861
+ list.onCancel = () => resolve(undefined);
862
+ }),
863
+ signal: controller.signal,
864
+ });
865
+ }
866
+ catch (err) {
867
+ controller.abort();
868
+ const reason = err instanceof Error ? err.message : String(err);
869
+ // "cancelled" / "back" are user-initiated aborts — surface a soft line
870
+ // and let them retry; anything else is a real failure. NEVER render
871
+ // the raw Pi reason (it can leak a URL / status / internal detail) —
872
+ // map it to a friendly, generic line. The soft-cancel branch also
873
+ // matches Pi's "Login cancelled" wording so a user abort reads clean.
874
+ const softCancel = /^login cancelled$/i.test(reason) || reason === "cancelled" || reason === "back";
875
+ lastError = softCancel
876
+ ? "Login cancelled. Start again, or pick a different provider."
877
+ : "We couldn't finish signing you in. Check your connection and try again.";
878
+ continue;
879
+ }
880
+ // Persist to BOTH stores (NOT authStorage.login, which would only write
881
+ // auth.json): auth-profiles.json is the canonical credential store the
882
+ // agent boots from; auth.json is Pi's in-process mirror so the model
883
+ // picker that runs next can use the token immediately.
884
+ // Resolve to the real Pi provider for storage — e.g. the "claude-code"
885
+ // entry stores its OAuth credential under "anthropic".
886
+ const providerId = provider.providerId ?? provider.id;
887
+ // Preserve provider-specific extras the login returned — notably GitHub
888
+ // Copilot's `availableModelIds` (the exact models THIS account's plan
889
+ // enabled), which Pi's `modifyModels` uses to filter the model menu. Hand
890
+ // the whole credential to Pi's in-memory store and stash the extras in the
891
+ // profile metadata so they survive a reboot.
892
+ const { access, refresh, expires, ...extras } = creds;
893
+ upsertOAuthProfile(DEFAULT_AGENT_ID, {
894
+ provider: providerId,
895
+ access,
896
+ refresh,
897
+ expires,
898
+ metadata: Object.keys(extras).length > 0 ? extras : undefined,
899
+ });
900
+ authStorage.set(providerId, { type: "oauth", ...creds });
901
+ authStorage.reload();
902
+ // Warm the live model cache with THIS account's current models so the
903
+ // model picker (next step) shows exactly what the subscription enables,
904
+ // not just Pi's bundled snapshot. Best-effort — `prefetchSubscriptionModels`
905
+ // swallows its own errors, and the picker falls back to the catalog if the
906
+ // cache is empty (codex, or an unauthorized/failed fetch).
907
+ try {
908
+ await prefetchSubscriptionModels(providerId, creds.access);
909
+ }
910
+ catch {
911
+ /* best-effort — picker falls back to the catalog */
912
+ }
913
+ tui.addChild(new Text("", 0, 0));
914
+ tui.addChild(new Text(` ${brand.amber("✓")} ${provider.name} connected.`, 0, 0));
915
+ tui.requestRender();
916
+ await delay(600);
917
+ return "ok";
918
+ }
919
+ }
920
+ /**
921
+ * Connect a subscription provider that ALSO has a vendor CLI login on disk
922
+ * (Claude Code / Codex). When such a login exists we present a choice that LEADS
923
+ * with browser sign-in — the right default when several people each use their own
924
+ * account — and offers reusing this machine's existing login as the convenience
925
+ * second option.
926
+ *
927
+ * Returns:
928
+ * - "ok" → reused the on-disk CLI login and persisted it
929
+ * - "back" → user pressed Esc; caller rewinds to the provider picker
930
+ * - "other" → no CLI login present, OR the user chose browser sign-in; caller
931
+ * falls through to the subscription (browser OAuth) / key path
932
+ */
933
+ async function ensureCliLogin(tui, authStorage, provider) {
934
+ const cred = provider.cliLogin.read === "claude" ? readClaudeCliLogin() : readCodexCliLogin();
935
+ if (!cred)
936
+ return "other"; // no CLI login present on this machine
937
+ // A login is already on this machine — but LEAD with browser sign-in: it works
938
+ // for ANY account, which is what you want when different people each use their
939
+ // own subscription. Reuse is the second, convenience option.
940
+ renderScreen(tui, `Step 3 of 5 · ${provider.name}`);
941
+ tui.addChild(new Text(` ${brand.amber(`How do you want to connect ${provider.name}?`)}`, 0, 0));
942
+ tui.addChild(new Text("", 0, 0));
943
+ const choiceList = new SelectList([
944
+ { value: "login", label: "Log in with your account", description: "Opens your browser — works for any account" },
945
+ { value: "reuse", label: "Reuse this machine's login", description: "The account already signed in here" },
946
+ ], 2, selectListTheme, { minPrimaryColumnWidth: 26, maxPrimaryColumnWidth: 32 });
947
+ tui.addChild(choiceList);
948
+ tui.setFocus(choiceList);
949
+ tui.requestRender();
950
+ let choice;
951
+ try {
952
+ const picked = await new Promise((resolve, reject) => {
953
+ choiceList.onSelect = (item) => resolve(item.value);
954
+ choiceList.onCancel = () => reject(new Error("back"));
955
+ });
956
+ choice = picked === "reuse" ? "reuse" : "other";
957
+ }
958
+ catch {
959
+ return "back";
960
+ }
961
+ // Browser sign-in → caller runs the subscription (OAuth) login next.
962
+ if (choice === "other")
963
+ return "other";
964
+ // Reuse path — persist to BOTH stores (auth-profiles.json is canonical; the
965
+ // authStorage mirror lets the wizard's model picker use the credential now).
966
+ if (cred.type === "oauth") {
967
+ upsertOAuthProfile(DEFAULT_AGENT_ID, {
968
+ provider: cred.provider,
969
+ access: cred.access,
970
+ refresh: cred.refresh,
971
+ expires: cred.expires,
972
+ });
973
+ authStorage.set(cred.provider, {
974
+ type: "oauth",
975
+ access: cred.access,
976
+ refresh: cred.refresh,
977
+ // Pi's in-memory oauth shape requires a numeric `expires`. When the CLI
978
+ // file carried no expiry, seed 0 so Pi treats the access token as
979
+ // expired and refreshes via the refresh token on first use. The durable
980
+ // profile (above) keeps the real value (possibly undefined).
981
+ expires: cred.expires ?? 0,
982
+ });
983
+ }
984
+ else {
985
+ // Durable store keeps the type:"token" shape (upsertTokenProfile). Mirror
986
+ // it into Pi's in-memory store as type:"oauth" for shape-consistency with
987
+ // the refresh-capable path — a Claude credential with an access token but
988
+ // no refresh token. expires:0 makes Pi treat the access token as expired
989
+ // and refresh on first use when a refresh token later exists.
990
+ upsertTokenProfile(DEFAULT_AGENT_ID, { provider: cred.provider, token: cred.token });
991
+ // Pi's in-memory OAuthCredential requires a `refresh` string; this CLI
992
+ // credential carries no refresh token, so seed "".
993
+ authStorage.set(cred.provider, { type: "oauth", access: cred.token, refresh: "", expires: cred.expires ?? 0 });
994
+ }
995
+ authStorage.reload();
996
+ // Warm the live model cache (best-effort — picker falls back to the catalog).
997
+ try {
998
+ await prefetchSubscriptionModels(cred.provider, cred.type === "oauth" ? cred.access : cred.token);
999
+ }
1000
+ catch {
1001
+ /* best-effort */
1002
+ }
1003
+ tui.addChild(new Text("", 0, 0));
1004
+ tui.addChild(new Text(` ${brand.amber("✓")} ${provider.name} connected (using your existing login).`, 0, 0));
1005
+ tui.requestRender();
1006
+ await delay(600);
1007
+ return "ok";
1008
+ }
1009
+ /**
1010
+ * Custom (catalog-defined) provider entry. For providers that carry a key + a
1011
+ * known Anthropic-compatible endpoint (GLM, Kimi, Qwen, MiniMax, DeepSeek): ask
1012
+ * for the key, persist it, register the endpoint + catalog models into
1013
+ * models.json, then refresh the registry so the model picker sees them.
1014
+ *
1015
+ * Returns:
1016
+ * - "ok" → key saved, provider registered
1017
+ * - "back" → user pressed Esc; caller rewinds to the provider picker
1018
+ */
1019
+ async function ensureCustomProvider(tui, authStorage, modelRegistry, provider) {
1020
+ let lastError = null;
1021
+ while (true) {
1022
+ renderScreen(tui, `Step 3 of 5 · ${provider.name}`);
1023
+ if (lastError) {
1024
+ tui.addChild(new Text(` ${brand.error("✗")} ${brand.error(lastError)}`, 0, 0));
1025
+ tui.addChild(new Text(brand.dim(" Press Enter to try again, or Esc to choose a different provider."), 0, 0));
1026
+ tui.addChild(new Text("", 0, 0));
1027
+ }
1028
+ tui.addChild(new Text(` Paste your ${provider.name} key.`, 0, 0));
1029
+ tui.addChild(new Text(brand.dim(` Get one at ${provider.keyUrl}`), 0, 0));
1030
+ tui.addChild(new Text(brand.dim(" Enter to continue · Esc to go back"), 0, 0));
1031
+ tui.addChild(new Text("", 0, 0));
1032
+ const input = new Input();
1033
+ tui.addChild(input);
1034
+ tui.setFocus(input);
1035
+ tui.requestRender();
1036
+ let key;
1037
+ try {
1038
+ key = await new Promise((resolve, reject) => {
1039
+ input.onSubmit = (value) => resolve(sanitizePastedValue(value));
1040
+ input.onEscape = () => reject(new Error("back"));
1041
+ });
1042
+ }
1043
+ catch {
1044
+ return "back";
1045
+ }
1046
+ // Empty submit — show a one-line hint instead of silently re-rendering.
1047
+ if (!key) {
1048
+ lastError = "Please enter an API key.";
1049
+ continue;
1050
+ }
1051
+ // Cheap, format-agnostic sanity check (length / whitespace). Custom
1052
+ // endpoints vary, so we do NOT online-validate here — but an obviously
1053
+ // malformed key is rejected with feedback rather than persisted.
1054
+ const localCheck = validateApiKey(key);
1055
+ if (!localCheck.ok) {
1056
+ lastError = localCheck.reason;
1057
+ continue;
1058
+ }
1059
+ upsertApiKeyProfile(DEFAULT_AGENT_ID, { provider: provider.id, key });
1060
+ authStorage.set(provider.id, { type: "api_key", key });
1061
+ authStorage.reload();
1062
+ await writeCustomProviderToModelsJson(resolveModelsPath(DEFAULT_AGENT_ID), {
1063
+ id: provider.id,
1064
+ baseUrl: provider.baseUrl,
1065
+ api: provider.api,
1066
+ apiKey: key,
1067
+ models: provider.models ?? [],
1068
+ });
1069
+ modelRegistry.refresh();
1070
+ tui.addChild(new Text(` ${brand.amber("✓")} ${provider.name} connected.`, 0, 0));
1071
+ tui.requestRender();
1072
+ await delay(500);
1073
+ return "ok";
1074
+ }
1075
+ }
1076
+ /**
1077
+ * Best-effort open the system browser at `url`. Detached + unref'd so the child
1078
+ * never keeps the wizard process alive, and every error is swallowed — a failed
1079
+ * launch just means the user copies the URL we printed above. NOT exported;
1080
+ * only the subscription-login flow uses it.
1081
+ */
1082
+ function openSubscriptionBrowser(url) {
1083
+ try {
1084
+ const child = process.platform === "win32"
1085
+ ? spawn("rundll32", ["url.dll,FileProtocolHandler", url], { detached: true, stdio: "ignore" })
1086
+ : process.platform === "darwin"
1087
+ ? spawn("open", [url], { detached: true, stdio: "ignore" })
1088
+ : spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
1089
+ child.unref();
1090
+ }
1091
+ catch {
1092
+ // Couldn't launch a browser — the URL is already on screen for manual use.
1093
+ }
1094
+ }
579
1095
  async function pickModel(tui, modelRegistry, providerId) {
580
1096
  const models = await getProviderModels(modelRegistry, providerId);
581
1097
  if (models.length === 0) {
@@ -586,7 +1102,7 @@ async function pickModel(tui, modelRegistry, providerId) {
586
1102
  tui.requestRender();
587
1103
  try {
588
1104
  const id = await new Promise((resolve, reject) => {
589
- input.onSubmit = (value) => resolve(value.trim());
1105
+ input.onSubmit = (value) => resolve(sanitizePastedValue(value));
590
1106
  input.onEscape = () => reject(new Error("back"));
591
1107
  });
592
1108
  return { modelId: id };