gearbox-code 0.1.38 → 0.2.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 (2) hide show
  1. package/dist/cli.mjs +1543 -580
  2. package/package.json +1 -1
package/dist/cli.mjs CHANGED
@@ -105916,6 +105916,7 @@ var init_dist20 = __esm(() => {
105916
105916
  // src/accounts/store.ts
105917
105917
  var exports_store = {};
105918
105918
  __export(exports_store, {
105919
+ uniqueSlug: () => uniqueSlug,
105919
105920
  setSecret: () => setSecret,
105920
105921
  setDefaultAccount: () => setDefaultAccount,
105921
105922
  secretRefs: () => secretRefs,
@@ -106061,9 +106062,26 @@ function accountsForProvider(provider) {
106061
106062
  function getAccount(id) {
106062
106063
  return listAccounts().find((a) => a.id === id);
106063
106064
  }
106065
+ function uniqueSlug(base2, taken) {
106066
+ const norm = base2.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "account";
106067
+ if (!taken.includes(norm))
106068
+ return norm;
106069
+ for (let n = 2;; n++) {
106070
+ const cand = `${norm}-${n}`;
106071
+ if (!taken.includes(cand))
106072
+ return cand;
106073
+ }
106074
+ }
106075
+ function deriveSlugBase(a) {
106076
+ return a.label;
106077
+ }
106064
106078
  function putAccount(account) {
106065
106079
  const f3 = loadAccounts();
106066
106080
  const i2 = f3.accounts.findIndex((a) => a.id === account.id);
106081
+ if (!account.slug) {
106082
+ const taken = f3.accounts.filter((a) => a.id !== account.id).map((a) => a.slug ?? "").filter(Boolean);
106083
+ account.slug = i2 >= 0 && f3.accounts[i2].slug || uniqueSlug(deriveSlugBase(account), taken);
106084
+ }
106067
106085
  if (i2 >= 0)
106068
106086
  f3.accounts[i2] = account;
106069
106087
  else
@@ -106251,6 +106269,32 @@ function modelRegistry() {
106251
106269
  }
106252
106270
  return out;
106253
106271
  }
106272
+ function subscriptionSeats() {
106273
+ const out = [];
106274
+ for (const a of listAccounts()) {
106275
+ if (!a.enabled || a.exec !== "cli")
106276
+ continue;
106277
+ const binary = (a.auth.kind === "cli" ? a.auth.binary : undefined) ?? catalogProvider(a.provider)?.binary;
106278
+ if (!binary)
106279
+ continue;
106280
+ const profile = a.auth.kind === "cli" ? a.auth.loginProfile : undefined;
106281
+ const sdkIds = a.models ?? catalogProvider(a.provider)?.defaultModels ?? [];
106282
+ for (const sdkId of sdkIds) {
106283
+ if (!sdkId)
106284
+ continue;
106285
+ const canon = CURATED.find((c) => c.sdkId === sdkId && NATIVE.has(c.provider));
106286
+ const spec6 = {
106287
+ ...canon ?? { contextWindow: 200000 },
106288
+ id: `cli:${a.id}:${sdkId}`,
106289
+ provider: `cli:${binary}`,
106290
+ sdkId,
106291
+ label: canon?.label ?? sdkId
106292
+ };
106293
+ out.push({ spec: spec6, canonicalId: canon?.id, account: a, binary, profile });
106294
+ }
106295
+ }
106296
+ return out;
106297
+ }
106254
106298
  function envVarFor(provider) {
106255
106299
  return ENV_KEY[provider] ?? catalogProvider(provider)?.envVars[0];
106256
106300
  }
@@ -111932,8 +111976,8 @@ function isIPv4(hostname2) {
111932
111976
  if (parts.length !== 4)
111933
111977
  return false;
111934
111978
  return parts.every((part) => {
111935
- const num = Number(part);
111936
- return Number.isInteger(num) && num >= 0 && num <= 255 && String(num) === part;
111979
+ const num2 = Number(part);
111980
+ return Number.isInteger(num2) && num2 >= 0 && num2 <= 255 && String(num2) === part;
111937
111981
  });
111938
111982
  }
111939
111983
  function isPrivateIPv4(ip) {
@@ -129271,6 +129315,11 @@ var init_mcp = __esm(() => {
129271
129315
  init_permission();
129272
129316
  });
129273
129317
 
129318
+ // src/model/family.ts
129319
+ var init_family = __esm(() => {
129320
+ init_providers();
129321
+ });
129322
+
129274
129323
  // src/accounts/resolve.ts
129275
129324
  async function resolveCreds(account) {
129276
129325
  const auth = account.auth;
@@ -129310,15 +129359,10 @@ async function resolveCreds(account) {
129310
129359
  }
129311
129360
  return {};
129312
129361
  }
129313
-
129314
- class AccountResolver {
129315
- pick(provider) {
129316
- return defaultAccount(provider);
129317
- }
129318
- }
129319
129362
  var init_resolve = __esm(() => {
129320
129363
  init_store();
129321
129364
  init_catalog();
129365
+ init_family();
129322
129366
  });
129323
129367
 
129324
129368
  // src/accounts/detect.ts
@@ -129537,6 +129581,89 @@ var init_onboarding = __esm(() => {
129537
129581
  ];
129538
129582
  });
129539
129583
 
129584
+ // src/accounts/sniff.ts
129585
+ function sniffCredential(text2) {
129586
+ const t2 = text2.trim();
129587
+ if (/^\s*\{/.test(t2) && /"type"\s*:\s*"service_account"/.test(t2)) {
129588
+ try {
129589
+ const j = JSON.parse(t2);
129590
+ return {
129591
+ kind: "vertex",
129592
+ provider: "vertex",
129593
+ fields: { project: j.project_id ?? "", serviceAccountJson: t2 },
129594
+ missing: j.project_id ? ["location"] : ["project", "location"],
129595
+ confidence: "high"
129596
+ };
129597
+ } catch {
129598
+ return {
129599
+ kind: "vertex",
129600
+ provider: "vertex",
129601
+ fields: { serviceAccountJson: t2 },
129602
+ missing: ["project", "location"],
129603
+ confidence: "low"
129604
+ };
129605
+ }
129606
+ }
129607
+ const azure2 = t2.match(/https?:\/\/([a-z0-9-]+)\.(?:openai\.azure\.com|cognitiveservices\.azure\.com|services\.ai\.azure\.com)/i);
129608
+ if (azure2) {
129609
+ return {
129610
+ kind: "azure",
129611
+ provider: "azure",
129612
+ fields: { resourceName: azure2[1], endpoint: t2 },
129613
+ missing: ["apiKey"],
129614
+ confidence: "high"
129615
+ };
129616
+ }
129617
+ if (/aws_access_key_id\s*=/.test(t2) || AWS_KEY_RE.test(t2) && /aws_secret_access_key|secret/i.test(t2)) {
129618
+ const id = t2.match(AWS_KEY_RE)?.[1] ?? "";
129619
+ const secret = t2.match(/aws_secret_access_key\s*=\s*([A-Za-z0-9/+=]+)/i)?.[1] ?? "";
129620
+ const region = t2.match(/(?:aws_)?region\s*=\s*([a-z0-9-]+)/i)?.[1] ?? "";
129621
+ const missing = [];
129622
+ if (!secret)
129623
+ missing.push("secretAccessKey");
129624
+ if (!region)
129625
+ missing.push("region");
129626
+ return {
129627
+ kind: "aws",
129628
+ provider: "bedrock",
129629
+ fields: { accessKeyId: id, secretAccessKey: secret, region },
129630
+ missing,
129631
+ confidence: "high"
129632
+ };
129633
+ }
129634
+ const awsId = t2.match(/^((?:AKIA|ASIA)[A-Z0-9]{16})$/)?.[1];
129635
+ if (awsId) {
129636
+ return {
129637
+ kind: "aws",
129638
+ provider: "bedrock",
129639
+ fields: { accessKeyId: awsId },
129640
+ missing: ["secretAccessKey", "region"],
129641
+ confidence: "high"
129642
+ };
129643
+ }
129644
+ if (/^vck_/.test(t2)) {
129645
+ return {
129646
+ kind: "openai-compat",
129647
+ provider: "vercel-gateway",
129648
+ fields: { apiKey: t2 },
129649
+ missing: [],
129650
+ confidence: "high"
129651
+ };
129652
+ }
129653
+ const provider = detectProviderByKey(t2);
129654
+ if (provider) {
129655
+ const cat = catalogProvider(provider);
129656
+ const kind = cat?.authKind === "openai-compat" ? "openai-compat" : "api-key";
129657
+ return { kind, provider, fields: { apiKey: t2 }, missing: [], confidence: "high" };
129658
+ }
129659
+ return { kind: "unknown", fields: { apiKey: t2 }, missing: ["provider"], confidence: "low" };
129660
+ }
129661
+ var AWS_KEY_RE;
129662
+ var init_sniff = __esm(() => {
129663
+ init_catalog();
129664
+ AWS_KEY_RE = /\b((?:AKIA|ASIA)[A-Z0-9]{16})\b/;
129665
+ });
129666
+
129540
129667
  // src/agent/cli-backend.ts
129541
129668
  var exports_cli_backend = {};
129542
129669
  __export(exports_cli_backend, {
@@ -129725,13 +129852,22 @@ async function runCliTask(opts) {
129725
129852
  const { binary, prompt, messages, onEvent, signal } = opts;
129726
129853
  const args = buildCliArgs(binary, prompt, { sessionId: opts.sessionId, autoApprove: opts.autoApprove, modelId: opts.modelId, effort: opts.effort });
129727
129854
  const state = newState();
129855
+ let failureMessage;
129856
+ const fail = (message) => {
129857
+ if (failureMessage)
129858
+ return;
129859
+ failureMessage = message;
129860
+ if (!opts.deferTerminal)
129861
+ onEvent({ type: "error", message });
129862
+ };
129728
129863
  let proc;
129729
129864
  try {
129730
129865
  proc = spawnProc([binary, ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe", cwd: opts.cwd ?? process.cwd(), env: subscriptionEnv(binary, opts.profile) });
129731
129866
  } catch (e2) {
129732
- onEvent({ type: "error", message: `couldn't start ${binary}: ${e2?.message ?? e2}` });
129733
- onEvent({ type: "done", usage: state.usage });
129734
- return finalize(state);
129867
+ fail(`couldn't start ${binary}: ${e2?.message ?? e2}`);
129868
+ if (!opts.deferTerminal)
129869
+ onEvent({ type: "done", usage: state.usage });
129870
+ return { ...finalize(state), failure: { message: failureMessage } };
129735
129871
  }
129736
129872
  const onAbort = () => proc.kill();
129737
129873
  signal?.addEventListener("abort", onAbort);
@@ -129775,25 +129911,26 @@ async function runCliTask(opts) {
129775
129911
  await Promise.all([readStdout(), readStderr(), proc.exited]);
129776
129912
  } catch (e2) {
129777
129913
  if (!signal?.aborted)
129778
- onEvent({ type: "error", message: e2?.message ?? String(e2) });
129914
+ fail(e2?.message ?? String(e2));
129779
129915
  } finally {
129780
129916
  signal?.removeEventListener("abort", onAbort);
129781
129917
  }
129782
129918
  if (!signal?.aborted) {
129783
129919
  const err = cleanCliStderr(stderr);
129784
129920
  if ((proc.exitCode ?? 0) !== 0) {
129785
- onEvent({ type: "error", message: cliFailureMessage(binary, stderr, { accountLabel: opts.accountLabel, reloginCommand: opts.reloginCommand }) });
129921
+ fail(cliFailureMessage(binary, stderr, { accountLabel: opts.accountLabel, reloginCommand: opts.reloginCommand }));
129786
129922
  } else if (!state.text && !sawEvent && err) {
129787
- onEvent({ type: "error", message: `${binary} produced no JSON output: ${err}` });
129923
+ fail(`${binary} produced no JSON output: ${err}`);
129788
129924
  } else if (!state.text && !sawEvent) {
129789
- onEvent({ type: "error", message: `${binary} finished without an assistant message` });
129925
+ fail(`${binary} finished without an assistant message`);
129790
129926
  }
129791
129927
  }
129792
129928
  const next = [...messages, { role: "user", content: prompt }];
129793
129929
  if (state.text)
129794
129930
  next.push({ role: "assistant", content: state.text });
129795
- onEvent({ type: "done", usage: state.usage });
129796
- return { messages: next, usage: state.usage, sessionId: state.sessionId, costUSD: state.costUSD, rates: [...state.rates.values()] };
129931
+ if (!opts.deferTerminal)
129932
+ onEvent({ type: "done", usage: state.usage });
129933
+ return { messages: next, usage: state.usage, sessionId: state.sessionId, costUSD: state.costUSD, rates: [...state.rates.values()], failure: failureMessage ? { message: failureMessage } : undefined };
129797
129934
  }
129798
129935
  var KEYS_TO_STRIP, CONFIG_DIR_VAR;
129799
129936
  var init_cli_backend = __esm(() => {
@@ -129811,10 +129948,12 @@ __export(exports_onboard, {
129811
129948
  testAccount: () => testAccount,
129812
129949
  cliLoginArgs: () => cliLoginArgs,
129813
129950
  cliAuthStatus: () => cliAuthStatus,
129951
+ bedrockListUrl: () => bedrockListUrl,
129814
129952
  addableProviders: () => addableProviders,
129815
129953
  addOpenAICompatAccount: () => addOpenAICompatAccount,
129816
129954
  addCliAccount: () => addCliAccount,
129817
129955
  addByPastedKey: () => addByPastedKey,
129956
+ addBedrockAccount: () => addBedrockAccount,
129818
129957
  addAzureFoundryAccount: () => addAzureFoundryAccount,
129819
129958
  addAzureAccount: () => addAzureAccount,
129820
129959
  addApiKeyAccount: () => addApiKeyAccount
@@ -129931,6 +130070,27 @@ async function addAzureAccount(resourceOrEndpoint, key, opts = {}) {
129931
130070
  putAccount(account);
129932
130071
  return { ok: true, account, message: `added ${account.label} (${id})` };
129933
130072
  }
130073
+ async function addBedrockAccount(accessKeyId, secretAccessKey, region, opts = {}) {
130074
+ if (!accessKeyId.trim() || !secretAccessKey.trim() || !region.trim()) {
130075
+ return { ok: false, message: "usage: /account add bedrock <access-key-id> <secret-access-key> <region>" };
130076
+ }
130077
+ const id = opts.id ?? `bedrock-${shortId()}`;
130078
+ const accessKeyIdRef = `${id}:aws-access-key-id`;
130079
+ const secretKeyRef = `${id}:aws-secret-access-key`;
130080
+ await setSecret(accessKeyIdRef, accessKeyId.trim());
130081
+ await setSecret(secretKeyRef, secretAccessKey.trim());
130082
+ const account = {
130083
+ id,
130084
+ label: opts.label ?? `Amazon Bedrock (${region})`,
130085
+ provider: "bedrock",
130086
+ exec: "in-loop",
130087
+ auth: { kind: "aws", accessKeyIdRef, secretKeyRef, region: region.trim() },
130088
+ enabled: true,
130089
+ addedAt: Date.now()
130090
+ };
130091
+ putAccount(account);
130092
+ return { ok: true, account, message: `added ${account.label} (${id})` };
130093
+ }
129934
130094
  function cliProfileDir(id) {
129935
130095
  const home5 = process.env.GEARBOX_HOME || join11(homedir9(), ".gearbox");
129936
130096
  return join11(home5, "cli", id);
@@ -130016,10 +130176,23 @@ function cliLoginArgs(binary) {
130016
130176
  return binary === "codex" ? ["login"] : ["auth", "login"];
130017
130177
  }
130018
130178
  async function addByPastedKey(key) {
130019
- const provider = detectProviderByKey(key);
130020
- if (!provider)
130021
- return { ok: false, message: "couldn't identify the provider from that key — use /accounts add <provider> <key>" };
130022
- return addApiKeyAccount(provider, key);
130179
+ const g = sniffCredential(key);
130180
+ if ((g.kind === "api-key" || g.kind === "openai-compat") && g.provider) {
130181
+ return addApiKeyAccount(g.provider, g.fields.apiKey ?? key);
130182
+ }
130183
+ if (g.kind === "aws" && !g.missing.length) {
130184
+ return addBedrockAccount(g.fields.accessKeyId, g.fields.secretAccessKey, g.fields.region);
130185
+ }
130186
+ return { ok: false, message: guidedMessageFor(g) };
130187
+ }
130188
+ function guidedMessageFor(g) {
130189
+ if (g.kind === "aws")
130190
+ return `looks like AWS/Bedrock — provide all three: /account add bedrock <access-key-id> <secret> <region>`;
130191
+ if (g.kind === "azure")
130192
+ return `looks like Azure (${g.fields.resourceName ?? "resource"}) — add the key: /account add azure ${g.fields.endpoint ?? "<endpoint>"} <api-key>`;
130193
+ if (g.kind === "vertex")
130194
+ return `looks like a Vertex service account — use: /account add vertex (guided), project ${g.fields.project || "<project>"}`;
130195
+ return `couldn't identify that credential — use /account add <provider> <key>, or /onboard for options`;
130023
130196
  }
130024
130197
  async function testAccount(a) {
130025
130198
  const creds = await resolveCreds(a);
@@ -130055,7 +130228,7 @@ async function testAccount(a) {
130055
130228
  const keyOk = /^(AKIA|ASIA)[A-Z0-9]{16}$/.test(accessKeyId);
130056
130229
  if (!keyOk)
130057
130230
  return { ok: false, message: `bedrock: access key ID looks malformed (expected AKIA… or ASIA…, got ${accessKeyId.slice(0, 8)}…)` };
130058
- return { ok: true, message: "credential fields present (Bedrock connectivity verified on first use)" };
130231
+ return { ok: true, message: `credential fields present Bedrock at ${bedrockListUrl(creds.aws.region)} (connectivity verified on first use)` };
130059
130232
  }
130060
130233
  if (a.provider === "vertex" && creds.vertex) {
130061
130234
  const { project, location } = creds.vertex;
@@ -130074,6 +130247,9 @@ async function testAccount(a) {
130074
130247
  return { ok: false, message: e2?.message ?? "request failed" };
130075
130248
  }
130076
130249
  }
130250
+ function bedrockListUrl(region) {
130251
+ return `https://bedrock.${region}.amazonaws.com/foundation-models`;
130252
+ }
130077
130253
  function addableProviders() {
130078
130254
  return CATALOG.filter((p) => p.authKind === "api-key" || p.authKind === "openai-compat").map((p) => ({ id: p.id, label: p.label, group: p.group }));
130079
130255
  }
@@ -130093,6 +130269,7 @@ var init_onboard = __esm(() => {
130093
130269
  init_store();
130094
130270
  init_catalog();
130095
130271
  init_onboarding();
130272
+ init_sniff();
130096
130273
  init_resolve();
130097
130274
  init_cli_backend();
130098
130275
  init_proc();
@@ -138374,9 +138551,23 @@ function recordUsage(opts) {
138374
138551
  u.turns += 1;
138375
138552
  u.estimated = u.estimated || opts.estimated;
138376
138553
  u.lastAt = now2;
138554
+ const mk = monthKeyOf(now2);
138555
+ if (u.monthKey !== mk) {
138556
+ u.monthKey = mk;
138557
+ u.monthSpentUSD = 0;
138558
+ }
138559
+ u.monthSpentUSD = (u.monthSpentUSD ?? 0) + opts.costUSD;
138377
138560
  f.accounts[opts.accountId] = u;
138378
138561
  save(f);
138379
138562
  }
138563
+ function monthKeyOf(now2) {
138564
+ return new Date(now2).toISOString().slice(0, 7);
138565
+ }
138566
+ function spentInPeriod(u, period, now2) {
138567
+ if (period === "total")
138568
+ return u.spentUSD;
138569
+ return u.monthKey === monthKeyOf(now2) ? u.monthSpentUSD ?? 0 : 0;
138570
+ }
138380
138571
  function recordRateLimits(accountId, rates) {
138381
138572
  if (!rates.length)
138382
138573
  return;
@@ -138487,6 +138678,307 @@ function buildUsageView(sessionUSD, resolve, now2 = Date.now(), accountIds = [])
138487
138678
  };
138488
138679
  }
138489
138680
 
138681
+ // src/commands.ts
138682
+ init_providers();
138683
+ init_catalog();
138684
+
138685
+ // src/ui/fuzzy.ts
138686
+ var BOUNDARY = /[/_\-. ]/;
138687
+ function fuzzyScore(query, target) {
138688
+ const q = query.toLowerCase();
138689
+ const t2 = target.toLowerCase();
138690
+ if (!q)
138691
+ return 0;
138692
+ let qi = 0;
138693
+ let score = 0;
138694
+ let last2 = -1;
138695
+ let run = 0;
138696
+ for (let ti = 0;ti < t2.length && qi < q.length; ti++) {
138697
+ if (t2[ti] !== q[qi])
138698
+ continue;
138699
+ if (last2 >= 0)
138700
+ score += ti - last2 - 1;
138701
+ const prev = ti > 0 ? t2[ti - 1] : "/";
138702
+ if (ti === 0 || BOUNDARY.test(prev))
138703
+ score -= 2;
138704
+ run = last2 === ti - 1 ? run + 1 : 0;
138705
+ score -= run;
138706
+ last2 = ti;
138707
+ qi++;
138708
+ }
138709
+ if (qi < q.length)
138710
+ return null;
138711
+ return score + (t2.length - last2) * 0.1 + t2.length * 0.01;
138712
+ }
138713
+ function fuzzyRank(items, query, key, limit = 8) {
138714
+ if (!query)
138715
+ return items.slice(0, limit);
138716
+ const scored = [];
138717
+ for (const it of items) {
138718
+ const s2 = fuzzyScore(query, key(it));
138719
+ if (s2 != null)
138720
+ scored.push({ item: it, s: s2 });
138721
+ }
138722
+ scored.sort((a, b) => a.s - b.s);
138723
+ return scored.slice(0, limit).map((x2) => x2.item);
138724
+ }
138725
+
138726
+ // src/commands.ts
138727
+ var envHint = (p) => ENV_LABEL[p] ?? catalogProvider(p)?.envVars[0] ?? "an API key";
138728
+ function badgeFor(s2) {
138729
+ switch (s2) {
138730
+ case "ok":
138731
+ return "✓ ready";
138732
+ case "expired":
138733
+ return "⚠ expired";
138734
+ case "invalid":
138735
+ return "✗ invalid";
138736
+ case "rate-limited":
138737
+ return "⏳ limited";
138738
+ case "no-credit":
138739
+ return "✗ no credit";
138740
+ default:
138741
+ return "— unknown";
138742
+ }
138743
+ }
138744
+ var COMMANDS = [
138745
+ { name: "/model", usage: "/model [name]", desc: "list models · /model <name> pins one · /model auto routes per task", group: "models" },
138746
+ { name: "/effort", usage: "/effort [level]", desc: "set the active model's reasoning level, e.g. low · high · xhigh · max", group: "models" },
138747
+ { name: "/prefer", usage: "/prefer kind model", desc: "remember a confirmed model preference for a task type", group: "models" },
138748
+ { name: "/why", usage: "/why", desc: "show the routing scorecard: every candidate scored, and why this one won", group: "models" },
138749
+ { name: "/clear", usage: "/clear", desc: "start a fresh conversation", group: "chat" },
138750
+ { name: "/resume", usage: "/resume [n]", desc: "reopen a past conversation", group: "chat" },
138751
+ { name: "/retry", usage: "/retry", desc: "send your last message again", group: "chat" },
138752
+ { name: "/compact", usage: "/compact", desc: "shrink the conversation to free up room", group: "chat" },
138753
+ { name: "/context", usage: "/context", desc: "see what's loaded and how many tokens it uses", group: "chat" },
138754
+ { name: "/ask", usage: "/ask <q>", desc: "ask about Gearbox itself — answered from its own docs", group: "chat" },
138755
+ { name: "/memory", usage: "/memory [note]", desc: "show or add facts to remember (or start a line with #)", group: "chat" },
138756
+ { name: "/account", usage: "/account", desc: "list accounts; /account <name> switches, /account login <name> re-auths, /account add adds one", group: "accounts" },
138757
+ { name: "/onboard", usage: "/onboard", desc: "first-run setup; provider list and import/add commands", group: "accounts" },
138758
+ { name: "/mcp", usage: "/mcp", desc: "list or connect MCP servers: /mcp add <name> <command> [args]", group: "accounts" },
138759
+ { name: "/cost", usage: "/cost", desc: "see what you've spent per account", group: "accounts" },
138760
+ { name: "/budget", usage: "/budget <provider> <amount> [monthly|total]", desc: "set a spend budget so routing can estimate remaining credit and preserve it", group: "accounts" },
138761
+ { name: "/copy", usage: "/copy", desc: "copy the last reply to the clipboard", group: "output" },
138762
+ { name: "/export", usage: "/export [file]", desc: "save the conversation to a file", group: "output" },
138763
+ { name: "/plan", usage: "/plan", desc: "plan mode: read-only, no edits (also shift+tab)", group: "modes" },
138764
+ { name: "/yolo", usage: "/yolo", desc: "run edits and commands without asking", group: "modes" },
138765
+ { name: "/config", usage: "/config", desc: "view or change saved settings", group: "settings" },
138766
+ { name: "/init", usage: "/init", desc: "scan this repo and write a GEARBOX.md guide", group: "other" },
138767
+ { name: "/keys", usage: "/keys", desc: "keyboard shortcuts", group: "other" },
138768
+ { name: "/help", usage: "/help", desc: "this list", group: "other" },
138769
+ { name: "/exit", usage: "/exit", desc: "quit gearbox", group: "other" }
138770
+ ];
138771
+ var HIDDEN = new Set(["/accounts", "/login", "/vim", "/ghost", "/cwd"]);
138772
+ function matchCommands(draft) {
138773
+ const q = draft.trim().toLowerCase();
138774
+ if (!q.startsWith("/"))
138775
+ return [];
138776
+ const head2 = q.split(/\s+/)[0] ?? q;
138777
+ if (head2 === "/")
138778
+ return COMMANDS;
138779
+ const prefix = COMMANDS.filter((c) => c.name.startsWith(head2));
138780
+ if (prefix.length)
138781
+ return prefix;
138782
+ return fuzzyRank(COMMANDS, head2.slice(1), (c) => c.name.slice(1), 12);
138783
+ }
138784
+ var GROUP_TITLES = [
138785
+ { id: "models", title: "models & routing" },
138786
+ { id: "chat", title: "conversation" },
138787
+ { id: "accounts", title: "accounts & cost" },
138788
+ { id: "output", title: "save & copy" },
138789
+ { id: "modes", title: "modes" },
138790
+ { id: "settings", title: "settings" },
138791
+ { id: "other", title: "other" }
138792
+ ];
138793
+ var ACCOUNT_ADD_HELP = `add an account:
138794
+ ` + ` /account add claude Claude subscription (Pro/Max)
138795
+ ` + ` /account add claude <name> a 2nd Claude account, e.g. /account add claude work
138796
+ ` + ` /account add codex ChatGPT subscription (Plus/Pro)
138797
+ ` + ` /account add codex <name> a 2nd ChatGPT account, e.g. /account add codex work
138798
+ ` + ` /account add azure <foundry-endpoint> <api-key> Azure AI Foundry (pass the full https:// endpoint)
138799
+ ` + ` /account add azure <resource-name> <api-key> [api-version] Azure OpenAI (pass the bare resource name)
138800
+ ` + ` /account add bedrock <access-key-id> <secret> <region> Amazon Bedrock
138801
+ ` + ` /account add openai-compat <name> <base-url> <api-key> <model> [model...]
138802
+ ` + ` /account add <paste> paste any key / AWS block / service-account JSON / endpoint (auto-detected)
138803
+ ` + ` /account add <provider> <api-key> e.g. anthropic, openai, openrouter
138804
+ ` + "After adding, /account refresh discovers the models the account can actually serve.";
138805
+ function helpText() {
138806
+ const visible = COMMANDS.filter((c) => !HIDDEN.has(c.name));
138807
+ const pad2 = Math.max(...visible.map((c) => c.name.length)) + 2;
138808
+ const out = ["commands · type / to filter, or just say what you want"];
138809
+ for (const g of GROUP_TITLES) {
138810
+ const items = visible.filter((c) => c.group === g.id);
138811
+ if (!items.length)
138812
+ continue;
138813
+ out.push("", g.title);
138814
+ for (const c of items)
138815
+ out.push(` ${c.name.padEnd(pad2)}${c.desc}`);
138816
+ }
138817
+ return out.join(`
138818
+ `);
138819
+ }
138820
+ function accountName(a) {
138821
+ if (a.exec === "cli") {
138822
+ const bin = a.auth?.binary;
138823
+ const base2 = bin === "codex" ? "ChatGPT" : "Claude";
138824
+ const named = a.id.match(/-cli-(.+)$/);
138825
+ return named ? `${base2} (${named[1]})` : base2;
138826
+ }
138827
+ return catalogProvider(a.provider)?.label ?? a.provider;
138828
+ }
138829
+ function accountSlug(a) {
138830
+ if (a.slug)
138831
+ return a.slug;
138832
+ return accountName(a).toLowerCase().replace(/[()]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
138833
+ }
138834
+ function accountLabel(a) {
138835
+ return `${accountName(a)} · ${a.exec === "cli" ? "subscription" : "API key"}`;
138836
+ }
138837
+ function formatAccounts(accounts, activeCliId, importable, statuses = {}) {
138838
+ const lines = ["your accounts"];
138839
+ if (!accounts.length) {
138840
+ lines.push(" (none yet)");
138841
+ } else {
138842
+ const active = activeCliId ? accounts.find((a) => a.id === activeCliId) : null;
138843
+ if (active)
138844
+ lines.push(` current: ${accountLabel(active)}`);
138845
+ else
138846
+ lines.push(" current: API routing");
138847
+ lines.push("");
138848
+ accounts.forEach((a, i2) => {
138849
+ const mark = a.id === activeCliId ? glyph.on : " ";
138850
+ const st = statuses[a.id];
138851
+ const status = a.id === activeCliId ? "active" : st?.duplicateOf ? `same login as ${st.duplicateOf}` : st?.signedIn === false ? "not signed in" : st?.signedIn === true ? "signed in" : a.exec === "cli" ? "not checked" : "ready";
138852
+ const alias = accountSlug(a);
138853
+ lines.push(` ${mark} ${accountLabel(a).padEnd(34)} ${status}`);
138854
+ lines.push(` use /account ${alias}`);
138855
+ if (st?.detail && st.signedIn)
138856
+ lines.push(` ${st.detail}`);
138857
+ });
138858
+ if (!activeCliId)
138859
+ lines.push("", " no subscription active — your API keys auto-route per task");
138860
+ }
138861
+ if (importable.length) {
138862
+ lines.push("", "found in your environment — /account import to add:");
138863
+ for (const c of importable)
138864
+ lines.push(` + ${c.label} (${c.envVar})`);
138865
+ }
138866
+ lines.push("", " switch: /account <name>", " add: /account add codex [name] · /account add claude [name] · /account add <api-key>", accounts.length ? " remove: /account remove <name>" : "", accounts.length ? " refresh models: /account refresh" : "");
138867
+ return lines.filter(Boolean).join(`
138868
+ `);
138869
+ }
138870
+ function buildContextView(sections, contextWindow, cwd2 = "") {
138871
+ const total = sections.reduce((s2, x2) => s2 + x2.tokens, 0);
138872
+ const max2 = Math.max(1, ...sections.map((s2) => s2.tokens));
138873
+ const fmt = (n) => n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
138874
+ const rows = sections.map((s2) => ({ label: s2.name, display: fmt(s2.tokens), frac: s2.tokens / max2 }));
138875
+ const labelPad = Math.max("total".length, ...rows.map((r2) => r2.label.length));
138876
+ const valuePad = Math.max(fmt(total).length, ...rows.map((r2) => r2.display.length));
138877
+ return {
138878
+ rows,
138879
+ total: fmt(total),
138880
+ windowPct: contextWindow ? Math.round(total / contextWindow * 100) : undefined,
138881
+ windowLabel: contextWindow ? fmt(contextWindow) : undefined,
138882
+ cwd: cwd2,
138883
+ labelPad,
138884
+ valuePad
138885
+ };
138886
+ }
138887
+ var ENV_LABEL = {
138888
+ anthropic: "ANTHROPIC_API_KEY",
138889
+ openai: "OPENAI_API_KEY",
138890
+ google: "GOOGLE_GENERATIVE_AI_API_KEY",
138891
+ deepseek: "DEEPSEEK_API_KEY"
138892
+ };
138893
+ var provAbbrev = (src) => src === "measured" ? "meas" : src === "researched" ? "rsch" : "seed";
138894
+ function scorecardRows(card) {
138895
+ const out = [];
138896
+ out.push({ text: `why · ${card.kind} task · quality bar ${card.bar.toFixed(2)}`, tone: "title" });
138897
+ if (card.prompt)
138898
+ out.push({ text: `"${card.prompt.length > 60 ? card.prompt.slice(0, 57) + "…" : card.prompt}"`, tone: "note" });
138899
+ if (!card.entries.length) {
138900
+ out.push({ text: card.note ?? "no candidates", tone: "note" });
138901
+ return out;
138902
+ }
138903
+ const entries = card.entries.slice(0, 8);
138904
+ const labelW = Math.min(20, Math.max(5, ...entries.map((e2) => e2.label.length)));
138905
+ const qcol = (e2) => `${e2.quality.toFixed(2)} ${provAbbrev(e2.qualitySrc)}`;
138906
+ const leftcol = (e2) => e2.headroomText ?? e2.balanceText ?? "—";
138907
+ const lw = Math.max(4, ...entries.map((e2) => leftcol(e2).length));
138908
+ const row = (label, q, cost, left, score, verdict) => `${label.padEnd(labelW).slice(0, labelW)} ${q.padEnd(9)} ${cost.padStart(7)} ${left.padEnd(lw)} ${score.padStart(5)} ${verdict}`;
138909
+ out.push({ text: row("model", "quality", "$/Mtok", "left", "score", "verdict"), tone: "colhead" });
138910
+ for (const e2 of entries) {
138911
+ const score = e2.verdict === "below bar" ? "—" : e2.score.toFixed(2);
138912
+ out.push({
138913
+ text: row(e2.label, qcol(e2), `$${e2.estCostPerMtok.toFixed(2)}`, leftcol(e2), score, e2.verdict + (e2.chosen ? " ◀" : "")),
138914
+ tone: e2.chosen ? "chosen" : e2.verdict === "below bar" ? "dim" : "row"
138915
+ });
138916
+ }
138917
+ if (card.entries.length > entries.length)
138918
+ out.push({ text: `…and ${card.entries.length - entries.length} more`, tone: "note" });
138919
+ return out;
138920
+ }
138921
+ function formatModelList(currentId, showAll = false) {
138922
+ const MODELS2 = modelRegistry();
138923
+ const line = (m2) => ` ${m2.id === currentId ? glyph.on : glyph.off} ${m2.label.padEnd(18)} ${m2.provider}`;
138924
+ const usable = MODELS2.filter((m2) => providerAvailable(m2.provider));
138925
+ const rest2 = MODELS2.filter((m2) => !providerAvailable(m2.provider));
138926
+ const rows = ["models · /model <name> pins one · /model auto routes per task"];
138927
+ if (usable.length) {
138928
+ rows.push("", "ready to use");
138929
+ const CAP = 8;
138930
+ const shown = new Map;
138931
+ let hidden = 0;
138932
+ for (const m2 of usable) {
138933
+ const n = shown.get(m2.provider) ?? 0;
138934
+ if (!showAll && n >= CAP) {
138935
+ hidden++;
138936
+ continue;
138937
+ }
138938
+ shown.set(m2.provider, n + 1);
138939
+ rows.push(line(m2));
138940
+ }
138941
+ if (hidden)
138942
+ rows.push(` + ${hidden} more on your accounts — /model all to list · /model <name> to pick`);
138943
+ } else {
138944
+ rows.push("", "no accounts yet — /account to add one");
138945
+ }
138946
+ if (showAll && rest2.length) {
138947
+ rows.push("", "needs an account");
138948
+ for (const m2 of rest2)
138949
+ rows.push(` ${glyph.off} ${m2.label.padEnd(18)} ${m2.provider}`);
138950
+ } else if (rest2.length) {
138951
+ rows.push("", ` + ${rest2.length} more once you add a key — /model all to list · /account to add one`);
138952
+ }
138953
+ return rows.join(`
138954
+ `);
138955
+ }
138956
+ function resolveModelSwitch(query) {
138957
+ const q = query.trim().toLowerCase();
138958
+ if (!q)
138959
+ return { ok: false, message: "usage: /model <name>" };
138960
+ const MODELS2 = modelRegistry();
138961
+ const matches2 = MODELS2.filter((m3) => m3.label.toLowerCase().includes(q) || m3.id.toLowerCase().includes(q));
138962
+ if (matches2.length === 0)
138963
+ return { ok: false, message: `no model matching “${query}” — /model to list` };
138964
+ const exact = matches2.find((m3) => m3.label.toLowerCase() === q || m3.id.toLowerCase() === q);
138965
+ const available = matches2.filter((m3) => providerAvailable(m3.provider));
138966
+ if (exact) {
138967
+ if (!providerAvailable(exact.provider))
138968
+ return { ok: false, message: `${exact.label}: no ${exact.provider} account yet — /account add ${exact.provider} <key> or set ${envHint(exact.provider)}` };
138969
+ return { ok: true, modelId: exact.id, message: `model → ${exact.label}` };
138970
+ }
138971
+ if (available.length === 0) {
138972
+ const m3 = matches2[0];
138973
+ return { ok: false, message: `“${query}” matches ${m3.label} but no account for ${m3.provider} — /accounts add ${m3.provider} <key> or set ${envHint(m3.provider)}` };
138974
+ }
138975
+ if (available.length > 1) {
138976
+ return { ok: false, message: `“${query}” matches ${available.map((m3) => m3.label).join(", ")} — be more specific` };
138977
+ }
138978
+ const m2 = available[0];
138979
+ return { ok: true, modelId: m2.id, message: `model → ${m2.label}` };
138980
+ }
138981
+
138490
138982
  // src/ui/components/Transcript.tsx
138491
138983
  var jsx_dev_runtime3 = __toESM(require_jsx_dev_runtime(), 1);
138492
138984
  var limitColor = (pct) => pct >= 85 ? color.err : pct >= 60 ? color.accent : color.ok;
@@ -138539,7 +139031,7 @@ function UsageCard({ view }) {
138539
139031
  color: color.faint,
138540
139032
  children: " subscriptions"
138541
139033
  }, undefined, false, undefined, this),
138542
- view.subscriptions.map((a, i) => /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Box_default, {
139034
+ view.subscriptions.map((a, i2) => /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Box_default, {
138543
139035
  children: [
138544
139036
  /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
138545
139037
  color: color.text,
@@ -138577,7 +139069,7 @@ function UsageCard({ view }) {
138577
139069
  children: " " + (a.limitNote ?? "limits not observed yet")
138578
139070
  }, undefined, false, undefined, this)
138579
139071
  ]
138580
- }, i, true, undefined, this))
139072
+ }, i2, true, undefined, this))
138581
139073
  ]
138582
139074
  }, undefined, true, undefined, this) : null,
138583
139075
  view.apiKeys.length ? /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Box_default, {
@@ -138588,7 +139080,7 @@ function UsageCard({ view }) {
138588
139080
  color: color.faint,
138589
139081
  children: " api keys"
138590
139082
  }, undefined, false, undefined, this),
138591
- view.apiKeys.map((a, i) => /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Box_default, {
139083
+ view.apiKeys.map((a, i2) => /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Box_default, {
138592
139084
  children: [
138593
139085
  /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
138594
139086
  color: color.text,
@@ -138611,7 +139103,7 @@ function UsageCard({ view }) {
138611
139103
  children: " · " + a.balanceNote
138612
139104
  }, undefined, false, undefined, this) : null
138613
139105
  ]
138614
- }, i, true, undefined, this))
139106
+ }, i2, true, undefined, this))
138615
139107
  ]
138616
139108
  }, undefined, true, undefined, this) : null,
138617
139109
  /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Box_default, {
@@ -138638,32 +139130,32 @@ function UsageCard({ view }) {
138638
139130
  ]
138639
139131
  }, undefined, true, undefined, this);
138640
139132
  }
138641
- var accountStateColor = (status) => status === "active" || status === "signed in" || status === "ready" ? color.ok : status === "duplicate" ? color.accent : status === "not signed in" ? color.run : color.faint;
139133
+ var accountStateColor = (status) => status === "active" || status === "signed in" || status === "ready" || status.startsWith("✓") ? color.ok : status === "duplicate" ? color.accent : status === "not signed in" || status.startsWith("✗") ? color.run : status.startsWith("⚠") || status.startsWith("⏳") ? color.accent : color.faint;
138642
139134
  function AccountCard({ view }) {
138643
- const subs = view.rows.filter((r) => r.type === "subscription");
138644
- const keys2 = view.rows.filter((r) => r.type === "API key");
138645
- const commandWidth = Math.max(18, ...view.rows.map((r) => `/account ${r.alias}`.length));
138646
- const Row = ({ r }) => {
138647
- const cmd = `/account ${r.alias}`;
139135
+ const subs = view.rows.filter((r2) => r2.type === "subscription");
139136
+ const keys2 = view.rows.filter((r2) => r2.type === "API key");
139137
+ const commandWidth = Math.max(18, ...view.rows.map((r2) => `/account ${r2.alias}`.length));
139138
+ const Row = ({ r: r2 }) => {
139139
+ const cmd = `/account ${r2.alias}`;
138648
139140
  return /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Box_default, {
138649
139141
  flexDirection: "column",
138650
139142
  children: [
138651
139143
  /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
138652
139144
  children: [
138653
139145
  /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
138654
- color: r.active ? color.ok : color.faint,
138655
- children: r.active ? " ● " : " "
139146
+ color: r2.active ? color.ok : color.faint,
139147
+ children: r2.active ? " ● " : " "
138656
139148
  }, undefined, false, undefined, this),
138657
139149
  /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
138658
139150
  color: color.text,
138659
- bold: r.active,
138660
- children: r.name.padEnd(view.labelPad)
139151
+ bold: r2.active,
139152
+ children: r2.name.padEnd(view.labelPad)
138661
139153
  }, undefined, false, undefined, this),
138662
139154
  /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
138663
- color: accountStateColor(r.status),
139155
+ color: accountStateColor(r2.status),
138664
139156
  children: [
138665
139157
  " ",
138666
- r.status.padEnd(view.statusPad)
139158
+ r2.status.padEnd(view.statusPad)
138667
139159
  ]
138668
139160
  }, undefined, true, undefined, this),
138669
139161
  /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
@@ -138675,28 +139167,21 @@ function AccountCard({ view }) {
138675
139167
  bold: true,
138676
139168
  backgroundColor: color.accentBg,
138677
139169
  children: cmd.padEnd(commandWidth)
138678
- }, undefined, false, undefined, this),
138679
- /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
138680
- color: color.faint,
138681
- children: [
138682
- " or ",
138683
- r.number
138684
- ]
138685
- }, undefined, true, undefined, this)
139170
+ }, undefined, false, undefined, this)
138686
139171
  ]
138687
139172
  }, undefined, true, undefined, this),
138688
- r.duplicateOf ? /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
139173
+ r2.duplicateOf ? /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
138689
139174
  color: color.faint,
138690
139175
  children: [
138691
139176
  " same login as ",
138692
139177
  /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
138693
139178
  color: color.text,
138694
- children: r.duplicateOf
139179
+ children: r2.duplicateOf
138695
139180
  }, undefined, false, undefined, this)
138696
139181
  ]
138697
- }, undefined, true, undefined, this) : r.detail ? /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
139182
+ }, undefined, true, undefined, this) : r2.detail ? /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
138698
139183
  color: color.faint,
138699
- children: " " + r.detail
139184
+ children: " " + r2.detail
138700
139185
  }, undefined, false, undefined, this) : null
138701
139186
  ]
138702
139187
  }, undefined, true, undefined, this);
@@ -138709,9 +139194,9 @@ function AccountCard({ view }) {
138709
139194
  color: color.faint,
138710
139195
  children: " " + title
138711
139196
  }, undefined, false, undefined, this),
138712
- rows.map((r) => /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Row, {
138713
- r
138714
- }, r.alias, false, undefined, this))
139197
+ rows.map((r2) => /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Row, {
139198
+ r: r2
139199
+ }, r2.alias, false, undefined, this))
138715
139200
  ]
138716
139201
  }, undefined, true, undefined, this) : null;
138717
139202
  return /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Box_default, {
@@ -138821,7 +139306,7 @@ function AccountCard({ view }) {
138821
139306
  }, undefined, false, undefined, this),
138822
139307
  /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
138823
139308
  color: color.accent,
138824
- children: "/account remove <name-or-number>"
139309
+ children: "/account remove <name>"
138825
139310
  }, undefined, false, undefined, this)
138826
139311
  ]
138827
139312
  }, undefined, true, undefined, this)
@@ -138862,9 +139347,9 @@ function guessLang(text) {
138862
139347
  function CodeRows({ lines, lang, width, start = 1, rowBg }) {
138863
139348
  const lineNoWidth = Math.max(2, String(start + lines.length - 1).length);
138864
139349
  return /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(jsx_dev_runtime3.Fragment, {
138865
- children: lines.map((line, i) => {
138866
- const bg = rowBg?.(line, i) ?? color.codeBg;
138867
- const prefix = `${String(start + i).padStart(lineNoWidth)} │ `;
139350
+ children: lines.map((line, i2) => {
139351
+ const bg = rowBg?.(line, i2) ?? color.codeBg;
139352
+ const prefix = `${String(start + i2).padStart(lineNoWidth)} │ `;
138868
139353
  const spans = highlightLine(line, lang);
138869
139354
  const used = prefix.length + spanLen(spans);
138870
139355
  return /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
@@ -138874,19 +139359,19 @@ function CodeRows({ lines, lang, width, start = 1, rowBg }) {
138874
139359
  backgroundColor: bg,
138875
139360
  children: prefix
138876
139361
  }, undefined, false, undefined, this),
138877
- spans.map((s, j) => /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
138878
- color: s.color,
138879
- bold: s.bold,
138880
- dimColor: s.dim,
139362
+ spans.map((s2, j) => /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
139363
+ color: s2.color,
139364
+ bold: s2.bold,
139365
+ dimColor: s2.dim,
138881
139366
  backgroundColor: bg,
138882
- children: s.text
139367
+ children: s2.text
138883
139368
  }, j, false, undefined, this)),
138884
139369
  /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
138885
139370
  backgroundColor: bg,
138886
139371
  children: pad2(used, width)
138887
139372
  }, undefined, false, undefined, this)
138888
139373
  ]
138889
- }, i, true, undefined, this);
139374
+ }, i2, true, undefined, this);
138890
139375
  })
138891
139376
  }, undefined, false, undefined, this);
138892
139377
  }
@@ -138898,7 +139383,7 @@ function DiffView({ lines, width }) {
138898
139383
  marginLeft: 5,
138899
139384
  marginTop: 1,
138900
139385
  children: [
138901
- shown.map((l, i) => {
139386
+ shown.map((l, i2) => {
138902
139387
  const bg = l.sign === "+" ? color.diffAddBg : color.diffDelBg;
138903
139388
  const sign = l.sign === "+" ? "+ " : "− ";
138904
139389
  const spans = highlightLine(l.text);
@@ -138911,19 +139396,19 @@ function DiffView({ lines, width }) {
138911
139396
  backgroundColor: bg,
138912
139397
  children: sign
138913
139398
  }, undefined, false, undefined, this),
138914
- spans.map((s, j) => /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
138915
- color: s.color,
138916
- bold: s.bold,
138917
- dimColor: s.dim,
139399
+ spans.map((s2, j) => /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
139400
+ color: s2.color,
139401
+ bold: s2.bold,
139402
+ dimColor: s2.dim,
138918
139403
  backgroundColor: bg,
138919
- children: s.text
139404
+ children: s2.text
138920
139405
  }, j, false, undefined, this)),
138921
139406
  /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
138922
139407
  backgroundColor: bg,
138923
139408
  children: pad2(used, width)
138924
139409
  }, undefined, false, undefined, this)
138925
139410
  ]
138926
- }, i, true, undefined, this);
139411
+ }, i2, true, undefined, this);
138927
139412
  }),
138928
139413
  extra > 0 ? /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
138929
139414
  color: color.faint,
@@ -138936,20 +139421,20 @@ function DiffView({ lines, width }) {
138936
139421
  ]
138937
139422
  }, undefined, true, undefined, this);
138938
139423
  }
138939
- var friendlyTool = (name) => name === "AskUserQuestion" ? "question" : name === "Write" ? "write" : name === "Edit" ? "edit" : name === "Read" ? "read" : name === "Bash" ? "shell" : name === "read_file" ? "read" : name === "write_file" ? "write" : name === "edit_file" ? "edit" : name === "run_shell" ? "shell" : name === "command_execution" ? "shell" : name === "file_change" ? "write" : name === "list_dir" ? "list" : name === "glob" ? "glob" : name === "search" ? "search" : name;
139424
+ var friendlyTool = (name15) => name15 === "AskUserQuestion" ? "question" : name15 === "Write" ? "write" : name15 === "Edit" ? "edit" : name15 === "Read" ? "read" : name15 === "Bash" ? "shell" : name15 === "read_file" ? "read" : name15 === "write_file" ? "write" : name15 === "edit_file" ? "edit" : name15 === "run_shell" ? "shell" : name15 === "command_execution" ? "shell" : name15 === "file_change" ? "write" : name15 === "list_dir" ? "list" : name15 === "glob" ? "glob" : name15 === "search" ? "search" : name15;
138940
139425
  var fmtMs = (ms) => ms == null ? "" : ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
138941
139426
  var frame = () => Math.floor(Date.now() / 360);
138942
139427
  var TOOL_SPIN = ["◐", "◓", "◑", "◒"];
138943
139428
  var spin = () => TOOL_SPIN[frame() % TOOL_SPIN.length];
138944
139429
  var activePhrase = (label) => `${label}${["", ".", "..", "..."][frame() % 4]}`;
138945
139430
  var toolColor = (item) => item.name === "AskUserQuestion" ? color.accent : item.status === "err" ? color.err : item.status === "running" ? color.run : item.name === "run_shell" || item.name === "command_execution" ? color.accent : item.name.toLowerCase().includes("write") || item.name.toLowerCase().includes("edit") || item.name === "file_change" ? color.ok : color.accentDim;
138946
- function previewHighlight(line, lang, doc) {
139431
+ function previewHighlight(line, lang, doc2) {
138947
139432
  const isPy = /^(py|python)$/i.test(lang ?? "");
138948
139433
  const tripleCount = isPy ? (line.match(/("""|''')/g) ?? []).length : 0;
138949
- if (isPy && (doc.open || tripleCount > 0)) {
139434
+ if (isPy && (doc2.open || tripleCount > 0)) {
138950
139435
  const spans = [{ text: line, color: color.codeString }];
138951
139436
  if (tripleCount % 2 === 1)
138952
- doc.open = !doc.open;
139437
+ doc2.open = !doc2.open;
138953
139438
  return spans;
138954
139439
  }
138955
139440
  return highlightLine(line, lang);
@@ -138958,9 +139443,9 @@ function noticeParts(text) {
138958
139443
  const out = [];
138959
139444
  const re = /(\/account\s+\d+|\/[a-z][\w-]*(?:\s+[^\s]+)?|`[^`]+`|\b\d+\.\b|\b(?:Claude|ChatGPT|Anthropic|OpenAI|OpenRouter|subscription|API key|active|current|switch|add|remove|use)\b)/gi;
138960
139445
  let last2 = 0;
138961
- for (const m of text.matchAll(re)) {
138962
- const idx = m.index ?? 0;
138963
- const token = m[0];
139446
+ for (const m2 of text.matchAll(re)) {
139447
+ const idx = m2.index ?? 0;
139448
+ const token = m2[0];
138964
139449
  if (idx > last2)
138965
139450
  out.push({ text: text.slice(last2, idx), color: color.dim });
138966
139451
  const low = token.toLowerCase();
@@ -138974,12 +139459,12 @@ function noticeParts(text) {
138974
139459
  }
138975
139460
  function NoticeText({ text }) {
138976
139461
  return /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
138977
- children: noticeParts(text).map((s, i) => /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
138978
- color: s.color,
138979
- bold: s.bold,
138980
- backgroundColor: s.bg,
138981
- children: s.text
138982
- }, i, false, undefined, this))
139462
+ children: noticeParts(text).map((s2, i2) => /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
139463
+ color: s2.color,
139464
+ bold: s2.bold,
139465
+ backgroundColor: s2.bg,
139466
+ children: s2.text
139467
+ }, i2, false, undefined, this))
138983
139468
  }, undefined, false, undefined, this);
138984
139469
  }
138985
139470
  function UserLine({ text, width }) {
@@ -138988,8 +139473,8 @@ function UserLine({ text, width }) {
138988
139473
  return /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Box_default, {
138989
139474
  marginTop: 1,
138990
139475
  flexDirection: "column",
138991
- children: lines.map((line, i) => {
138992
- const prefix = i === 0 ? "▌ " : " ";
139476
+ children: lines.map((line, i2) => {
139477
+ const prefix = i2 === 0 ? "▌ " : " ";
138993
139478
  const used = prefix.length + line.length;
138994
139479
  return /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
138995
139480
  children: [
@@ -139010,7 +139495,7 @@ function UserLine({ text, width }) {
139010
139495
  children: " ".repeat(Math.max(0, width - used - 2))
139011
139496
  }, undefined, false, undefined, this)
139012
139497
  ]
139013
- }, i, true, undefined, this);
139498
+ }, i2, true, undefined, this);
139014
139499
  })
139015
139500
  }, undefined, false, undefined, this);
139016
139501
  }
@@ -139028,7 +139513,7 @@ function AssistantLine({ text, width }) {
139028
139513
  }, undefined, false, undefined, this)
139029
139514
  }, undefined, false, undefined, this);
139030
139515
  }
139031
- var spanLen = (spans) => spans.reduce((n, s) => n + s.text.length, 0);
139516
+ var spanLen = (spans) => spans.reduce((n, s2) => n + s2.text.length, 0);
139032
139517
  var pad2 = (used, width) => " ".repeat(Math.max(0, width - used));
139033
139518
  function ToolLine({ item, width, expandAll = false }) {
139034
139519
  const dotColor = toolColor(item);
@@ -139138,7 +139623,7 @@ function ToolLine({ item, width, expandAll = false }) {
139138
139623
  }, undefined, true, undefined, this);
139139
139624
  })()
139140
139625
  }, undefined, false, undefined, this),
139141
- previewShown.map((l, i) => {
139626
+ previewShown.map((l, i2) => {
139142
139627
  const spans = previewHighlight(l, item.previewLang, docState);
139143
139628
  const used = 2 + 3 + 2 + spanLen(spans);
139144
139629
  return /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
@@ -139152,7 +139637,7 @@ function ToolLine({ item, width, expandAll = false }) {
139152
139637
  color: color.faint,
139153
139638
  backgroundColor: color.codeBg,
139154
139639
  children: [
139155
- String(i + 1).padStart(2),
139640
+ String(i2 + 1).padStart(2),
139156
139641
  " "
139157
139642
  ]
139158
139643
  }, undefined, true, undefined, this),
@@ -139161,18 +139646,18 @@ function ToolLine({ item, width, expandAll = false }) {
139161
139646
  backgroundColor: color.codeBg,
139162
139647
  children: "│ "
139163
139648
  }, undefined, false, undefined, this),
139164
- spans.map((s, j) => /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
139165
- color: s.color,
139166
- bold: s.bold,
139649
+ spans.map((s2, j) => /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
139650
+ color: s2.color,
139651
+ bold: s2.bold,
139167
139652
  backgroundColor: color.codeBg,
139168
- children: s.text
139653
+ children: s2.text
139169
139654
  }, j, false, undefined, this)),
139170
139655
  /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
139171
139656
  backgroundColor: color.codeBg,
139172
139657
  children: pad2(used, codeWidth)
139173
139658
  }, undefined, false, undefined, this)
139174
139659
  ]
139175
- }, i, true, undefined, this);
139660
+ }, i2, true, undefined, this);
139176
139661
  }),
139177
139662
  (item.previewLines ?? 0) > previewShown.length ? /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
139178
139663
  children: [
@@ -139256,10 +139741,10 @@ function ToolLine({ item, width, expandAll = false }) {
139256
139741
  color: color.faint,
139257
139742
  children: `… ${outLines} lines · ⌃O to expand`
139258
139743
  }, undefined, false, undefined, this) : null,
139259
- shown.map((l, i) => /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
139744
+ shown.map((l, i2) => /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
139260
139745
  color: color.dim,
139261
139746
  children: `│ ${l}`
139262
- }, i, false, undefined, this))
139747
+ }, i2, false, undefined, this))
139263
139748
  ]
139264
139749
  }, undefined, true, undefined, this);
139265
139750
  })()
@@ -139455,23 +139940,23 @@ function ContextCard({ view }) {
139455
139940
  /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Box_default, {
139456
139941
  marginTop: 1,
139457
139942
  flexDirection: "column",
139458
- children: view.rows.map((r, i) => /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Box_default, {
139943
+ children: view.rows.map((r2, i2) => /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Box_default, {
139459
139944
  children: [
139460
139945
  /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
139461
139946
  color: color.dim,
139462
- children: " " + r.label.padEnd(view.labelPad)
139947
+ children: " " + r2.label.padEnd(view.labelPad)
139463
139948
  }, undefined, false, undefined, this),
139464
139949
  /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
139465
139950
  color: color.text,
139466
- children: " " + r.display.padStart(view.valuePad) + " "
139951
+ children: " " + r2.display.padStart(view.valuePad) + " "
139467
139952
  }, undefined, false, undefined, this),
139468
139953
  /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Bar, {
139469
- frac: r.frac,
139954
+ frac: r2.frac,
139470
139955
  width: 18,
139471
139956
  on: color.accent
139472
139957
  }, undefined, false, undefined, this)
139473
139958
  ]
139474
- }, i, true, undefined, this))
139959
+ }, i2, true, undefined, this))
139475
139960
  }, undefined, false, undefined, this),
139476
139961
  /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Box_default, {
139477
139962
  marginTop: 1,
@@ -139502,6 +139987,27 @@ function ContextCard({ view }) {
139502
139987
  ]
139503
139988
  }, undefined, true, undefined, this);
139504
139989
  }
139990
+ function ScorecardCard({ card }) {
139991
+ const toneColor = { title: color.text, colhead: color.faint, chosen: color.accent, row: color.dim, dim: color.faint, note: color.faint };
139992
+ const rows = scorecardRows(card);
139993
+ return /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Box_default, {
139994
+ flexDirection: "column",
139995
+ marginTop: 1,
139996
+ marginLeft: 2,
139997
+ children: rows.map((r2, i2) => /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Box_default, {
139998
+ children: [
139999
+ /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
140000
+ color: color.accentDim,
140001
+ children: i2 === 0 ? glyph.notice + " " : " "
140002
+ }, undefined, false, undefined, this),
140003
+ /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
140004
+ color: toneColor[r2.tone] ?? color.text,
140005
+ children: r2.text
140006
+ }, undefined, false, undefined, this)
140007
+ ]
140008
+ }, i2, true, undefined, this))
140009
+ }, undefined, false, undefined, this);
140010
+ }
139505
140011
  function Row({ item, width, expandAll = false }) {
139506
140012
  switch (item.kind) {
139507
140013
  case "user":
@@ -139552,23 +140058,27 @@ function Row({ item, width, expandAll = false }) {
139552
140058
  return /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(ContextCard, {
139553
140059
  view: item.view
139554
140060
  }, undefined, false, undefined, this);
140061
+ case "scorecard":
140062
+ return /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(ScorecardCard, {
140063
+ card: item.card
140064
+ }, undefined, false, undefined, this);
139555
140065
  case "notice":
139556
140066
  return /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Box_default, {
139557
140067
  marginTop: 1,
139558
140068
  marginLeft: 2,
139559
140069
  flexDirection: "column",
139560
140070
  children: item.text.split(`
139561
- `).map((line, i) => /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Box_default, {
140071
+ `).map((line, i2) => /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Box_default, {
139562
140072
  children: [
139563
140073
  /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Text, {
139564
140074
  color: color.accentDim,
139565
- children: i === 0 ? glyph.notice + " " : " "
140075
+ children: i2 === 0 ? glyph.notice + " " : " "
139566
140076
  }, undefined, false, undefined, this),
139567
140077
  /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(NoticeText, {
139568
140078
  text: line
139569
140079
  }, undefined, false, undefined, this)
139570
140080
  ]
139571
- }, i, true, undefined, this))
140081
+ }, i2, true, undefined, this))
139572
140082
  }, undefined, false, undefined, this);
139573
140083
  case "error":
139574
140084
  return /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Box_default, {
@@ -139618,9 +140128,9 @@ function Transcript({ items, width = 80, header, expandAll = false }) {
139618
140128
  }, undefined, true, undefined, this);
139619
140129
  }
139620
140130
  let cut = items.length;
139621
- for (let i = 0;i < items.length; i++) {
139622
- if (!isFinal(items[i])) {
139623
- cut = i;
140131
+ for (let i2 = 0;i2 < items.length; i2++) {
140132
+ if (!isFinal(items[i2])) {
140133
+ cut = i2;
139624
140134
  break;
139625
140135
  }
139626
140136
  }
@@ -139635,14 +140145,14 @@ function Transcript({ items, width = 80, header, expandAll = false }) {
139635
140145
  children: [
139636
140146
  /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Static, {
139637
140147
  items: entries,
139638
- children: (e) => /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Box_default, {
139639
- paddingX: e.kind === "item" ? 1 : 0,
139640
- children: e.kind === "header" ? header : /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Row, {
139641
- item: e.item,
140148
+ children: (e2) => /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Box_default, {
140149
+ paddingX: e2.kind === "item" ? 1 : 0,
140150
+ children: e2.kind === "header" ? header : /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Row, {
140151
+ item: e2.item,
139642
140152
  width,
139643
140153
  expandAll
139644
140154
  }, undefined, false, undefined, this)
139645
- }, e.key, false, undefined, this)
140155
+ }, e2.key, false, undefined, this)
139646
140156
  }, undefined, false, undefined, this),
139647
140157
  live.length ? /* @__PURE__ */ jsx_dev_runtime3.jsxDEV(Box_default, {
139648
140158
  flexDirection: "column",
@@ -139668,10 +140178,10 @@ var SEP2 = ` ${glyph.bullet} `;
139668
140178
  function statusBarLayout({
139669
140179
  model,
139670
140180
  effort,
139671
- mode = "normal"
140181
+ mode: mode2 = "normal"
139672
140182
  }) {
139673
- const modeLabel = mode === "auto-accept" ? "auto-accept" : mode;
139674
- const modelStart = 1 + (mode !== "normal" ? modeLabel.length + SEP2.length : 0);
140183
+ const modeLabel = mode2 === "auto-accept" ? "auto-accept" : mode2;
140184
+ const modelStart = 1 + (mode2 !== "normal" ? modeLabel.length + SEP2.length : 0);
139675
140185
  const modelZone = [modelStart, modelStart + model.length];
139676
140186
  if (!effort)
139677
140187
  return { modelZone, effortZone: null };
@@ -139700,13 +140210,13 @@ function StatusBar({
139700
140210
  tokens,
139701
140211
  cost = 0,
139702
140212
  width,
139703
- mode = "normal",
140213
+ mode: mode2 = "normal",
139704
140214
  effort,
139705
140215
  subscription = null,
139706
140216
  online = true
139707
140217
  }) {
139708
140218
  const sep = SEP2;
139709
- const modeLabel = mode === "auto-accept" ? "auto-accept" : mode;
140219
+ const modeLabel = mode2 === "auto-accept" ? "auto-accept" : mode2;
139710
140220
  const left = [
139711
140221
  model,
139712
140222
  effort ? `effort ${effort}` : null,
@@ -139725,7 +140235,7 @@ function StatusBar({
139725
140235
  color: color.dim,
139726
140236
  wrap: "truncate-end",
139727
140237
  children: [
139728
- mode !== "normal" ? /* @__PURE__ */ jsx_dev_runtime4.jsxDEV(Text, {
140238
+ mode2 !== "normal" ? /* @__PURE__ */ jsx_dev_runtime4.jsxDEV(Text, {
139729
140239
  color: color.accent,
139730
140240
  children: [
139731
140241
  modeLabel,
@@ -139736,13 +140246,13 @@ function StatusBar({
139736
140246
  color: color.dim,
139737
140247
  children: left[0]
139738
140248
  }, undefined, false, undefined, this) : null,
139739
- left.slice(1).map((x) => /* @__PURE__ */ jsx_dev_runtime4.jsxDEV(Text, {
139740
- color: x.includes("tok") ? color.accentDim : color.faint,
140249
+ left.slice(1).map((x2) => /* @__PURE__ */ jsx_dev_runtime4.jsxDEV(Text, {
140250
+ color: x2.includes("tok") ? color.accentDim : color.faint,
139741
140251
  children: [
139742
140252
  sep,
139743
- x
140253
+ x2
139744
140254
  ]
139745
- }, x, true, undefined, this)),
140255
+ }, x2, true, undefined, this)),
139746
140256
  ctxPct != null && ctxPct > 0 ? /* @__PURE__ */ jsx_dev_runtime4.jsxDEV(Text, {
139747
140257
  color: ctxColor,
139748
140258
  children: [
@@ -139802,258 +140312,6 @@ function StatusBar({
139802
140312
  }, undefined, true, undefined, this);
139803
140313
  }
139804
140314
 
139805
- // src/commands.ts
139806
- init_providers();
139807
- init_catalog();
139808
-
139809
- // src/ui/fuzzy.ts
139810
- var BOUNDARY = /[/_\-. ]/;
139811
- function fuzzyScore(query, target) {
139812
- const q = query.toLowerCase();
139813
- const t2 = target.toLowerCase();
139814
- if (!q)
139815
- return 0;
139816
- let qi = 0;
139817
- let score = 0;
139818
- let last2 = -1;
139819
- let run = 0;
139820
- for (let ti = 0;ti < t2.length && qi < q.length; ti++) {
139821
- if (t2[ti] !== q[qi])
139822
- continue;
139823
- if (last2 >= 0)
139824
- score += ti - last2 - 1;
139825
- const prev = ti > 0 ? t2[ti - 1] : "/";
139826
- if (ti === 0 || BOUNDARY.test(prev))
139827
- score -= 2;
139828
- run = last2 === ti - 1 ? run + 1 : 0;
139829
- score -= run;
139830
- last2 = ti;
139831
- qi++;
139832
- }
139833
- if (qi < q.length)
139834
- return null;
139835
- return score + (t2.length - last2) * 0.1 + t2.length * 0.01;
139836
- }
139837
- function fuzzyRank(items, query, key, limit = 8) {
139838
- if (!query)
139839
- return items.slice(0, limit);
139840
- const scored = [];
139841
- for (const it of items) {
139842
- const s2 = fuzzyScore(query, key(it));
139843
- if (s2 != null)
139844
- scored.push({ item: it, s: s2 });
139845
- }
139846
- scored.sort((a, b) => a.s - b.s);
139847
- return scored.slice(0, limit).map((x2) => x2.item);
139848
- }
139849
-
139850
- // src/commands.ts
139851
- var envHint = (p) => ENV_LABEL[p] ?? catalogProvider(p)?.envVars[0] ?? "an API key";
139852
- var COMMANDS = [
139853
- { name: "/model", usage: "/model [name]", desc: "list models · /model <name> pins one · /model auto routes per task", group: "models" },
139854
- { name: "/effort", usage: "/effort [level]", desc: "set the active model's reasoning level, e.g. low · high · xhigh · max", group: "models" },
139855
- { name: "/prefer", usage: "/prefer kind model", desc: "remember a confirmed model preference for a task type", group: "models" },
139856
- { name: "/clear", usage: "/clear", desc: "start a fresh conversation", group: "chat" },
139857
- { name: "/resume", usage: "/resume [n]", desc: "reopen a past conversation", group: "chat" },
139858
- { name: "/retry", usage: "/retry", desc: "send your last message again", group: "chat" },
139859
- { name: "/compact", usage: "/compact", desc: "shrink the conversation to free up room", group: "chat" },
139860
- { name: "/context", usage: "/context", desc: "see what's loaded and how many tokens it uses", group: "chat" },
139861
- { name: "/ask", usage: "/ask <q>", desc: "ask about Gearbox itself — answered from its own docs", group: "chat" },
139862
- { name: "/memory", usage: "/memory [note]", desc: "show or add facts to remember (or start a line with #)", group: "chat" },
139863
- { name: "/account", usage: "/account", desc: "list accounts; /account <number> to switch, /account add to add one", group: "accounts" },
139864
- { name: "/onboard", usage: "/onboard", desc: "first-run setup; provider list and import/add commands", group: "accounts" },
139865
- { name: "/mcp", usage: "/mcp", desc: "list or connect MCP servers: /mcp add <name> <command> [args]", group: "accounts" },
139866
- { name: "/cost", usage: "/cost", desc: "see what you've spent per account", group: "accounts" },
139867
- { name: "/copy", usage: "/copy", desc: "copy the last reply to the clipboard", group: "output" },
139868
- { name: "/export", usage: "/export [file]", desc: "save the conversation to a file", group: "output" },
139869
- { name: "/plan", usage: "/plan", desc: "plan mode: read-only, no edits (also shift+tab)", group: "modes" },
139870
- { name: "/yolo", usage: "/yolo", desc: "run edits and commands without asking", group: "modes" },
139871
- { name: "/config", usage: "/config", desc: "view or change saved settings", group: "settings" },
139872
- { name: "/init", usage: "/init", desc: "scan this repo and write a GEARBOX.md guide", group: "other" },
139873
- { name: "/keys", usage: "/keys", desc: "keyboard shortcuts", group: "other" },
139874
- { name: "/help", usage: "/help", desc: "this list", group: "other" },
139875
- { name: "/exit", usage: "/exit", desc: "quit gearbox", group: "other" }
139876
- ];
139877
- var HIDDEN = new Set(["/accounts", "/login", "/vim", "/ghost", "/cwd"]);
139878
- function matchCommands(draft) {
139879
- const q = draft.trim().toLowerCase();
139880
- if (!q.startsWith("/"))
139881
- return [];
139882
- const head2 = q.split(/\s+/)[0] ?? q;
139883
- if (head2 === "/")
139884
- return COMMANDS;
139885
- const prefix = COMMANDS.filter((c) => c.name.startsWith(head2));
139886
- if (prefix.length)
139887
- return prefix;
139888
- return fuzzyRank(COMMANDS, head2.slice(1), (c) => c.name.slice(1), 12);
139889
- }
139890
- var GROUP_TITLES = [
139891
- { id: "models", title: "models & routing" },
139892
- { id: "chat", title: "conversation" },
139893
- { id: "accounts", title: "accounts & cost" },
139894
- { id: "output", title: "save & copy" },
139895
- { id: "modes", title: "modes" },
139896
- { id: "settings", title: "settings" },
139897
- { id: "other", title: "other" }
139898
- ];
139899
- var ACCOUNT_ADD_HELP = `add an account:
139900
- ` + ` /account add claude Claude subscription (Pro/Max)
139901
- ` + ` /account add claude <name> a 2nd Claude account, e.g. /account add claude work
139902
- ` + ` /account add codex ChatGPT subscription (Plus/Pro)
139903
- ` + ` /account add codex <name> a 2nd ChatGPT account, e.g. /account add codex work
139904
- ` + ` /account add azure <foundry-endpoint> <api-key> Azure AI Foundry (pass the full https:// endpoint)
139905
- ` + ` /account add azure <resource-name> <api-key> [api-version] Azure OpenAI (pass the bare resource name)
139906
- ` + ` /account add openai-compat <name> <base-url> <api-key> <model> [model...]
139907
- ` + ` /account add <api-key> paste any provider key (auto-detected)
139908
- ` + ` /account add <provider> <api-key> e.g. anthropic, openai, openrouter
139909
- ` + "After adding, /account refresh discovers the models the account can actually serve.";
139910
- function helpText() {
139911
- const visible = COMMANDS.filter((c) => !HIDDEN.has(c.name));
139912
- const pad3 = Math.max(...visible.map((c) => c.name.length)) + 2;
139913
- const out = ["commands · type / to filter, or just say what you want"];
139914
- for (const g of GROUP_TITLES) {
139915
- const items = visible.filter((c) => c.group === g.id);
139916
- if (!items.length)
139917
- continue;
139918
- out.push("", g.title);
139919
- for (const c of items)
139920
- out.push(` ${c.name.padEnd(pad3)}${c.desc}`);
139921
- }
139922
- return out.join(`
139923
- `);
139924
- }
139925
- function accountName(a) {
139926
- if (a.exec === "cli") {
139927
- const bin = a.auth?.binary;
139928
- const base2 = bin === "codex" ? "ChatGPT" : "Claude";
139929
- const named = a.id.match(/-cli-(.+)$/);
139930
- return named ? `${base2} (${named[1]})` : base2;
139931
- }
139932
- return catalogProvider(a.provider)?.label ?? a.provider;
139933
- }
139934
- function accountSlug(a) {
139935
- return accountName(a).toLowerCase().replace(/[()]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
139936
- }
139937
- function accountLabel(a) {
139938
- return `${accountName(a)} · ${a.exec === "cli" ? "subscription" : "API key"}`;
139939
- }
139940
- function formatAccounts(accounts, activeCliId, importable, statuses = {}) {
139941
- const lines = ["your accounts"];
139942
- if (!accounts.length) {
139943
- lines.push(" (none yet)");
139944
- } else {
139945
- const active = activeCliId ? accounts.find((a) => a.id === activeCliId) : null;
139946
- if (active)
139947
- lines.push(` current: ${accountLabel(active)}`);
139948
- else
139949
- lines.push(" current: API routing");
139950
- lines.push("");
139951
- accounts.forEach((a, i2) => {
139952
- const mark = a.id === activeCliId ? glyph.on : " ";
139953
- const st = statuses[a.id];
139954
- const status = a.id === activeCliId ? "active" : st?.duplicateOf ? `same login as ${st.duplicateOf}` : st?.signedIn === false ? "not signed in" : st?.signedIn === true ? "signed in" : a.exec === "cli" ? "not checked" : "ready";
139955
- const alias = accountSlug(a);
139956
- lines.push(` ${mark} ${accountLabel(a).padEnd(34)} ${status}`);
139957
- lines.push(` use /account ${alias}${i2 + 1 ? ` (or ${i2 + 1})` : ""}`);
139958
- if (st?.detail && st.signedIn)
139959
- lines.push(` ${st.detail}`);
139960
- });
139961
- if (!activeCliId)
139962
- lines.push("", " no subscription active — your API keys auto-route per task");
139963
- }
139964
- if (importable.length) {
139965
- lines.push("", "found in your environment — /account import to add:");
139966
- for (const c of importable)
139967
- lines.push(` + ${c.label} (${c.envVar})`);
139968
- }
139969
- lines.push("", " switch: /account <name-or-number>", " add: /account add codex [name] · /account add claude [name] · /account add <api-key>", accounts.length ? " remove: /account remove <name-or-number>" : "", accounts.length ? " refresh models: /account refresh" : "");
139970
- return lines.filter(Boolean).join(`
139971
- `);
139972
- }
139973
- function buildContextView(sections, contextWindow, cwd2 = "") {
139974
- const total = sections.reduce((s2, x2) => s2 + x2.tokens, 0);
139975
- const max2 = Math.max(1, ...sections.map((s2) => s2.tokens));
139976
- const fmt = (n) => n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
139977
- const rows = sections.map((s2) => ({ label: s2.name, display: fmt(s2.tokens), frac: s2.tokens / max2 }));
139978
- const labelPad = Math.max("total".length, ...rows.map((r2) => r2.label.length));
139979
- const valuePad = Math.max(fmt(total).length, ...rows.map((r2) => r2.display.length));
139980
- return {
139981
- rows,
139982
- total: fmt(total),
139983
- windowPct: contextWindow ? Math.round(total / contextWindow * 100) : undefined,
139984
- windowLabel: contextWindow ? fmt(contextWindow) : undefined,
139985
- cwd: cwd2,
139986
- labelPad,
139987
- valuePad
139988
- };
139989
- }
139990
- var ENV_LABEL = {
139991
- anthropic: "ANTHROPIC_API_KEY",
139992
- openai: "OPENAI_API_KEY",
139993
- google: "GOOGLE_GENERATIVE_AI_API_KEY",
139994
- deepseek: "DEEPSEEK_API_KEY"
139995
- };
139996
- function formatModelList(currentId, showAll = false) {
139997
- const MODELS2 = modelRegistry();
139998
- const line = (m2) => ` ${m2.id === currentId ? glyph.on : glyph.off} ${m2.label.padEnd(18)} ${m2.provider}`;
139999
- const usable = MODELS2.filter((m2) => providerAvailable(m2.provider));
140000
- const rest2 = MODELS2.filter((m2) => !providerAvailable(m2.provider));
140001
- const rows = ["models · /model <name> pins one · /model auto routes per task"];
140002
- if (usable.length) {
140003
- rows.push("", "ready to use");
140004
- const CAP = 8;
140005
- const shown = new Map;
140006
- let hidden = 0;
140007
- for (const m2 of usable) {
140008
- const n = shown.get(m2.provider) ?? 0;
140009
- if (!showAll && n >= CAP) {
140010
- hidden++;
140011
- continue;
140012
- }
140013
- shown.set(m2.provider, n + 1);
140014
- rows.push(line(m2));
140015
- }
140016
- if (hidden)
140017
- rows.push(` + ${hidden} more on your accounts — /model all to list · /model <name> to pick`);
140018
- } else {
140019
- rows.push("", "no accounts yet — /account to add one");
140020
- }
140021
- if (showAll && rest2.length) {
140022
- rows.push("", "needs an account");
140023
- for (const m2 of rest2)
140024
- rows.push(` ${glyph.off} ${m2.label.padEnd(18)} ${m2.provider}`);
140025
- } else if (rest2.length) {
140026
- rows.push("", ` + ${rest2.length} more once you add a key — /model all to list · /account to add one`);
140027
- }
140028
- return rows.join(`
140029
- `);
140030
- }
140031
- function resolveModelSwitch(query) {
140032
- const q = query.trim().toLowerCase();
140033
- if (!q)
140034
- return { ok: false, message: "usage: /model <name>" };
140035
- const MODELS2 = modelRegistry();
140036
- const matches2 = MODELS2.filter((m3) => m3.label.toLowerCase().includes(q) || m3.id.toLowerCase().includes(q));
140037
- if (matches2.length === 0)
140038
- return { ok: false, message: `no model matching “${query}” — /model to list` };
140039
- const exact = matches2.find((m3) => m3.label.toLowerCase() === q || m3.id.toLowerCase() === q);
140040
- const available = matches2.filter((m3) => providerAvailable(m3.provider));
140041
- if (exact) {
140042
- if (!providerAvailable(exact.provider))
140043
- return { ok: false, message: `${exact.label}: no ${exact.provider} account yet — /account add ${exact.provider} <key> or set ${envHint(exact.provider)}` };
140044
- return { ok: true, modelId: exact.id, message: `model → ${exact.label}` };
140045
- }
140046
- if (available.length === 0) {
140047
- const m3 = matches2[0];
140048
- return { ok: false, message: `“${query}” matches ${m3.label} but no account for ${m3.provider} — /accounts add ${m3.provider} <key> or set ${envHint(m3.provider)}` };
140049
- }
140050
- if (available.length > 1) {
140051
- return { ok: false, message: `“${query}” matches ${available.map((m3) => m3.label).join(", ")} — be more specific` };
140052
- }
140053
- const m2 = available[0];
140054
- return { ok: true, modelId: m2.id, message: `model → ${m2.label}` };
140055
- }
140056
-
140057
140315
  // src/ui/components/CommandPalette.tsx
140058
140316
  var jsx_dev_runtime5 = __toESM(require_jsx_dev_runtime(), 1);
140059
140317
  function windowed(items, selected, limit) {
@@ -141873,7 +142131,7 @@ function Viewport({ lines, scrollTop, height, width, selection }) {
141873
142131
 
141874
142132
  // src/ui/lines.ts
141875
142133
  var limitColor2 = (pct) => pct >= 85 ? color.err : pct >= 60 ? color.accent : color.ok;
141876
- var accountStateColor2 = (status) => status === "active" || status === "signed in" || status === "ready" ? color.ok : status === "duplicate" ? color.accent : status === "not signed in" ? color.run : color.faint;
142134
+ var accountStateColor2 = (status) => status === "active" || status === "signed in" || status === "ready" || status.startsWith("✓") ? color.ok : status === "duplicate" ? color.accent : status === "not signed in" || status.startsWith("✗") ? color.run : status.startsWith("⚠") || status.startsWith("⏳") ? color.accent : color.faint;
141877
142135
  var BLANK = [];
141878
142136
  function clipSpans(spans, width) {
141879
142137
  const out = [];
@@ -142376,8 +142634,7 @@ function itemsToLines(items, width, expand = false) {
142376
142634
  { text: r2.name.padEnd(v.labelPad), color: color.text, bold: r2.active },
142377
142635
  { text: " " + r2.status.padEnd(v.statusPad), color: accountStateColor2(r2.status) },
142378
142636
  { text: " use ", color: color.faint },
142379
- { text: cmd.padEnd(commandWidth), color: color.accent, bold: true, bg: color.accentBg },
142380
- { text: " or " + r2.number, color: color.faint }
142637
+ { text: cmd.padEnd(commandWidth), color: color.accent, bold: true, bg: color.accentBg }
142381
142638
  ], width));
142382
142639
  if (r2.duplicateOf)
142383
142640
  out.push(clipSpans([{ text: " same login as ", color: color.faint }, { text: r2.duplicateOf, color: color.text }], width));
@@ -142395,7 +142652,7 @@ function itemsToLines(items, width, expand = false) {
142395
142652
  }
142396
142653
  out.push(BLANK);
142397
142654
  out.push(clipSpans([{ text: " add ", color: color.faint }, { text: "/account add codex [name]", color: color.accent }, { text: " /account add claude [name]", color: color.accent }, { text: " /account add <api-key>", color: color.accent }], width));
142398
- out.push(clipSpans([{ text: " remove ", color: color.faint }, { text: "/account remove <name-or-number>", color: color.accent }], width));
142655
+ out.push(clipSpans([{ text: " remove ", color: color.faint }, { text: "/account remove <name>", color: color.accent }], width));
142399
142656
  break;
142400
142657
  }
142401
142658
  case "usage": {
@@ -142476,6 +142733,18 @@ function itemsToLines(items, width, expand = false) {
142476
142733
  out.push(clipSpans([{ text: " working directory: " + v.cwd, color: color.faint }], width));
142477
142734
  break;
142478
142735
  }
142736
+ case "scorecard": {
142737
+ const toneColor = { title: color.text, colhead: color.faint, chosen: color.accent, row: color.dim, dim: color.faint, note: color.faint };
142738
+ let first = true;
142739
+ for (const r2 of scorecardRows(it.card)) {
142740
+ const prefix = first ? " " + glyph.notice + " " : " ";
142741
+ out.push(clipSpans([{ text: prefix, color: color.accentDim }, { text: r2.text, color: toneColor[r2.tone] ?? color.text }], width));
142742
+ if (first)
142743
+ out.push(BLANK);
142744
+ first = false;
142745
+ }
142746
+ break;
142747
+ }
142479
142748
  case "error": {
142480
142749
  let first = true;
142481
142750
  for (const para of it.text.split(`
@@ -142657,9 +142926,32 @@ class FixedSelector {
142657
142926
  }
142658
142927
  }
142659
142928
 
142929
+ // src/model/cooldown.ts
142930
+ function classifyFailure(message) {
142931
+ const m2 = (message || "").toLowerCase();
142932
+ const exhausted = /\b429\b|\b529\b|\b402\b/.test(m2) || /rate.?limit|too many requests|insufficient_quota|quota|over(loaded|capacity)|throttl|resource.?exhausted|usage.?limit|billing|payment required|out of credit|credit balance/.test(m2);
142933
+ return exhausted ? "exhausted" : "other";
142934
+ }
142935
+ var DEFAULT_COOLDOWN_MS = 5 * 60000;
142936
+ var cooldowns = new Map;
142937
+ function markExhausted(key, ms, reason, now2 = Date.now()) {
142938
+ cooldowns.set(key, { until: now2 + Math.max(0, ms), reason });
142939
+ }
142940
+ function coolingDown(key, now2 = Date.now()) {
142941
+ const c = cooldowns.get(key);
142942
+ if (!c)
142943
+ return false;
142944
+ if (c.until <= now2) {
142945
+ cooldowns.delete(key);
142946
+ return false;
142947
+ }
142948
+ return true;
142949
+ }
142950
+
142660
142951
  // src/model/router.ts
142661
142952
  init_providers();
142662
142953
  init_profiles();
142954
+ init_store();
142663
142955
 
142664
142956
  // src/model/preferences.ts
142665
142957
  import { readFileSync as readFileSync6, writeFileSync as writeFileSync5, mkdirSync as mkdirSync5 } from "node:fs";
@@ -142674,10 +142966,26 @@ function loadRoutingPreferences() {
142674
142966
  try {
142675
142967
  const f3 = JSON.parse(readFileSync6(file4(), "utf8"));
142676
142968
  if (f3?.byKind)
142677
- return { version: 1, byKind: f3.byKind };
142969
+ return { version: 1, byKind: f3.byKind, global: f3.global, budgets: f3.budgets };
142678
142970
  } catch {}
142679
142971
  return empty();
142680
142972
  }
142973
+ function loadBudgets() {
142974
+ return loadRoutingPreferences().budgets ?? {};
142975
+ }
142976
+ function setBudget(key, budget) {
142977
+ const prefs = loadRoutingPreferences();
142978
+ const budgets = { ...prefs.budgets ?? {} };
142979
+ if (budget)
142980
+ budgets[key] = budget;
142981
+ else
142982
+ delete budgets[key];
142983
+ prefs.budgets = budgets;
142984
+ save2(prefs);
142985
+ }
142986
+ function globalPreference() {
142987
+ return loadRoutingPreferences().global;
142988
+ }
142681
142989
  function save2(prefs) {
142682
142990
  try {
142683
142991
  mkdirSync5(dirname2(file4()), { recursive: true });
@@ -142703,6 +143011,119 @@ function confirmRoutingPreference(pref) {
142703
143011
 
142704
143012
  // src/model/router.ts
142705
143013
  init_capabilities();
143014
+
143015
+ // src/model/routing-context.ts
143016
+ init_store();
143017
+ var clamp01 = (n) => Math.max(0, Math.min(1, n));
143018
+ function headroomOf(u) {
143019
+ const snaps = u?.rates ?? (u?.rate ? [u.rate] : []);
143020
+ let rateHeadroom;
143021
+ let bindingWindow;
143022
+ let apiThrottle;
143023
+ for (const r2 of snaps) {
143024
+ const h2 = 1 - clamp01(r2.utilization);
143025
+ if (r2.type?.startsWith("api:")) {
143026
+ if (apiThrottle === undefined || h2 < apiThrottle)
143027
+ apiThrottle = h2;
143028
+ continue;
143029
+ }
143030
+ if (rateHeadroom === undefined || h2 < rateHeadroom) {
143031
+ rateHeadroom = h2;
143032
+ bindingWindow = { type: r2.type, utilization: r2.utilization, resetsAt: r2.resetsAt };
143033
+ }
143034
+ }
143035
+ return { rateHeadroom, bindingWindow, rateAt: snaps[0]?.at, apiThrottle };
143036
+ }
143037
+ function buildRoutingContext(now2, opts) {
143038
+ const accounts = opts?.accounts ?? listAccounts();
143039
+ const usageById = new Map((opts?.usage ?? loadUsage()).map((u) => [u.accountId, u]));
143040
+ const budgets = opts?.budgets ?? loadBudgets();
143041
+ const byAccountId = new Map;
143042
+ for (const acct of accounts) {
143043
+ if (!acct.enabled)
143044
+ continue;
143045
+ const u = usageById.get(acct.id);
143046
+ byAccountId.set(acct.id, {
143047
+ accountId: acct.id,
143048
+ provider: acct.provider,
143049
+ exec: acct.exec,
143050
+ isSubscription: acct.exec === "cli",
143051
+ ...balanceOf(acct, u, budgets, now2),
143052
+ ...headroomOf(u)
143053
+ });
143054
+ }
143055
+ return { now: now2, byAccountId };
143056
+ }
143057
+ function balanceOf(acct, u, budgets, now2) {
143058
+ if (acct.exec === "cli")
143059
+ return {};
143060
+ if (u?.balance?.remainingUSD !== undefined) {
143061
+ return { balanceRemainingUSD: u.balance.remainingUSD, balanceTotalUSD: u.balance.totalUSD, balanceAt: u.balance.at };
143062
+ }
143063
+ const budget = budgets[acct.id] ?? budgets[acct.provider];
143064
+ if (!budget)
143065
+ return {};
143066
+ const spent = u ? spentInPeriod(u, budget.period, now2) : 0;
143067
+ return {
143068
+ balanceRemainingUSD: Math.max(0, budget.amountUSD - spent),
143069
+ balanceTotalUSD: budget.amountUSD,
143070
+ balanceAt: now2,
143071
+ balanceEstimated: true
143072
+ };
143073
+ }
143074
+
143075
+ // src/model/scoring.ts
143076
+ var DEFAULT_WEIGHTS = {
143077
+ wScarcity: 1,
143078
+ wSwitch: 0.15,
143079
+ wPlan: 1,
143080
+ wLimit: 2,
143081
+ wApiThrottle: 0.5,
143082
+ planHeadroomKnee: 0.2,
143083
+ apiThrottleKnee: 0.15,
143084
+ scarcityStaleMs: 15 * 60000
143085
+ };
143086
+ var clamp2 = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
143087
+ function scoreCandidate(c, input) {
143088
+ const w = input.weights ?? DEFAULT_WEIGHTS;
143089
+ const inTok = input.estInputTokens;
143090
+ const outTok = input.estOutputTokens ?? 0.2 * inTok;
143091
+ const costEst = inTok / 1e6 * c.inUSDPerMtok + outTok / 1e6 * c.outUSDPerMtok;
143092
+ const a = c.account;
143093
+ let planBonus = 0;
143094
+ const meteredEquiv = costEst;
143095
+ if (a.isSubscription) {
143096
+ const headroom = a.rateHeadroom ?? 1;
143097
+ const ramp = clamp2(headroom / w.planHeadroomKnee, 0, 1);
143098
+ planBonus = w.wPlan * meteredEquiv * ramp;
143099
+ }
143100
+ let scarcity = 0;
143101
+ if (!a.isSubscription && a.balanceRemainingUSD !== undefined) {
143102
+ const fresh = a.balanceAt === undefined || input.now - a.balanceAt <= w.scarcityStaleMs;
143103
+ if (fresh)
143104
+ scarcity = w.wScarcity * (costEst / Math.max(a.balanceRemainingUSD, 0.000001));
143105
+ }
143106
+ let limitPenalty = 0;
143107
+ if (a.isSubscription && a.rateHeadroom !== undefined && a.rateHeadroom < w.planHeadroomKnee) {
143108
+ limitPenalty = w.wLimit * ((w.planHeadroomKnee - a.rateHeadroom) / w.planHeadroomKnee);
143109
+ }
143110
+ let apiThrottlePenalty = 0;
143111
+ if (!a.isSubscription && a.apiThrottle !== undefined && a.apiThrottle < w.apiThrottleKnee) {
143112
+ apiThrottlePenalty = w.wApiThrottle * ((w.apiThrottleKnee - a.apiThrottle) / w.apiThrottleKnee);
143113
+ }
143114
+ const warm = !!input.warm && input.warm.accountId === a.accountId && input.warm.modelId === c.id;
143115
+ const switchPenalty = input.warm && !warm ? w.wSwitch * costEst : 0;
143116
+ const score = costEst + scarcity + switchPenalty + limitPenalty + apiThrottlePenalty - planBonus;
143117
+ return { candidate: c, score, costEst, terms: { costEst, scarcity, switchPenalty, limitPenalty, apiThrottlePenalty, planBonus, meteredEquiv } };
143118
+ }
143119
+ function pickBest(input) {
143120
+ const scored = input.candidates.map((c) => scoreCandidate(c, input));
143121
+ scored.sort((a, b) => a.score - b.score || b.candidate.tps - a.candidate.tps || b.candidate.quality - a.candidate.quality || a.candidate.id.localeCompare(b.candidate.id));
143122
+ return scored[0];
143123
+ }
143124
+
143125
+ // src/model/router.ts
143126
+ var NOMINAL_INPUT_TOKENS = 16000;
142706
143127
  var BAR = {
142707
143128
  summarize: 0,
142708
143129
  classify: 0,
@@ -142726,8 +143147,8 @@ function classify(prompt) {
142726
143147
  return "search";
142727
143148
  return "code";
142728
143149
  }
142729
- function qualityOf(m2) {
142730
- const pr = profileFor(m2.id);
143150
+ function qualityOf(c) {
143151
+ const pr = profileFor(c.canonicalId ?? c.spec.id);
142731
143152
  if (!pr)
142732
143153
  return 0.5;
142733
143154
  if (pr.quality.sweBenchVerified != null)
@@ -142736,14 +143157,16 @@ function qualityOf(m2) {
142736
143157
  return pr.quality.intelligenceIndex / 100;
142737
143158
  return 0.5;
142738
143159
  }
142739
- function costOf(m2) {
142740
- const pr = profileFor(m2.id);
142741
- if (!pr)
142742
- return Number.POSITIVE_INFINITY;
142743
- return pr.cost.inUSDPerMtok + 0.2 * pr.cost.outUSDPerMtok;
143160
+ function costPair(c) {
143161
+ const cost = profileFor(c.canonicalId ?? c.spec.id)?.cost ?? c.spec.cost;
143162
+ return cost ?? { inUSDPerMtok: 1e6, outUSDPerMtok: 1e6 };
143163
+ }
143164
+ function tpsOf(c) {
143165
+ return profileFor(c.canonicalId ?? c.spec.id)?.latency?.tps ?? 0;
142744
143166
  }
142745
- function tpsOf(m2) {
142746
- return profileFor(m2.id)?.latency?.tps ?? 0;
143167
+ function toScoreCandidate(c) {
143168
+ const cost = costPair(c);
143169
+ return { id: c.spec.id, inUSDPerMtok: cost.inUSDPerMtok, outUSDPerMtok: cost.outUSDPerMtok, quality: qualityOf(c), tps: tpsOf(c), account: c.state };
142747
143170
  }
142748
143171
 
142749
143172
  class RoutingSelector {
@@ -142751,58 +143174,231 @@ class RoutingSelector {
142751
143174
  constructor(fallbackId) {
142752
143175
  this.fallbackId = fallbackId;
142753
143176
  }
142754
- select(task) {
143177
+ enumerate(ctx) {
143178
+ const out = [];
143179
+ const neutral = (id, provider) => ctx.byAccountId.get(id) ?? { accountId: id, provider, exec: "in-loop", isSubscription: false };
143180
+ for (const m2 of modelRegistry().filter((mm) => providerAvailable(mm.provider))) {
143181
+ const accts = accountsForProvider(m2.provider).filter((a) => a.enabled && a.exec !== "cli");
143182
+ if (accts.length === 0) {
143183
+ out.push({ spec: m2, canonicalId: m2.id, backend: { kind: "in-loop" }, state: neutral(`env:${m2.provider}`, m2.provider) });
143184
+ } else {
143185
+ for (const a of accts)
143186
+ out.push({ spec: m2, canonicalId: m2.id, backend: { kind: "in-loop", account: a }, state: neutral(a.id, m2.provider) });
143187
+ }
143188
+ }
143189
+ for (const seat of subscriptionSeats()) {
143190
+ const state = ctx.byAccountId.get(seat.account.id) ?? { accountId: seat.account.id, provider: seat.account.provider, exec: "cli", isSubscription: true };
143191
+ out.push({ spec: seat.spec, canonicalId: seat.canonicalId, backend: { kind: "cli", account: seat.account, binary: seat.binary, profile: seat.profile }, state });
143192
+ }
143193
+ const live = out.filter((c) => !coolingDown(c.state.accountId, ctx.now));
143194
+ return live.length ? live : out;
143195
+ }
143196
+ prepare(task) {
142755
143197
  const kind = task.kind ?? classify(task.prompt);
142756
143198
  const bar = BAR[kind];
142757
143199
  const required2 = task.requires ?? [];
142758
- const available = modelRegistry().filter((m2) => providerAvailable(m2.provider));
142759
- const capable = required2.length ? available.filter((m2) => supportsRequirements(m2, required2)) : available;
142760
- if (available.length > 0 && capable.length === 0) {
142761
- const missing = available.slice(0, 4).map((m2) => `${m2.label}: ${missingRequirements(m2, required2).join(", ")}`).join("; ");
142762
- throw new Error(`No configured model supports this turn (${required2.join(", ")} required). ${missing}`);
142763
- }
142764
- if (available.length === 0) {
143200
+ const ctx = buildRoutingContext(ctx_now());
143201
+ const estInputTokens = task.estTokens || NOMINAL_INPUT_TOKENS;
143202
+ const all = this.enumerate(ctx);
143203
+ if (all.length === 0) {
142765
143204
  const m2 = pickDefaultModel(this.fallbackId);
142766
- if (!m2) {
142767
- throw new Error("No model available. Set a key: ANTHROPIC_API_KEY / OPENAI_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY / DEEPSEEK_API_KEY");
142768
- }
142769
- return { model: m2, reason: "only model with a key" };
143205
+ return { kind, bar, required: required2, ctx, pool: [], clears: [], estInputTokens, fallback: m2 ?? undefined };
143206
+ }
143207
+ const capable = required2.length ? all.filter((c) => supportsRequirements(c.spec, required2)) : all;
143208
+ if (capable.length === 0) {
143209
+ const missing = all.slice(0, 4).map((c) => `${c.spec.label}: ${missingRequirements(c.spec, required2).join(", ")}`).join("; ");
143210
+ throw new Error(`No configured model supports this turn (${required2.join(", ")} required). ${missing}`);
142770
143211
  }
142771
143212
  const need = (task.estTokens ?? 0) * 1.2;
142772
- const base2 = capable.length ? capable : available;
142773
- const fits = need > 0 ? base2.filter((m2) => m2.contextWindow >= need) : base2;
142774
- const pool = fits.length ? fits : base2;
142775
- const clears = pool.filter((m2) => qualityOf(m2) >= bar);
142776
- const candidates = clears.length ? clears : pool;
143213
+ const fits = need > 0 ? capable.filter((c) => c.spec.contextWindow >= need) : capable;
143214
+ let pool = fits.length ? fits : capable;
143215
+ pool = applyGlobalPreference(pool);
143216
+ const clears = pool.filter((c) => qualityOf(c) >= bar);
143217
+ return { kind, bar, required: required2, ctx, pool, clears, estInputTokens };
143218
+ }
143219
+ preferredIn(kind, candidates) {
142777
143220
  const pref = preferenceFor(kind);
142778
- const preferredPool = pref?.modelId ? pool.find((m2) => m2.id === pref.modelId) : pref?.provider ? pool.find((m2) => m2.provider === pref.provider) : undefined;
142779
- const preferred = pref?.modelId ? candidates.find((m2) => m2.id === pref.modelId) : pref?.provider ? candidates.find((m2) => m2.provider === pref.provider) : undefined;
142780
- if (preferred && qualityOf(preferred) >= bar) {
142781
- return { model: preferred, reason: `${kind} · remembered preference` };
143221
+ return pref?.modelId ? candidates.find((c) => c.canonicalId === pref.modelId || c.spec.id === pref.modelId) : pref?.provider ? candidates.find((c) => c.spec.provider === pref.provider) : undefined;
143222
+ }
143223
+ select(task) {
143224
+ const p = this.prepare(task);
143225
+ if (p.pool.length === 0) {
143226
+ if (!p.fallback) {
143227
+ throw new Error("No model available. Set a key: ANTHROPIC_API_KEY / OPENAI_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY / DEEPSEEK_API_KEY");
143228
+ }
143229
+ return { model: p.fallback, reason: "only model with a key", backend: { kind: "in-loop" } };
143230
+ }
143231
+ const candidates = p.clears.length ? p.clears : p.pool;
143232
+ const preferred = this.preferredIn(p.kind, candidates);
143233
+ if (preferred)
143234
+ return { model: preferred.spec, reason: `${p.kind} · remembered preference`, backend: preferred.backend };
143235
+ const best = pickBest({ candidates: candidates.map(toScoreCandidate), now: p.ctx.now, estInputTokens: p.estInputTokens });
143236
+ const winner = candidates.find((c) => c.spec.id === best.candidate.id);
143237
+ return { model: winner.spec, reason: reasonFor(winner, p.kind, p.required), backend: winner.backend };
143238
+ }
143239
+ explain(task) {
143240
+ const p = this.prepare(task);
143241
+ const entries = [];
143242
+ if (p.pool.length === 0) {
143243
+ return { kind: p.kind, bar: p.bar, prompt: task.prompt, entries, note: p.fallback ? `only ${p.fallback.label} has a key` : "no model available" };
143244
+ }
143245
+ const candidates = p.clears.length ? p.clears : p.pool;
143246
+ const preferred = this.preferredIn(p.kind, candidates);
143247
+ const scored = new Map;
143248
+ for (const s2 of p.pool.map((c) => scoreCandidate(toScoreCandidate(c), { candidates: [], now: p.ctx.now, estInputTokens: p.estInputTokens })))
143249
+ scored.set(s2.candidate.id, s2);
143250
+ const winnerId = preferred ? preferred.spec.id : pickBest({ candidates: candidates.map(toScoreCandidate), now: p.ctx.now, estInputTokens: p.estInputTokens }).candidate.id;
143251
+ for (const c of p.pool) {
143252
+ const s2 = scored.get(c.spec.id);
143253
+ const clears = qualityOf(c) >= p.bar;
143254
+ const chosen = c.spec.id === winnerId;
143255
+ entries.push({
143256
+ label: c.spec.label,
143257
+ backend: c.backend.kind === "cli" ? "seat" : "api",
143258
+ quality: qualityOf(c),
143259
+ qualitySrc: profileFor(c.canonicalId ?? c.spec.id)?.quality.src ?? "seeded",
143260
+ estCostPerMtok: costPerMtok(c),
143261
+ balanceText: balanceText(c.state),
143262
+ headroomText: headroomText(c.state),
143263
+ score: s2.score,
143264
+ chosen,
143265
+ verdict: chosen ? preferred ? "preferred" : "chosen" : !clears ? "below bar" : verdictFor(c, s2)
143266
+ });
142782
143267
  }
142783
- candidates.sort((a, b) => costOf(a) - costOf(b) || tpsOf(b) - tpsOf(a) || qualityOf(b) - qualityOf(a));
142784
- const model = candidates[0];
142785
- const skipped = preferredPool && qualityOf(preferredPool) < bar ? ` · ${preferredPool.label} skipped below quality bar` : "";
142786
- const caps = required2.length ? ` · ${required2.join("+")} required` : "";
142787
- const reason = `${kind}${caps} · $${costOf(model).toFixed(2)}/Mtok${skipped}`;
142788
- return { model, reason };
143268
+ entries.sort((a, b) => Number(b.chosen) - Number(a.chosen) || Number(b.verdict !== "below bar") - Number(a.verdict !== "below bar") || a.score - b.score);
143269
+ return { kind: p.kind, bar: p.bar, prompt: task.prompt, entries };
142789
143270
  }
142790
143271
  }
143272
+ function costPerMtok(c) {
143273
+ const { inUSDPerMtok, outUSDPerMtok } = costPair(c);
143274
+ return inUSDPerMtok + 0.2 * outUSDPerMtok;
143275
+ }
143276
+ function balanceText(s2) {
143277
+ if (s2.isSubscription || s2.balanceRemainingUSD === undefined)
143278
+ return;
143279
+ const v = s2.balanceRemainingUSD;
143280
+ const amt = v >= 100 ? `$${Math.round(v)}` : `$${v.toFixed(2)}`;
143281
+ return s2.balanceEstimated ? `${amt} est` : amt;
143282
+ }
143283
+ function headroomText(s2) {
143284
+ if (s2.isSubscription && s2.rateHeadroom !== undefined)
143285
+ return `${Math.round(s2.rateHeadroom * 100)}% left`;
143286
+ if (!s2.isSubscription && s2.apiThrottle !== undefined && s2.apiThrottle < 0.15)
143287
+ return "throttling";
143288
+ return;
143289
+ }
143290
+ function verdictFor(c, s2) {
143291
+ if (c.state.isSubscription)
143292
+ return "seat ~free";
143293
+ if (s2.terms.scarcity > s2.terms.costEst)
143294
+ return "scarce credit";
143295
+ if (s2.terms.apiThrottlePenalty > 0)
143296
+ return "near limit";
143297
+ return "ok";
143298
+ }
143299
+ function applyGlobalPreference(pool) {
143300
+ const g = globalPreference();
143301
+ if (!g)
143302
+ return pool;
143303
+ let p = pool;
143304
+ const keep = (next) => {
143305
+ if (next.length)
143306
+ p = next;
143307
+ };
143308
+ if (g.prefer === "subscription")
143309
+ keep(p.filter((c) => c.state.isSubscription));
143310
+ else if (g.prefer === "api")
143311
+ keep(p.filter((c) => !c.state.isSubscription));
143312
+ if (g.accountId)
143313
+ keep(p.filter((c) => c.state.accountId === g.accountId));
143314
+ if (g.provider)
143315
+ keep(p.filter((c) => c.spec.provider === g.provider || c.state.provider === g.provider));
143316
+ return p;
143317
+ }
143318
+ function reasonFor(c, kind, required2) {
143319
+ const caps = required2.length ? ` · ${required2.join("+")} required` : "";
143320
+ if (c.backend.kind === "cli")
143321
+ return `${kind}${caps} · ${c.backend.binary} subscription · seat`;
143322
+ const { inUSDPerMtok, outUSDPerMtok } = costPair(c);
143323
+ return `${kind}${caps} · $${(inUSDPerMtok + 0.2 * outUSDPerMtok).toFixed(2)}/Mtok`;
143324
+ }
143325
+ function ctx_now() {
143326
+ return Date.now();
143327
+ }
143328
+
143329
+ // src/model/rate-headers.ts
143330
+ function parseGoDuration(s2) {
143331
+ if (!s2)
143332
+ return;
143333
+ const re = /(\d+(?:\.\d+)?)(ms|h|m|s)/g;
143334
+ let total = 0;
143335
+ let matched = false;
143336
+ let m2;
143337
+ while (m2 = re.exec(s2)) {
143338
+ matched = true;
143339
+ const n = Number(m2[1]);
143340
+ total += m2[2] === "h" ? n * 3600 : m2[2] === "m" ? n * 60 : m2[2] === "ms" ? n / 1000 : n;
143341
+ }
143342
+ return matched ? total : undefined;
143343
+ }
143344
+ var num = (v) => {
143345
+ if (v == null)
143346
+ return;
143347
+ const n = Number(v);
143348
+ return Number.isFinite(n) ? n : undefined;
143349
+ };
143350
+ var clamp012 = (n) => Math.max(0, Math.min(1, n));
143351
+ function resetSeconds(raw, now2) {
143352
+ if (!raw)
143353
+ return;
143354
+ const iso = Date.parse(raw);
143355
+ if (!Number.isNaN(iso))
143356
+ return Math.floor(iso / 1000);
143357
+ const dur = parseGoDuration(raw);
143358
+ if (dur != null)
143359
+ return Math.floor(now2 / 1000) + Math.round(dur);
143360
+ const secs = num(raw);
143361
+ return secs != null ? Math.floor(now2 / 1000) + Math.round(secs) : undefined;
143362
+ }
143363
+ function window2(limit, remaining, reset, type, now2) {
143364
+ if (limit == null || limit <= 0 || remaining == null)
143365
+ return null;
143366
+ return { utilization: clamp012(1 - remaining / limit), resetsAt: resetSeconds(reset, now2), type };
143367
+ }
143368
+ function parseRateHeaders(_provider, headers, now2) {
143369
+ const h2 = {};
143370
+ for (const [k, v] of Object.entries(headers))
143371
+ h2[k.toLowerCase()] = v;
143372
+ const out = [];
143373
+ const aReq = window2(num(h2["anthropic-ratelimit-requests-limit"]), num(h2["anthropic-ratelimit-requests-remaining"]), h2["anthropic-ratelimit-requests-reset"], "api:requests", now2);
143374
+ const aTok = window2(num(h2["anthropic-ratelimit-tokens-limit"]), num(h2["anthropic-ratelimit-tokens-remaining"]), h2["anthropic-ratelimit-tokens-reset"], "api:tokens", now2);
143375
+ if (aReq)
143376
+ out.push(aReq);
143377
+ if (aTok)
143378
+ out.push(aTok);
143379
+ const oReq = window2(num(h2["x-ratelimit-limit-requests"]), num(h2["x-ratelimit-remaining-requests"]), h2["x-ratelimit-reset-requests"], "api:requests", now2);
143380
+ const oTok = window2(num(h2["x-ratelimit-limit-tokens"]), num(h2["x-ratelimit-remaining-tokens"]), h2["x-ratelimit-reset-tokens"], "api:tokens", now2);
143381
+ if (oReq && !aReq)
143382
+ out.push(oReq);
143383
+ if (oTok && !aTok)
143384
+ out.push(oTok);
143385
+ return out;
143386
+ }
142791
143387
 
142792
143388
  // src/ui/App.tsx
142793
143389
  init_reasoning();
142794
143390
  init_providers();
142795
143391
 
142796
143392
  // src/ui/panel.ts
142797
- var clamp2 = (n, lo, hi) => Math.max(lo, Math.min(n, hi));
142798
- var clampIndex = (i2, count) => count <= 0 ? 0 : clamp2(i2, 0, count - 1);
142799
- var clampScroll = (s2, max2) => clamp2(s2, 0, Math.max(0, max2));
143393
+ var clamp3 = (n, lo, hi) => Math.max(lo, Math.min(n, hi));
143394
+ var clampIndex = (i2, count) => count <= 0 ? 0 : clamp3(i2, 0, count - 1);
143395
+ var clampScroll = (s2, max2) => clamp3(s2, 0, Math.max(0, max2));
142800
143396
  var panelBodyHeight = (height) => Math.max(1, height - 2);
142801
143397
  function windowStart(index, count, viewH) {
142802
143398
  if (count <= viewH)
142803
143399
  return 0;
142804
143400
  const half = Math.floor(viewH / 2);
142805
- return clamp2(index - half, 0, count - viewH);
143401
+ return clamp3(index - half, 0, count - viewH);
142806
143402
  }
142807
143403
  function filterModelRows(rows, filter7) {
142808
143404
  const q = filter7.trim().toLowerCase();
@@ -142893,6 +143489,21 @@ function Panel({
142893
143489
  r2.status
142894
143490
  ]
142895
143491
  }, undefined, true, undefined, this),
143492
+ r2.detail ? /* @__PURE__ */ jsx_dev_runtime12.jsxDEV(Text, {
143493
+ color: color.faint,
143494
+ children: [
143495
+ " · ",
143496
+ r2.detail
143497
+ ]
143498
+ }, undefined, true, undefined, this) : null,
143499
+ r2.type === "subscription" && !(r2.detail && r2.detail.includes("@")) ? /* @__PURE__ */ jsx_dev_runtime12.jsxDEV(Text, {
143500
+ color: color.accentDim,
143501
+ children: [
143502
+ " · /account login ",
143503
+ r2.alias,
143504
+ " to identify"
143505
+ ]
143506
+ }, undefined, true, undefined, this) : null,
142896
143507
  r2.active ? /* @__PURE__ */ jsx_dev_runtime12.jsxDEV(Text, {
142897
143508
  color: color.ok,
142898
143509
  children: [
@@ -142905,7 +143516,7 @@ function Panel({
142905
143516
  }, r2.alias, true, undefined, this);
142906
143517
  })
142907
143518
  }, undefined, false, undefined, this);
142908
- hint = "↑↓ move · ⏎ switch · 1–9 jump · esc close";
143519
+ hint = "↑↓ move · ⏎ switch · esc close";
142909
143520
  } else {
142910
143521
  const rows = filterModelRows(models ?? [], panel.filter);
142911
143522
  const idx = clampIndex(panel.index, rows.length);
@@ -144542,13 +145153,19 @@ var resultSummary = (out) => {
144542
145153
  async function runTask(opts) {
144543
145154
  const { model, messages, onEvent, signal, plan } = opts;
144544
145155
  const usage = { inputTokens: 0, outputTokens: 0 };
145156
+ let failureMessage;
144545
145157
  const providerOptions = opts.effort ? reasoningOptions(model, opts.effort) : {};
144546
145158
  let errored = false;
145159
+ let producedOutput = false;
145160
+ let failureRaw = undefined;
144547
145161
  const emitErr = (err) => {
144548
145162
  if (errored || signal?.aborted)
144549
145163
  return;
144550
145164
  errored = true;
144551
- onEvent({ type: "error", message: unavailableModelHint(cleanError(err), model) });
145165
+ failureMessage = cleanError(err);
145166
+ failureRaw = err;
145167
+ if (!opts.deferTerminal)
145168
+ onEvent({ type: "error", message: unavailableModelHint(failureMessage, model) });
144552
145169
  };
144553
145170
  onEvent({ type: "phase", label: "contacting model", detail: model.label, state: "running" });
144554
145171
  const activeTools = await createToolset(onEvent, { readOnly: Boolean(plan) });
@@ -144603,6 +145220,7 @@ async function runTask(opts) {
144603
145220
  case "text-delta": {
144604
145221
  const t2 = part.text ?? part.textDelta ?? "";
144605
145222
  if (t2) {
145223
+ producedOutput = true;
144606
145224
  onEvent({ type: "text", text: t2 });
144607
145225
  await maybePaint();
144608
145226
  }
@@ -144613,6 +145231,7 @@ async function runTask(opts) {
144613
145231
  const name31 = part.toolName ?? part.name ?? "tool";
144614
145232
  names.set(id, name31);
144615
145233
  started.add(id);
145234
+ producedOutput = true;
144616
145235
  openStream(id, name31);
144617
145236
  onEvent({ type: "tool-start", id, name: name31, arg: "" });
144618
145237
  onEvent({ type: "phase", label: friendlyToolPhase(name31), state: "running" });
@@ -144626,6 +145245,7 @@ async function runTask(opts) {
144626
145245
  const st = streams.get(id) ?? openStream(id, names.get(id) ?? "tool");
144627
145246
  if (!started.has(id)) {
144628
145247
  started.add(id);
145248
+ producedOutput = true;
144629
145249
  onEvent({ type: "tool-start", id, name: st.name, arg: "" });
144630
145250
  }
144631
145251
  st.rawBuf += chunk2;
@@ -144653,6 +145273,7 @@ async function runTask(opts) {
144653
145273
  onEvent({ type: "tool-stream", id, arg });
144654
145274
  } else {
144655
145275
  started.add(id);
145276
+ producedOutput = true;
144656
145277
  onEvent({ type: "tool-start", id, name: name31, arg });
144657
145278
  onEvent({ type: "phase", label: friendlyToolPhase(name31), detail: arg, state: "running" });
144658
145279
  }
@@ -144692,15 +145313,20 @@ async function runTask(opts) {
144692
145313
  emitErr(e2);
144693
145314
  }
144694
145315
  let next = messages;
145316
+ let headers;
144695
145317
  if (result2) {
144696
145318
  try {
144697
145319
  const resp = await result2.response;
144698
145320
  next = [...messages, ...resp.messages];
145321
+ headers = resp.headers;
144699
145322
  } catch {}
144700
145323
  }
144701
- onEvent({ type: "phase", label: errored ? "blocked" : "finished", state: errored ? "err" : "ok" });
144702
- onEvent({ type: "done", usage });
144703
- return { messages: next, usage };
145324
+ const failure = errored ? { message: failureMessage ?? cleanError(failureRaw), raw: failureRaw, producedOutput } : undefined;
145325
+ if (!opts.deferTerminal) {
145326
+ onEvent({ type: "phase", label: errored ? "blocked" : "finished", state: errored ? "err" : "ok" });
145327
+ onEvent({ type: "done", usage });
145328
+ }
145329
+ return { messages: next, usage, headers, failure };
144704
145330
  }
144705
145331
  async function runCompletion(opts) {
144706
145332
  const { model, system, prompt, onEvent, signal } = opts;
@@ -144900,7 +145526,7 @@ bun run typecheck
144900
145526
  },
144901
145527
  {
144902
145528
  file: "CLAUDE.md",
144903
- text: "# Gearbox — project guide\n\nGearbox is a multi-provider coding harness for the terminal: a beautiful, simple terminal agent that reads/writes code and runs commands, talking to any provider (Anthropic, OpenAI, Google, DeepSeek) through one clean loop.\n\n**The point of the project:** intelligent per-task *model routing* — automatically picking the right model for each task across every provider and account you pay for. Basic routing is live (`RoutingSelector` — classify → quality bar → cheapest winner); the richer engine (shadow-eval, credit/limit penalties, confidence display) layers on top of the same seam. See `DESIGN.md` for the full vision and `experiments/FINDINGS.md` for the validation behind it.\n\n## The one rule that matters\n\n**Keep the routing seam clean.** The agent must never hardcode a model. It asks a `ModelSelector` for the model to use. `RoutingSelector` is the live default (classify task → filter by quality bar → cheapest winner); `FixedSelector` is used only when a model is explicitly pinned (`--model` flag or `/model <name>`). Concretely:\n\n- `src/model/selector.ts` — the seam. `select(task) => ModelChoice`. Do not bypass it.\n- `src/model/router.ts` — `RoutingSelector`: classify prompt → quality bar → cost-sort candidates → respect `/prefer` preferences.\n- `src/model/profiles.ts` — the data corpus: quality, cost, latency, tokenizer calibration per model. Routing reads this.\n- `src/providers.ts` — maps a provider+model id to an AI SDK model instance. Already multi-provider. Adding a model is data, not code.\n- Every model call captures token usage (`src/agent/run.ts`) so the cost engine has data. Do not drop usage.\n- The UI consumes a normalized `AgentEvent` stream (`src/agent/events.ts`), never the AI SDK's raw types. This decouples the UI from the provider layer and from routing.\n\nIf you find yourself writing `anthropic('claude-...')` anywhere outside `providers.ts`, stop — route it through the selector.\n\n## Layout\n\n```\nsrc/\n cli.tsx entry point; renders the Ink app; picks RoutingSelector by default\n config.ts minimal config (default model, provider from env)\n providers.ts provider+model id -> AI SDK model (multi-provider; contextWindow per model)\n commands.ts slash-command metadata + pure helpers (fuzzy model match, /help, model list)\n tools.ts read / write / edit / list / search / glob / run_shell (AI SDK tools)\n model/\n selector.ts THE ROUTING SEAM — ModelSelector interface + FixedSelector (pinned model)\n router.ts RoutingSelector: classify → quality bar → cost-sort → preferences (the live default)\n profiles.ts model corpus: quality (SWE-bench), cost ($/Mtok), latency, tokenizer calibration\n tokens.ts calibrated token counting (js-tiktoken × per-model calibration factor)\n preferences.ts persist /prefer kind model choices to ~/.gearbox/routing-preferences.json\n reasoning.ts reasoning/thinking config helpers\n context/\n builder.ts context engine: system + memory + repo map + retrieved files + curated history\n retrieve.ts BM25 lexical retrieval — top-K relevant files for a prompt (no model call)\n repomap.ts repo structure summary for the system prompt\n memory.ts project memory (GEARBOX.md / CLAUDE.md loaded into context)\n compact.ts context compaction (/compact)\n accounts/\n types.ts Account + AuthMethod types (API key, AWS, Azure, Vertex, CLI, OpenAI-compat)\n store.ts accounts.json persistence (~/.gearbox/accounts.json)\n catalog.ts provider catalog (known providers, env vars, labels)\n detect.ts auto-detect env creds + cloud credentials\n onboard.ts interactive add/test account flows\n resolve.ts credential resolution (Account → ResolvedCreds, fetching secrets on demand)\n discover.ts per-account model discovery (Azure deployments / Foundry / gateway /models) → account.models; catalog defaultModels are seeds, not callable ids\n usage.ts per-account spend ledger + rate-limit snapshots + balance tracking\n balance.ts provider balance fetch helpers\n help/\n ask.ts /ask corpus: bundled docs + generated command reference, system prompt, meta-question auto-detect\n agent/\n events.ts AgentEvent — normalized stream the UI consumes\n run.ts real agent loop (AI SDK streamText -> AgentEvent), abort-aware; runCompletion = tool-less grounded answer (used by /ask)\n cli-backend.ts claude/codex CLI subprocess backend (for Pro/Max subscriptions)\n mock.ts scripted demo stream (runs with no API key; used by tests)\n ui/\n theme.ts colors + glyphs (the look)\n input.ts pure key→action reducer for the composer (tested)\n history.ts pure ↑/↓ prompt-history nav (tested)\n net.ts background online probe; status bar shows ⚠ offline when down\n useTerminalSize.ts reactive width on resize (everything reflows)\n git.ts current branch for the status line\n App.tsx the Ink app: state, useInput dispatch, commands, turns\n components/ Banner, Transcript, Composer, CommandPalette, StatusBar, PermissionPrompt, Panel\n panel.ts dismissable command-panel model + pure helpers (clamp/window/filter); tested\ntest/ pure-logic + render tests (ink-testing-library); no keys\nDESIGN.md full product vision (routing, requirements, UX)\nexperiments/ prototypes that validated the architecture\n```\n\nThe composer is custom (Ink `useInput` + `src/ui/input.ts`), not a third-party widget — full control over the cursor, ↑/↓ history, and esc-to-interrupt, with no focus/remount fragility. **Multi-line**: ⌃J (or shift/alt+⏎) inserts a newline, ⏎ submits; ↑/↓ move between lines and fall through to history at the top/bottom line; bracketed paste (enabled in `cli.tsx`) inserts multi-line text literally (CR normalized, paste markers stripped) instead of submitting per line. `caretPos()` is the shared line/col helper. **Readline editing** (all pure in `input.ts`, tested): ⌃U/⌃K kill to line start/end, ⌃W / ⌥⌫ kill word, ⌃D forward-delete, ⌥/⌃ + ←→ word-jump, ⌃A/⌃E line home/end. Keys: ⏎ send · ⌃J newline · ↑↓ line/history · ← → cursor · ⌥←→ word · tab complete @file · **shift+tab cycles mode (normal · auto-accept · plan)** · ⌃Y copy last reply · esc interrupt · ⌃c quit. `/keys` shows the cheatsheet.\n\n**Modes & effort.** Three input modes cycled by shift+tab (`App.tsx` `cycleMode`): **normal** (asks before writes/edits/shell), **auto-accept** (file writes/edits apply without asking — the permission broker auto-resolves `write`/`edit`; shell still gated; diffs still render), **plan** (read-only). Plus **yolo** (auto-approve everything) via `/yolo`. **Effort tiers** (`/effort fast|balanced|max`, or `setEffort`) pin the model through the routing seam (fast→haiku, balanced/max→sonnet) — the active mode + `⚡effort` show as badges in the `StatusBar`. **Click pickers** (fullscreen only): clicking the **model** or **effort** label in the status bar opens a floating picker above it (↑↓ select · ⏎ apply · esc close), reusing the same `/model`/`/effort` command path. The slash commands remain the keyboard path. The fragile row+column hit-test lives in pure, tested `statusBarHit`/`statusBarLayout` (`StatusBar.tsx`); `App.tsx` only supplies live layout (composer line count, `PALETTE_ROWS`, the rendered model/effort/mode) and toggles `quickPicker` state. Inline mode has no mouse grab, so the labels stay informational there. **Copy**: ⌃Y / `/copy` copies the last reply via OSC 52 (`src/ui/clipboard.ts`, works over SSH); `/export [file]` writes the transcript to Markdown. **Terminal integration** (`src/ui/terminal.ts`): the tab title (OSC 2) reflects working/idle, and a long turn (>8s) rings the bell + fires a desktop notification (macOS) so you can step away.\n\n**More UX affordances.** **Type-ahead**: prompts submitted while busy are queued (`queueRef`, shown as chips) and sent when the turn ends. **⌃C** interrupts a turn → clears the composer → \"press again to quit\" (`cli.tsx` renders with `exitOnCtrlC:false`). **Large pastes** collapse to a `[Pasted N lines]` chip (`pasteStoreRef`), expanded back on submit. **Fuzzy** `@file`/`/command` pickers (`src/ui/fuzzy.ts` — substring-first, then subsequence scored by boundary+contiguity; tested). **Cost**: live `$` estimate in the status bar from per-turn model+tokens (`estimateCost` + per-model pricing in `providers.ts`). **Syntax highlighting** for code blocks (`src/ui/highlight.ts` — lightweight per-line tokenizer → Ink spans, NEVER raw ANSI; used by both `lines.ts` `clipSpans` and `Markdown.tsx`). `?` on an empty composer shows the cheatsheet (`KEYS_HELP`).\n\n**Sessions** (`src/session.ts`): conversations persist per-project under `~/.gearbox/sessions/<slug>/` (`GEARBOX_HOME` overrides). Each record holds provider-neutral `messages` + the UI `items` + **per-turn `{model, usage, at}`** (routing/cost data — the record is deliberately not single-model). `gearbox --continue`/`-c` resumes the latest; `/resume [n]` lists/loads in-app; `/clear` starts a fresh session. Prompt history persists across runs (`history.json`). Saving is best-effort (never crashes the app); skipped in demo mode.\n\nFeatures: full markdown via **marked** (parse, `marked.lexer`) + **Ink** (render) in `Markdown.tsx` — headings, bold/italic/inline-code, tables, ordered+nested lists, blockquotes, code blocks. NO foreign ANSI in Ink (cli-highlight/marked-terminal were tried and removed — they corrupt Ink's width/wrapping; render marked's token tree as Ink elements instead). Markdown gets a `width` prop (threaded App→Transcript→Markdown) for table/rule sizing. Colored diffs under edits (`src/diff.ts`, edit/write tools return `{summary,diff}`), plan mode (read-only tools + plan prompt; `/plan` or shift+tab), `!cmd` runs a shell command directly (`src/shell.ts`), `@file` mentions (fuzzy picker `src/ui/mention.ts`+`files.ts`; expanded into the model message on send), live \"working · Ns\" timer.\n\n**Boo (the mascot).** A pixel ghost, now **parametric** (`src/ui/ghost/engine.ts`, ported from a Claude Design handoff). A 20×20 pixel sprite composited from composable layers — body (palette) + face (eyes/mouth) + accessory + persona + a frame-driven overlay (tears/dots/confetti/Z's/sparkle/hearts) — then FOLDED into half-block cells (`▀`/`▄`, top px → `t`/glyph color, bottom px → `b`/bg). `renderGhost(cfg)` is the source of truth for the **default blocks path**; it's pure + memoized. The data: 13 faces (`FACES`), 9 palettes (`PALETTES`), 6 accessories, 9 personas (personas/accessories ported but not yet surfaced in the live UI). Ink `color`/`backgroundColor` props only, NEVER raw ANSI (corrupts Ink's width math). PNG paths are **opt-in** via `GEARBOX_GHOST`:\n\n- `GEARBOX_GHOST=kitty` — real PNG via kitty graphics Unicode placeholders (`U+10EEEE`, fg encodes image id, diacritics encode row/col; PNGs transmitted once in `cli.tsx`). NOTE: the placeholder protocol is young and mis-rendered (squished) in Ghostty during testing — kept opt-in until that's solved.\n- `GEARBOX_GHOST=iterm` — OSC 1337 splash banner (iTerm2/WezTerm).\n\n`detectImageMode()` returns `blocks` unless `GEARBOX_GHOST` opts in. Baked PNGs live in `src/ui/mascot-png.ts`; `bun run scripts/ghost-preview.ts` previews the parametric engine (splash + all faces + the in-flow state crops). **Boo is animated but deliberately calm** on the blocks path (`AnimatedGhost` in `Mascot.tsx`): one shared, unhurried 240ms tick (leaf-local `useTick`, never lifted to App root); talk + overlays advance at half that (~480ms). There is NO idle bob/float and NO splash sparkle — motion is a quiet sign of life, not fidgeting (the splash just blinks every ~6s; in-flow only the state-meaningful overlay/talk moves). `GEARBOX_NO_MOTION=1` freezes to frame 0. `/ghost [mood]` cycles the skin (`skinToCfg` maps it to a cfg; `shades` is the cool face + shades accessory).\n\n**Layout: fullscreen by default; inline is opt-in.** **Fullscreen is the default** (alt-screen frame + virtualized scroll region + scrollbar + mouse wheel scroll); `--inline`, `GEARBOX_INLINE=1`, or `/config inline on` (pref `fullscreen: false`) opts into inline mode. `GEARBOX_FULLSCREEN=1` or `--fullscreen` forces fullscreen explicitly. The decision lives in `cli.tsx` (`wantsFullscreen`). Grabbing the mouse for wheel-scroll is exactly what disables native terminal selection, so in fullscreen mode text selection requires the terminal's modifier (e.g. Option-drag in Ghostty). **Inline mode** (the plain `Transcript` component): no alt-screen, no mouse grab — native click-drag selection / scrollback / copy all work with no modifier. The transcript is a **virtualized line buffer**: `src/ui/lines.ts` (`itemsToLines`) flattens items into styled `Line`s (markdown→lines, wrapping, diffs) — INVARIANT: every line ≤ width (tested), so nothing overflows. **Streaming perf**: flattening the markdown-heavy `assistant`/`user` items is super-linear with their length, so `staticItemLines` memoizes per item in a `WeakMap` keyed by object reference (unchanged items keep identity across renders, so only the changing tail re-parses — history is free; running tools are not cached since their spinner animates). On the producer side, assistant **text deltas are coalesced** on a ~45ms flush timer in `App.tsx`'s `onEvent` (mirroring the tool-stream coalescer), so streaming re-renders at ~22fps instead of per-token — both together stop the auto-scroll jitter that grew with reply length. `finishAssistant`/the turn `finally` flush any buffered text before marking done or on interrupt. In fullscreen, `App` renders only the visible window via `Viewport` (`src/ui/components/Viewport.tsx`) at a computed `transcriptHeight = rows − header − footer` (footer over-estimated so the frame never exceeds the screen; alt-screen clips, so under-filling is safe). Fullscreen scroll: mouse wheel (SGR mouse reporting enabled in `cli.tsx`; parsed off raw stdin in `App` since Ink doesn't model mouse — buttons 64/65) and PgUp/PgDn; new output re-pins to the bottom (`atBottomRef`); a scrollbar sits on the right. (In fullscreen, mouse reporting means text selection needs the terminal's modifier, e.g. Option-drag in Ghostty — which is why inline is now the default.) The virtualized buffer replaced an earlier flex/overflow fullscreen that corrupted on tall output. Chrome spans full width; prose wraps ≤100 cols. The plain `Transcript` component is the inline-fallback renderer. `scripts/gen-mascot.ts` still bakes the PNGs + baked sprites (`mascot-sprite.ts` `GHOSTS`) — but those now feed **only the opt-in kitty/iTerm image path** (`image.ts`); the default blocks path renders the parametric engine instead. The splash scales to the terminal (big=2×/mini=1×/none by rows×cols, in `App.tsx`). The inline/working presence is the compact **state ghost** (see below) — a native-resolution head crop so Boo never dominates the transcript.\n\nCommands are grouped in `/help` (models · conversation · accounts · save · modes · settings · other) and `src/commands.ts` carries plain-language descriptions: /model [name] (fuzzy — \"haiku\"; `/model auto` routes, `/model all` lists every provider) /effort [fast|balanced|max] /prefer [kind model] (remember a confirmed routing preference for a task type) /clear /resume /retry /compact /context /memory /ask &lt;q&gt; (answer questions about Gearbox itself from its bundled docs via a cheap routed model; plain meta-questions auto-route here with a visible affordance) /account (unified: list/add/login/use/rm/refresh — `/accounts` and `/login` are hidden aliases; `/account refresh` re-discovers each account's real callable models) /cost /copy /export [file] /plan /yolo /theme /config (theme·vim·notify·inline; `/vim` is a hidden alias) /init /keys /help /exit. **Hidden** (work but not listed): /accounts /login /vim /ghost. **Removed:** /cwd (the working dir now shows in `/context`). `formatModelList` shows usable models first and collapses no-key providers to a one-line count.\n\n**Command panel (fullscreen only).** Big info-dump commands open a dismissable, Esc-closable overlay instead of dumping into the transcript (`Panel.tsx` + pure `panel.ts`, wired in `App.tsx`): `/help` `/keys` `/context` `/cost` `/memory` are scrollable static dumps (reuse `itemsToLines` + `Viewport`); `/account` and `/model` are interactive lists (↑↓ select · ⏎ acts — they just dispatch the equivalent `/account <n>` / `/model <id>` command and close), and `/model` has type-to-filter (127 Foundry models). The panel replaces the transcript Viewport region while open and takes precedence over `welcome`; the key handler is a branch in `useInput` placed after ⌃C so Esc closes the panel rather than interrupting a turn. Short confirmations (`model → haiku`, `remembered`, `✓ added`, errors) stay inline. Inline mode keeps the old inline printing (no alt-screen to overlay). `openInfoPanel` returns false inline so callers fall back to `push`.\n\n**Permission gate:** `write_file`/`edit_file`/`run_shell` block on a confirm before mutating. Broker: `src/permission.ts` (`requestPermission` in the tools; `setPermissionHandler` installed by `App`; no handler → allow, so tests/headless are unchanged). Decisions: **once** (1), **always** (2, grants that kind for the session), **all/yolo** (a, auto-approves everything until toggled), **deny** (3/esc). YOLO is also toggled by `/yolo` or started with `--yolo`; a `⚡ yolo` badge shows in the status. The `!` prefix is user-initiated so it is NOT gated. Search/nav tools: `search` (ripgrep, Bun-walk fallback) and `glob` (`Bun.Glob`), both read-only (also in plan mode). The working indicator IS Boo now (`components/Working.tsx`): a compact head-crop ghost whose face follows the agent state — thinking (dots) → streaming (talk) → tool (loading dots) → a clean-finish celebrate (party hat + confetti) → error (crying with falling tears). `App.tsx` derives `mascotState` from the `onEvent` stream; the success/error beat **lingers ~1.5s** after the turn (`linger` state — the working line gates on `busy || linger`, since it would otherwise unmount the instant `busy` goes false). Crops are per-state (`stateView`): head (rows 4–14), head+dots (2–14), head+hat (0–14) so overlays outside the head still read. This deliberately supersedes the earlier \"Boo stays on the welcome splash only / in-flow movement reads as noise\" decision — the compact, state-bearing ghost is the point of the design port.\n\n## Conventions\n\n- Runtime: **Bun**. TypeScript + TSX. Run with `bun run src/cli.tsx`.\n- UI: **Ink** (React for terminals) + **@inkjs/ui**. Keep it calm and beautiful: restrained palette (one accent), generous spacing, consistent glyphs. The look lives in `src/ui/theme.ts` — change colors/glyphs there, not inline.\n- Open + free: MIT, no paid dependencies, no hosted backend, no telemetry. The only cost is the user's own model calls on their own keys.\n- Tools must be safe by default: confirm or sandbox anything destructive; never `rm -rf` or write outside the workspace without intent.\n\n## Run it\n\n```bash\nbun install\n# set at least one key:\nexport ANTHROPIC_API_KEY=... # or OPENAI_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY / DEEPSEEK_API_KEY\nbun run src/cli.tsx # or: bun start\n```\n\nWith no key it launches in demo mode (a scripted transcript) so the UI still runs.\n\n## Test\n\n```bash\nbun test # render tests + agent-loop tests; no API key needed\nbun run typecheck # tsc --noEmit\n```"
145529
+ text: "# Gearbox — project guide\n\nGearbox is a multi-provider coding harness for the terminal: a beautiful, simple terminal agent that reads/writes code and runs commands, talking to any provider (Anthropic, OpenAI, Google, DeepSeek) through one clean loop.\n\n**The point of the project:** intelligent per-task *model routing* — automatically picking the right model for each task across every provider and account you pay for. Basic routing is live (`RoutingSelector` — classify → quality bar → cheapest winner); the richer engine (shadow-eval, credit/limit penalties, confidence display) layers on top of the same seam. See `DESIGN.md` for the full vision and `experiments/FINDINGS.md` for the validation behind it.\n\n## The one rule that matters\n\n**Keep the routing seam clean.** The agent must never hardcode a model. It asks a `ModelSelector` for the model to use. `RoutingSelector` is the live default (classify task → filter by quality bar → cheapest winner); `FixedSelector` is used only when a model is explicitly pinned (`--model` flag or `/model <name>`). Concretely:\n\n- `src/model/selector.ts` — the seam. `select(task) => ModelChoice` (now carrying an optional `Backend` so the runner can dispatch in-loop vs a subscription seat). Do not bypass it.\n- `src/model/router.ts` — `RoutingSelector` is **account-aware**: it scores `(model, account)` PAIRS, not just models. Candidates = in-loop registry models × the accounts that serve them + flat-rate subscription **seats** (`providers.subscriptionSeats()`, a seat mirrors a canonical model but runs via the vendor binary). Flow: classify → quality bar → context fit → global/`/prefer` preference filter → score → return `{model, reason, backend}`. A seat is ~free until its rate limit, so it wins by default and fails over to metered API as the window fills. `SubscriptionPinSelector`/`FixedSelector` are hard pins (explicit `/account use` or `/model`) that beat auto-routing.\n- `src/model/scoring.ts` — the PURE scorer: `score = costEst + scarcity + switchPenalty + limitPenalty + apiThrottlePenalty − planBonus`, argmin tie-broken deterministically. No I/O; fixture-tested. Every account-state term degrades safely — and where a provider exposes nothing, we ESTIMATE rather than go blind: live API rate-limit headers (`src/model/rate-headers.ts`, parsed from each response → `apiThrottle`, gentle near-empty penalty) and a self-declared budget − tracked spend (`/budget`, an estimated balance feeding scarcity). A missing signal with no estimate is still neutral (no errors per provider).\n- `src/model/routing-context.ts` — `buildRoutingContext()`: the per-turn account-state snapshot (balance where exposed, subscription rate headroom = min over the 5h/weekly windows) read from disk-cached `usage.json`. No network on the hot path; balances refreshed in the background (App effect).\n- `src/model/profiles.ts` — the data corpus: quality, cost, latency, tokenizer calibration, **and the per-model effort vocabulary** (`efforts`) per the provider research. Routing reads this; effort is clamped/omitted against the chosen model's set, never sent unsupported.\n- `src/providers.ts` — maps a provider+model id to an AI SDK model instance. Already multi-provider. Adding a model is data, not code.\n- Every model call captures token usage (`src/agent/run.ts`) so the cost engine has data. Do not drop usage.\n- The UI consumes a normalized `AgentEvent` stream (`src/agent/events.ts`), never the AI SDK's raw types. This decouples the UI from the provider layer and from routing.\n\nIf you find yourself writing `anthropic('claude-...')` anywhere outside `providers.ts`, stop — route it through the selector.\n\n## Layout\n\n```\nsrc/\n cli.tsx entry point; renders the Ink app; picks RoutingSelector by default\n config.ts minimal config (default model, provider from env)\n providers.ts provider+model id -> AI SDK model (multi-provider; contextWindow per model)\n commands.ts slash-command metadata + pure helpers (fuzzy model match, /help, model list)\n tools.ts read / write / edit / list / search / glob / run_shell (AI SDK tools)\n model/\n selector.ts THE ROUTING SEAM — ModelSelector + ModelChoice.backend + FixedSelector (pinned model)\n router.ts RoutingSelector (account-aware): scores (model, account) pairs incl. subscription seats; SubscriptionPinSelector\n scoring.ts PURE scorer: cost + scarcity + limit − plan bonus; deterministic, fixture-tested\n routing-context.ts per-turn account-state snapshot (balance + subscription rate headroom) from usage.json\n cooldown.ts reactive-failover support: classify a failure + park an exhausted account so the router routes around it\n profiles.ts model corpus: quality (SWE-bench), cost ($/Mtok), latency, tokenizer calibration, per-model effort vocab\n tokens.ts calibrated token counting (js-tiktoken × per-model calibration factor)\n preferences.ts persist /prefer kind model choices to ~/.gearbox/routing-preferences.json\n reasoning.ts reasoning/thinking config helpers\n context/\n builder.ts context engine: system + memory + repo map + retrieved files + curated history\n retrieve.ts BM25 lexical retrieval — top-K relevant files for a prompt (no model call)\n repomap.ts repo structure summary for the system prompt\n memory.ts project memory (GEARBOX.md / CLAUDE.md loaded into context)\n compact.ts context compaction (/compact)\n accounts/\n types.ts Account + AuthMethod types (API key, AWS, Azure, Vertex, CLI, OpenAI-compat)\n store.ts accounts.json persistence (~/.gearbox/accounts.json)\n catalog.ts provider catalog (known providers, env vars, labels)\n detect.ts auto-detect env creds + cloud credentials\n onboard.ts interactive add/test account flows; addByPastedKey routes through sniff.ts\n sniff.ts pure credential sniffer: paste anything (key / AWS block / SA JSON / Azure URL / gateway key) → {kind, provider, fields, missing}\n resolve.ts credential resolution (Account → ResolvedCreds) + rank(model) → ordered failover pool (cross-provider, health-sorted)\n discover.ts per-account model discovery (Azure deployments / Foundry / gateway /models) → account.models; catalog defaultModels are seeds, not callable ids\n health.ts account health: classifyError (provider error → state), checkHealth (cached probe, timeout-bounded), recordHealth; no background polling\n usage.ts per-account spend ledger + rate-limit snapshots + balance tracking\n balance.ts provider balance fetch helpers\n help/\n ask.ts /ask corpus: bundled docs + generated command reference, system prompt, meta-question auto-detect\n agent/\n events.ts AgentEvent — normalized stream the UI consumes\n run.ts real agent loop (AI SDK streamText -> AgentEvent), abort-aware; runCompletion = tool-less grounded answer (used by /ask); returns a structured failure (for failover)\n failover.ts runWithFailover: run a turn over the ranked account pool; on a credential failure before output, advance to the next; clear exhaustion errors\n cli-backend.ts claude/codex CLI subprocess backend (for Pro/Max subscriptions)\n mock.ts scripted demo stream (runs with no API key; used by tests)\n ui/\n theme.ts colors + glyphs (the look)\n input.ts pure key→action reducer for the composer (tested)\n history.ts pure ↑/↓ prompt-history nav (tested)\n net.ts background online probe; status bar shows ⚠ offline when down\n useTerminalSize.ts reactive width on resize (everything reflows)\n git.ts current branch for the status line\n App.tsx the Ink app: state, useInput dispatch, commands, turns\n components/ Banner, Transcript, Composer, CommandPalette, StatusBar, PermissionPrompt, Panel\n panel.ts dismissable command-panel model + pure helpers (clamp/window/filter); tested\ntest/ pure-logic + render tests (ink-testing-library); no keys\nDESIGN.md full product vision (routing, requirements, UX)\nexperiments/ prototypes that validated the architecture\n```\n\nThe composer is custom (Ink `useInput` + `src/ui/input.ts`), not a third-party widget — full control over the cursor, ↑/↓ history, and esc-to-interrupt, with no focus/remount fragility. **Multi-line**: ⌃J (or shift/alt+⏎) inserts a newline, ⏎ submits; ↑/↓ move between lines and fall through to history at the top/bottom line; bracketed paste (enabled in `cli.tsx`) inserts multi-line text literally (CR normalized, paste markers stripped) instead of submitting per line. `caretPos()` is the shared line/col helper. **Readline editing** (all pure in `input.ts`, tested): ⌃U/⌃K kill to line start/end, ⌃W / ⌥⌫ kill word, ⌃D forward-delete, ⌥/⌃ + ←→ word-jump, ⌃A/⌃E line home/end. Keys: ⏎ send · ⌃J newline · ↑↓ line/history · ← → cursor · ⌥←→ word · tab complete @file · **shift+tab cycles mode (normal · auto-accept · plan)** · ⌃Y copy last reply · esc interrupt · ⌃c quit. `/keys` shows the cheatsheet.\n\n**Modes & effort.** Three input modes cycled by shift+tab (`App.tsx` `cycleMode`): **normal** (asks before writes/edits/shell), **auto-accept** (file writes/edits apply without asking — the permission broker auto-resolves `write`/`edit`; shell still gated; diffs still render), **plan** (read-only). Plus **yolo** (auto-approve everything) via `/yolo`. **Effort tiers** (`/effort fast|balanced|max`, or `setEffort`) pin the model through the routing seam (fast→haiku, balanced/max→sonnet) — the active mode + `⚡effort` show as badges in the `StatusBar`. **Click pickers** (fullscreen only): clicking the **model** or **effort** label in the status bar opens a floating picker above it (↑↓ select · ⏎ apply · esc close), reusing the same `/model`/`/effort` command path. The slash commands remain the keyboard path. The fragile row+column hit-test lives in pure, tested `statusBarHit`/`statusBarLayout` (`StatusBar.tsx`); `App.tsx` only supplies live layout (composer line count, `PALETTE_ROWS`, the rendered model/effort/mode) and toggles `quickPicker` state. Inline mode has no mouse grab, so the labels stay informational there. **Copy**: ⌃Y / `/copy` copies the last reply via OSC 52 (`src/ui/clipboard.ts`, works over SSH); `/export [file]` writes the transcript to Markdown. **Terminal integration** (`src/ui/terminal.ts`): the tab title (OSC 2) reflects working/idle, and a long turn (>8s) rings the bell + fires a desktop notification (macOS) so you can step away.\n\n**More UX affordances.** **Type-ahead**: prompts submitted while busy are queued (`queueRef`, shown as chips) and sent when the turn ends. **⌃C** interrupts a turn → clears the composer → \"press again to quit\" (`cli.tsx` renders with `exitOnCtrlC:false`). **Large pastes** collapse to a `[Pasted N lines]` chip (`pasteStoreRef`), expanded back on submit. **Fuzzy** `@file`/`/command` pickers (`src/ui/fuzzy.ts` — substring-first, then subsequence scored by boundary+contiguity; tested). **Cost**: live `$` estimate in the status bar from per-turn model+tokens (`estimateCost` + per-model pricing in `providers.ts`). **Syntax highlighting** for code blocks (`src/ui/highlight.ts` — lightweight per-line tokenizer → Ink spans, NEVER raw ANSI; used by both `lines.ts` `clipSpans` and `Markdown.tsx`). `?` on an empty composer shows the cheatsheet (`KEYS_HELP`).\n\n**Sessions** (`src/session.ts`): conversations persist per-project under `~/.gearbox/sessions/<slug>/` (`GEARBOX_HOME` overrides). Each record holds provider-neutral `messages` + the UI `items` + **per-turn `{model, usage, at}`** (routing/cost data — the record is deliberately not single-model). `gearbox --continue`/`-c` resumes the latest; `/resume [n]` lists/loads in-app; `/clear` starts a fresh session. Prompt history persists across runs (`history.json`). Saving is best-effort (never crashes the app); skipped in demo mode.\n\nFeatures: full markdown via **marked** (parse, `marked.lexer`) + **Ink** (render) in `Markdown.tsx` — headings, bold/italic/inline-code, tables, ordered+nested lists, blockquotes, code blocks. NO foreign ANSI in Ink (cli-highlight/marked-terminal were tried and removed — they corrupt Ink's width/wrapping; render marked's token tree as Ink elements instead). Markdown gets a `width` prop (threaded App→Transcript→Markdown) for table/rule sizing. Colored diffs under edits (`src/diff.ts`, edit/write tools return `{summary,diff}`), plan mode (read-only tools + plan prompt; `/plan` or shift+tab), `!cmd` runs a shell command directly (`src/shell.ts`), `@file` mentions (fuzzy picker `src/ui/mention.ts`+`files.ts`; expanded into the model message on send), live \"working · Ns\" timer.\n\n**Boo (the mascot).** A pixel ghost, now **parametric** (`src/ui/ghost/engine.ts`, ported from a Claude Design handoff). A 20×20 pixel sprite composited from composable layers — body (palette) + face (eyes/mouth) + accessory + persona + a frame-driven overlay (tears/dots/confetti/Z's/sparkle/hearts) — then FOLDED into half-block cells (`▀`/`▄`, top px → `t`/glyph color, bottom px → `b`/bg). `renderGhost(cfg)` is the source of truth for the **default blocks path**; it's pure + memoized. The data: 13 faces (`FACES`), 9 palettes (`PALETTES`), 6 accessories, 9 personas (personas/accessories ported but not yet surfaced in the live UI). Ink `color`/`backgroundColor` props only, NEVER raw ANSI (corrupts Ink's width math). PNG paths are **opt-in** via `GEARBOX_GHOST`:\n\n- `GEARBOX_GHOST=kitty` — real PNG via kitty graphics Unicode placeholders (`U+10EEEE`, fg encodes image id, diacritics encode row/col; PNGs transmitted once in `cli.tsx`). NOTE: the placeholder protocol is young and mis-rendered (squished) in Ghostty during testing — kept opt-in until that's solved.\n- `GEARBOX_GHOST=iterm` — OSC 1337 splash banner (iTerm2/WezTerm).\n\n`detectImageMode()` returns `blocks` unless `GEARBOX_GHOST` opts in. Baked PNGs live in `src/ui/mascot-png.ts`; `bun run scripts/ghost-preview.ts` previews the parametric engine (splash + all faces + the in-flow state crops). **Boo is animated but deliberately calm** on the blocks path (`AnimatedGhost` in `Mascot.tsx`): one shared, unhurried 240ms tick (leaf-local `useTick`, never lifted to App root); talk + overlays advance at half that (~480ms). There is NO idle bob/float and NO splash sparkle — motion is a quiet sign of life, not fidgeting (the splash just blinks every ~6s; in-flow only the state-meaningful overlay/talk moves). `GEARBOX_NO_MOTION=1` freezes to frame 0. `/ghost [mood]` cycles the skin (`skinToCfg` maps it to a cfg; `shades` is the cool face + shades accessory).\n\n**Layout: fullscreen by default; inline is opt-in.** **Fullscreen is the default** (alt-screen frame + virtualized scroll region + scrollbar + mouse wheel scroll); `--inline`, `GEARBOX_INLINE=1`, or `/config inline on` (pref `fullscreen: false`) opts into inline mode. `GEARBOX_FULLSCREEN=1` or `--fullscreen` forces fullscreen explicitly. The decision lives in `cli.tsx` (`wantsFullscreen`). Grabbing the mouse for wheel-scroll is exactly what disables native terminal selection, so in fullscreen mode text selection requires the terminal's modifier (e.g. Option-drag in Ghostty). **Inline mode** (the plain `Transcript` component): no alt-screen, no mouse grab — native click-drag selection / scrollback / copy all work with no modifier. The transcript is a **virtualized line buffer**: `src/ui/lines.ts` (`itemsToLines`) flattens items into styled `Line`s (markdown→lines, wrapping, diffs) — INVARIANT: every line ≤ width (tested), so nothing overflows. **Streaming perf**: flattening the markdown-heavy `assistant`/`user` items is super-linear with their length, so `staticItemLines` memoizes per item in a `WeakMap` keyed by object reference (unchanged items keep identity across renders, so only the changing tail re-parses — history is free; running tools are not cached since their spinner animates). On the producer side, assistant **text deltas are coalesced** on a ~45ms flush timer in `App.tsx`'s `onEvent` (mirroring the tool-stream coalescer), so streaming re-renders at ~22fps instead of per-token — both together stop the auto-scroll jitter that grew with reply length. `finishAssistant`/the turn `finally` flush any buffered text before marking done or on interrupt. In fullscreen, `App` renders only the visible window via `Viewport` (`src/ui/components/Viewport.tsx`) at a computed `transcriptHeight = rows − header − footer` (footer over-estimated so the frame never exceeds the screen; alt-screen clips, so under-filling is safe). Fullscreen scroll: mouse wheel (SGR mouse reporting enabled in `cli.tsx`; parsed off raw stdin in `App` since Ink doesn't model mouse — buttons 64/65) and PgUp/PgDn; new output re-pins to the bottom (`atBottomRef`); a scrollbar sits on the right. (In fullscreen, mouse reporting means text selection needs the terminal's modifier, e.g. Option-drag in Ghostty — which is why inline is now the default.) The virtualized buffer replaced an earlier flex/overflow fullscreen that corrupted on tall output. Chrome spans full width; prose wraps ≤100 cols. The plain `Transcript` component is the inline-fallback renderer. `scripts/gen-mascot.ts` still bakes the PNGs + baked sprites (`mascot-sprite.ts` `GHOSTS`) — but those now feed **only the opt-in kitty/iTerm image path** (`image.ts`); the default blocks path renders the parametric engine instead. The splash scales to the terminal (big=2×/mini=1×/none by rows×cols, in `App.tsx`). The inline/working presence is the compact **state ghost** (see below) — a native-resolution head crop so Boo never dominates the transcript.\n\nCommands are grouped in `/help` (models · conversation · accounts · save · modes · settings · other) and `src/commands.ts` carries plain-language descriptions: /model [name] (fuzzy — \"haiku\"; `/model auto` routes, `/model all` lists every provider) /effort [fast|balanced|max] /prefer [kind model] (remember a confirmed routing preference for a task type) /clear /resume /retry /compact /context /memory /ask &lt;q&gt; (answer questions about Gearbox itself from its bundled docs via a cheap routed model; plain meta-questions auto-route here with a visible affordance) /account (unified: list/add/login/use/rm/refresh — `/accounts` and `/login` are hidden aliases; `/account refresh` re-discovers each account's real callable models) /cost /copy /export [file] /plan /yolo /theme /config (theme·vim·notify·inline; `/vim` is a hidden alias) /init /keys /help /exit. **Hidden** (work but not listed): /accounts /login /vim /ghost. **Removed:** /cwd (the working dir now shows in `/context`). `formatModelList` shows usable models first and collapses no-key providers to a one-line count.\n\n**Command panel (fullscreen only).** Big info-dump commands open a dismissable, Esc-closable overlay instead of dumping into the transcript (`Panel.tsx` + pure `panel.ts`, wired in `App.tsx`): `/help` `/keys` `/context` `/cost` `/memory` are scrollable static dumps (reuse `itemsToLines` + `Viewport`); `/account` and `/model` are interactive lists (↑↓ select · ⏎ acts — they just dispatch the equivalent `/account <n>` / `/model <id>` command and close), and `/model` has type-to-filter (127 Foundry models). The panel replaces the transcript Viewport region while open and takes precedence over `welcome`; the key handler is a branch in `useInput` placed after ⌃C so Esc closes the panel rather than interrupting a turn. Short confirmations (`model → haiku`, `remembered`, `✓ added`, errors) stay inline. Inline mode keeps the old inline printing (no alt-screen to overlay). `openInfoPanel` returns false inline so callers fall back to `push`.\n\n**Accounts: reliability by design.** Every subscription, API key, and cloud credential is meant to work all the time. Switching is **by name only** (a stable unique `slug` per account, e.g. `claude-work`; positional numbers are gone — removing an account never repoints another). Each account carries a cached **health** state (`✓ ready · ⚠ expired · ✗ invalid · ⏳ limited · — unknown`) refreshed at natural touchpoints (boot sweep, opening `/account`, on switch, on live failure) — never by background polling; probes are timeout-bounded. A turn runs through a **failover pool**: `resolve.rank(model)` returns every account that can serve the model's family (cross-provider — Claude can fall Anthropic key → Bedrock → Vertex), health-sorted; `agent/failover.ts` tries them best-first and, on a credential-class failure **before any output** (expired/invalid/no-credit/rate-limited), transparently advances to the next and tells the user which account ran. A real (network/model) error or any failure after output streamed does NOT churn the pool. When the pool is exhausted, one consolidated error names each account, why it failed, and the one command to fix it. Expired subscriptions get one-step re-login: `/account login <name>` (and CLI failures name that exact command). `/account add <paste>` runs the credential **sniffer** (`accounts/sniff.ts`) — paste an API key, an AWS access key or credentials block, a Vertex service-account JSON, an Azure endpoint, or a Vercel gateway key, and it identifies the provider, fills the gaps interactively, and live-tests it.\n\n**Permission gate:** `write_file`/`edit_file`/`run_shell` block on a confirm before mutating. Broker: `src/permission.ts` (`requestPermission` in the tools; `setPermissionHandler` installed by `App`; no handler → allow, so tests/headless are unchanged). Decisions: **once** (1), **always** (2, grants that kind for the session), **all/yolo** (a, auto-approves everything until toggled), **deny** (3/esc). YOLO is also toggled by `/yolo` or started with `--yolo`; a `⚡ yolo` badge shows in the status. The `!` prefix is user-initiated so it is NOT gated. Search/nav tools: `search` (ripgrep, Bun-walk fallback) and `glob` (`Bun.Glob`), both read-only (also in plan mode). The working indicator IS Boo now (`components/Working.tsx`): a compact head-crop ghost whose face follows the agent state — thinking (dots) → streaming (talk) → tool (loading dots) → a clean-finish celebrate (party hat + confetti) → error (crying with falling tears). `App.tsx` derives `mascotState` from the `onEvent` stream; the success/error beat **lingers ~1.5s** after the turn (`linger` state — the working line gates on `busy || linger`, since it would otherwise unmount the instant `busy` goes false). Crops are per-state (`stateView`): head (rows 4–14), head+dots (2–14), head+hat (0–14) so overlays outside the head still read. This deliberately supersedes the earlier \"Boo stays on the welcome splash only / in-flow movement reads as noise\" decision — the compact, state-bearing ghost is the point of the design port.\n\n## Conventions\n\n- Runtime: **Bun**. TypeScript + TSX. Run with `bun run src/cli.tsx`.\n- UI: **Ink** (React for terminals) + **@inkjs/ui**. Keep it calm and beautiful: restrained palette (one accent), generous spacing, consistent glyphs. The look lives in `src/ui/theme.ts` — change colors/glyphs there, not inline.\n- Open + free: MIT, no paid dependencies, no hosted backend, no telemetry. The only cost is the user's own model calls on their own keys.\n- Tools must be safe by default: confirm or sandbox anything destructive; never `rm -rf` or write outside the workspace without intent.\n\n## Run it\n\n```bash\nbun install\n# set at least one key:\nexport ANTHROPIC_API_KEY=... # or OPENAI_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY / DEEPSEEK_API_KEY\nbun run src/cli.tsx # or: bun start\n```\n\nWith no key it launches in demo mode (a scripted transcript) so the UI still runs.\n\n## Test\n\n```bash\nbun test # render tests + agent-loop tests; no API key needed\nbun run typecheck # tsc --noEmit\n```"
144904
145530
  },
144905
145531
  {
144906
145532
  file: "DESIGN.md",
@@ -145354,11 +145980,30 @@ var PROVIDERS = {
145354
145980
  "vercel-gateway": {
145355
145981
  url: "https://ai-gateway.vercel.sh/v1/credits",
145356
145982
  parse: (j) => {
145357
- const bal = j?.balance ?? j?.credits?.balance;
145358
- return typeof bal === "number" ? { remainingUSD: bal } : null;
145983
+ const bal = num2(j?.balance ?? j?.credits?.balance);
145984
+ return bal == null ? null : { remainingUSD: bal };
145985
+ }
145986
+ },
145987
+ deepseek: {
145988
+ url: "https://api.deepseek.com/user/balance",
145989
+ parse: (j) => {
145990
+ const infos = Array.isArray(j?.balance_infos) ? j.balance_infos : [];
145991
+ const pick3 = infos.find((b) => b?.currency === "USD") ?? infos[0];
145992
+ const remaining = num2(pick3?.total_balance);
145993
+ return remaining == null ? null : { remainingUSD: remaining };
145359
145994
  }
145360
145995
  }
145361
145996
  };
145997
+ function num2(v) {
145998
+ if (typeof v === "number" && Number.isFinite(v))
145999
+ return v;
146000
+ if (typeof v === "string") {
146001
+ const n = Number(v);
146002
+ if (Number.isFinite(n))
146003
+ return n;
146004
+ }
146005
+ return;
146006
+ }
145362
146007
  function balanceExposed(provider) {
145363
146008
  return provider in PROVIDERS;
145364
146009
  }
@@ -145708,6 +146353,83 @@ function writeProjectGuide(cwd2 = process.cwd()) {
145708
146353
  return { path, summary: `wrote GEARBOX.md (${diffStat(diff2)})`, diff: diff2 };
145709
146354
  }
145710
146355
 
146356
+ // src/accounts/health.ts
146357
+ init_store();
146358
+ init_onboard();
146359
+ function statusOf(err) {
146360
+ return err?.statusCode ?? err?.status ?? err?.response?.status ?? err?.data?.error?.status;
146361
+ }
146362
+ function textOf2(err) {
146363
+ return String(err?.message ?? err?.error?.message ?? err?.responseBody ?? err?.error ?? err ?? "").toLowerCase();
146364
+ }
146365
+ function classifyError(_provider, err) {
146366
+ const status = statusOf(err);
146367
+ const t2 = textOf2(err);
146368
+ if (/credit balance|insufficient_quota|insufficient funds|billing|payment|quota exceeded/.test(t2))
146369
+ return "no-credit";
146370
+ if (/not logged in|not signed in|re-?authenticate|token (?:has )?expired|expired|session expired|login required|refresh token/.test(t2))
146371
+ return "expired";
146372
+ if (status === 429 || /rate.?limit|too many requests|overloaded|capacity/.test(t2))
146373
+ return "rate-limited";
146374
+ if (status === 401 || status === 403 || /invalid.*(api.?key|x-api-key|credential|token)|incorrect api key|unauthorized|authentication.?fail|permission denied/.test(t2))
146375
+ return "invalid";
146376
+ return "real-error";
146377
+ }
146378
+ var HEALTH_TTL_MS = 5 * 60000;
146379
+ var HEALTH_CHECK_TIMEOUT_MS = 8000;
146380
+ function withTimeout(p, ms, fallback) {
146381
+ return new Promise((resolve12, reject2) => {
146382
+ let done = false;
146383
+ const timer = setTimeout(() => {
146384
+ if (!done) {
146385
+ done = true;
146386
+ resolve12(fallback);
146387
+ }
146388
+ }, ms);
146389
+ p.then((v) => {
146390
+ if (!done) {
146391
+ done = true;
146392
+ clearTimeout(timer);
146393
+ resolve12(v);
146394
+ }
146395
+ }, (e2) => {
146396
+ if (!done) {
146397
+ done = true;
146398
+ clearTimeout(timer);
146399
+ reject2(e2);
146400
+ }
146401
+ });
146402
+ });
146403
+ }
146404
+ function isFresh(h2, now3) {
146405
+ return !!h2 && now3 - h2.checkedAt < HEALTH_TTL_MS;
146406
+ }
146407
+ function recordHealth(account, state, detail) {
146408
+ const at3 = Date.now();
146409
+ const cur = getAccount(account.id) ?? account;
146410
+ putAccount({ ...cur, health: { state, checkedAt: at3, detail } });
146411
+ }
146412
+ function checkHealth(account) {
146413
+ const at3 = Date.now();
146414
+ const probe = (async () => {
146415
+ try {
146416
+ if (account.exec === "cli") {
146417
+ const bin = account.auth.binary;
146418
+ const profile = account.auth.loginProfile;
146419
+ const st = await cliAuthStatus(bin, profile);
146420
+ return { state: st.loggedIn ? "ok" : "expired", checkedAt: at3, detail: st.detail };
146421
+ }
146422
+ const r2 = await testAccount(account);
146423
+ if (r2.ok)
146424
+ return { state: "ok", checkedAt: at3 };
146425
+ return { state: classifyError(account.provider, { message: r2.message }), checkedAt: at3, detail: r2.message };
146426
+ } catch (e2) {
146427
+ return { state: classifyError(account.provider, e2), checkedAt: at3, detail: String(e2?.message ?? e2) };
146428
+ }
146429
+ })();
146430
+ return withTimeout(probe, HEALTH_CHECK_TIMEOUT_MS, { state: "unknown", checkedAt: at3, detail: "health check timed out" });
146431
+ }
146432
+
145711
146433
  // src/ui/App.tsx
145712
146434
  init_mcp();
145713
146435
 
@@ -145917,7 +146639,6 @@ import { basename as basename3, extname, resolve as resolve12 } from "node:path"
145917
146639
  import { existsSync as existsSync11, readFileSync as readFileSync16, statSync as statSync5 } from "node:fs";
145918
146640
  import { writeFile as fsWriteFile } from "node:fs/promises";
145919
146641
  import { spawnSync as nodeSpawnSync2 } from "node:child_process";
145920
- var accountResolver = new AccountResolver;
145921
146642
  var KEYS_HELP = [
145922
146643
  "Keyboard shortcuts",
145923
146644
  " ⏎ send · ⌃J newline · esc interrupt · ⌃C twice to quit",
@@ -145984,6 +146705,18 @@ var FALLBACK_CODEX_MODELS = [
145984
146705
  { id: "gpt-5.4", label: "gpt-5.4", provider: "codex", efforts: ["low", "medium", "high", "xhigh"] },
145985
146706
  { id: "gpt-5.4-mini", label: "gpt-5.4-mini", provider: "codex", efforts: ["low", "medium", "high", "xhigh"] }
145986
146707
  ];
146708
+ function shortFailure(message) {
146709
+ const m2 = (message || "").toLowerCase();
146710
+ if (/\b402\b|credit|payment|billing|out of credit/.test(m2))
146711
+ return "out of credit";
146712
+ if (/over(loaded|capacity)|\b529\b/.test(m2))
146713
+ return "overloaded";
146714
+ if (/usage.?limit/.test(m2))
146715
+ return "at its usage limit";
146716
+ if (/quota|insufficient_quota/.test(m2))
146717
+ return "out of quota";
146718
+ return "rate-limited";
146719
+ }
145987
146720
  var codexModelCache = null;
145988
146721
  function codexCliModels() {
145989
146722
  if (codexModelCache)
@@ -146287,7 +147020,7 @@ function App2({ selector: initialSelector, runner, fullscreen = false, resumeId
146287
147020
  setPanelState(p);
146288
147021
  };
146289
147022
  const panelMaxScrollRef = import_react26.useRef(0);
146290
- const panelAccountNumbersRef = import_react26.useRef([]);
147023
+ const panelAccountSlugsRef = import_react26.useRef([]);
146291
147024
  const buildPanelModelRows = (cur) => modelRegistry().filter((m2) => providerAvailable(m2.provider)).map((m2) => ({ id: m2.id, label: m2.label, provider: m2.provider, current: m2.id === cur }));
146292
147025
  const openInfoPanel = (title, item) => {
146293
147026
  if (!fullscreen)
@@ -146428,6 +147161,43 @@ function App2({ selector: initialSelector, runner, fullscreen = false, resumeId
146428
147161
  notice(`loaded the real model list for ${learned} account${learned === 1 ? "" : "s"} — /model to see them`);
146429
147162
  })();
146430
147163
  }, []);
147164
+ import_react26.useEffect(() => {
147165
+ let cancelled = false;
147166
+ (async () => {
147167
+ const now3 = Date.now();
147168
+ const stale = listAccounts().filter((a) => !isFresh(a.health, now3));
147169
+ await Promise.all(stale.map(async (a) => {
147170
+ try {
147171
+ const h2 = await checkHealth(a);
147172
+ if (cancelled)
147173
+ return;
147174
+ recordHealth(a, h2.state, h2.detail);
147175
+ } catch {}
147176
+ }));
147177
+ })();
147178
+ return () => {
147179
+ cancelled = true;
147180
+ };
147181
+ }, []);
147182
+ import_react26.useEffect(() => {
147183
+ let alive = true;
147184
+ const refresh = async () => {
147185
+ const targets = listAccounts().filter((a) => a.enabled && a.exec !== "cli" && balanceExposed(a.provider));
147186
+ for (const a of targets) {
147187
+ if (!alive)
147188
+ return;
147189
+ const bal = await fetchBalance(a);
147190
+ if (bal?.remainingUSD != null)
147191
+ recordBalance(a.id, bal);
147192
+ }
147193
+ };
147194
+ refresh();
147195
+ const t2 = setInterval(() => void refresh(), 5 * 60000);
147196
+ return () => {
147197
+ alive = false;
147198
+ clearInterval(t2);
147199
+ };
147200
+ }, []);
146431
147201
  import_react26.useEffect(() => {
146432
147202
  setPermissionHandler((req) => new Promise((resolve13) => {
146433
147203
  if (modeRef.current === "auto-accept" && (req.kind === "write" || req.kind === "edit")) {
@@ -146439,12 +147209,48 @@ function App2({ selector: initialSelector, runner, fullscreen = false, resumeId
146439
147209
  }));
146440
147210
  return () => setPermissionHandler(null);
146441
147211
  }, []);
146442
- const scrollBy = import_react26.useCallback((delta) => {
146443
- const cur = atBottomRef.current ? maxScrollRef.current : scrollTopRef.current;
146444
- const ns = Math.max(0, Math.min(maxScrollRef.current, cur + delta));
146445
- atBottomRef.current = ns >= maxScrollRef.current;
146446
- setScrollTop(ns);
147212
+ const scrollTargetRef = import_react26.useRef(null);
147213
+ const scrollAnimRef = import_react26.useRef(null);
147214
+ const noMotion = process.env.GEARBOX_NO_MOTION === "1";
147215
+ const stopScrollAnim = import_react26.useCallback(() => {
147216
+ if (scrollAnimRef.current) {
147217
+ clearInterval(scrollAnimRef.current);
147218
+ scrollAnimRef.current = null;
147219
+ }
146447
147220
  }, []);
147221
+ const scrollBy = import_react26.useCallback((delta) => {
147222
+ const max2 = maxScrollRef.current;
147223
+ const cur = atBottomRef.current ? max2 : scrollTopRef.current;
147224
+ const target = Math.max(0, Math.min(max2, (scrollTargetRef.current ?? cur) + delta));
147225
+ if (noMotion) {
147226
+ scrollTargetRef.current = null;
147227
+ atBottomRef.current = target >= max2;
147228
+ setScrollTop(target);
147229
+ return;
147230
+ }
147231
+ scrollTargetRef.current = target;
147232
+ if (scrollAnimRef.current)
147233
+ return;
147234
+ let pos = cur;
147235
+ scrollAnimRef.current = setInterval(() => {
147236
+ const m2 = maxScrollRef.current;
147237
+ const tgt = Math.max(0, Math.min(m2, scrollTargetRef.current ?? pos));
147238
+ const diff2 = tgt - pos;
147239
+ if (Math.abs(diff2) < 1) {
147240
+ pos = tgt;
147241
+ scrollTargetRef.current = null;
147242
+ stopScrollAnim();
147243
+ } else {
147244
+ const step = Math.sign(diff2) * Math.max(1, Math.round(Math.abs(diff2) * 0.35));
147245
+ pos += step;
147246
+ if (diff2 > 0 && pos > tgt || diff2 < 0 && pos < tgt)
147247
+ pos = tgt;
147248
+ }
147249
+ atBottomRef.current = pos >= m2;
147250
+ setScrollTop(pos);
147251
+ }, 16);
147252
+ }, [noMotion, stopScrollAnim]);
147253
+ import_react26.useEffect(() => stopScrollAnim, [stopScrollAnim]);
146448
147254
  const copyWithFeedback = import_react26.useCallback((text2) => {
146449
147255
  const clean = text2.replace(/[ \t]+\n/g, `
146450
147256
  `).trim();
@@ -146681,7 +147487,7 @@ function App2({ selector: initialSelector, runner, fullscreen = false, resumeId
146681
147487
  if (p.kind === "static")
146682
147488
  setPanel({ ...p, scroll: clampScroll(p.scroll + delta, panelMaxScrollRef.current) });
146683
147489
  else if (p.kind === "accounts")
146684
- setPanel({ ...p, index: clampIndex(p.index + delta, panelAccountNumbersRef.current.length) });
147490
+ setPanel({ ...p, index: clampIndex(p.index + delta, panelAccountSlugsRef.current.length) });
146685
147491
  else
146686
147492
  setPanel({ ...p, index: clampIndex(p.index + delta, filterModelRows(buildPanelModelRows(), p.filter).length) });
146687
147493
  } else
@@ -146936,10 +147742,10 @@ function App2({ selector: initialSelector, runner, fullscreen = false, resumeId
146936
147742
  const pushUsage = (view) => push({ kind: "usage", id: idRef.current++, view });
146937
147743
  const pushAccounts = (view) => push({ kind: "accounts", id: idRef.current++, view });
146938
147744
  const normalizeAccountRef = (s2) => s2.toLowerCase().replace(/[()]/g, " ").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
146939
- const accountAliases = (a, index2) => {
147745
+ const accountAliases = (a) => {
146940
147746
  const name31 = accountName(a);
146941
147747
  const slug3 = accountSlug(a);
146942
- const aliases = new Set([String(index2 + 1), slug3, normalizeAccountRef(name31), normalizeAccountRef(a.label), normalizeAccountRef(a.id)]);
147748
+ const aliases = new Set([slug3, normalizeAccountRef(name31), normalizeAccountRef(a.label), normalizeAccountRef(a.id)]);
146943
147749
  const nick = name31.match(/\(([^)]+)\)/)?.[1];
146944
147750
  if (nick)
146945
147751
  aliases.add(normalizeAccountRef(nick));
@@ -146952,13 +147758,13 @@ function App2({ selector: initialSelector, runner, fullscreen = false, resumeId
146952
147758
  const findAccountRef = (query, accounts = listAccounts()) => {
146953
147759
  const q = normalizeAccountRef(query);
146954
147760
  if (!q)
146955
- return { error: "which account? use /account <name-or-number>" };
146956
- const exact = accounts.map((a, i2) => ({ a, aliases: accountAliases(a, i2) })).filter(({ aliases }) => aliases.has(q));
147761
+ return { error: "which account? use /account <name>" };
147762
+ const exact = accounts.map((a) => ({ a, aliases: accountAliases(a) })).filter(({ aliases }) => aliases.has(q));
146957
147763
  if (exact.length === 1)
146958
147764
  return { account: exact[0].a };
146959
147765
  if (exact.length > 1)
146960
147766
  return { error: `"${query}" matches ${exact.map(({ a }) => accountName(a)).join(", ")} — use the full alias` };
146961
- const fuzzy = accounts.map((a, i2) => ({ a, aliases: [...accountAliases(a, i2)] })).filter(({ aliases }) => aliases.some((x2) => x2.includes(q)));
147767
+ const fuzzy = accounts.map((a) => ({ a, aliases: [...accountAliases(a)] })).filter(({ aliases }) => aliases.some((x2) => x2.includes(q)));
146962
147768
  if (fuzzy.length === 1)
146963
147769
  return { account: fuzzy[0].a };
146964
147770
  if (fuzzy.length > 1)
@@ -146967,19 +147773,19 @@ function App2({ selector: initialSelector, runner, fullscreen = false, resumeId
146967
147773
  };
146968
147774
  const buildAccountView = (accounts, activeCliId, importable, statuses) => {
146969
147775
  const active = activeCliId ? accounts.find((a) => a.id === activeCliId) : null;
146970
- const rows2 = accounts.map((a, i2) => {
147776
+ const rows2 = accounts.map((a) => {
146971
147777
  const st = statuses[a.id];
146972
147778
  const activeRow = a.id === activeCliId;
146973
- const status = activeRow ? "active" : st?.duplicateOf ? "duplicate" : st?.signedIn === false ? "not signed in" : st?.signedIn === true ? "signed in" : a.exec === "cli" ? "not checked" : "ready";
147779
+ const status = activeRow ? "active" : st?.duplicateOf ? "duplicate" : st?.signedIn === false ? "not signed in" : st?.signedIn === true ? "signed in" : a.exec === "cli" ? "not checked" : badgeFor(a.health?.state);
146974
147780
  return {
146975
147781
  name: accountName(a),
146976
147782
  type: a.exec === "cli" ? "subscription" : "API key",
146977
147783
  status,
146978
147784
  active: activeRow,
146979
147785
  alias: accountSlug(a),
146980
- number: i2 + 1,
146981
- detail: st?.signedIn ? st.detail : undefined,
146982
- duplicateOf: st?.duplicateOf
147786
+ detail: (st?.signedIn ? st.detail : undefined) ?? a.identity?.label,
147787
+ duplicateOf: st?.duplicateOf,
147788
+ health: a.health?.state
146983
147789
  };
146984
147790
  });
146985
147791
  return {
@@ -146990,7 +147796,77 @@ function App2({ selector: initialSelector, runner, fullscreen = false, resumeId
146990
147796
  statusPad: Math.max(6, ...rows2.map((r2) => r2.status.length))
146991
147797
  };
146992
147798
  };
147799
+ const refreshCliStatuses = import_react26.useCallback(async () => {
147800
+ const accounts = listAccounts().filter((a) => a.exec === "cli");
147801
+ const statuses = { ...accountStatusCacheRef.current };
147802
+ await Promise.all(accounts.map(async (a) => {
147803
+ const bin = a.auth.binary;
147804
+ const profile = a.auth.loginProfile;
147805
+ try {
147806
+ const st = await cliAuthStatus(bin, profile);
147807
+ statuses[a.id] = { signedIn: st.loggedIn, detail: st.detail, identity: st.identity };
147808
+ if (st.loggedIn && st.identityLabel) {
147809
+ putAccount({ ...a, identity: { key: st.identity ?? a.id, label: st.identityLabel, checkedAt: Date.now() } });
147810
+ }
147811
+ } catch {}
147812
+ }));
147813
+ accountStatusCacheRef.current = statuses;
147814
+ }, []);
146993
147815
  const askModeRef = import_react26.useRef(false);
147816
+ const runCliBackend = import_react26.useCallback(async (args) => {
147817
+ const { binary, profile, modelId, accountId, efforts, label, pinned, prompt, messages, onEvent, signal } = args;
147818
+ usedAccountRef.current = accountId;
147819
+ const detail = pinned ? `${binary}${label ? ` · ${label}` : ""} owns tools and permissions` : `${binary}${label ? ` · ${label}` : ""} subscription · seat (~free) owns tools and permissions`;
147820
+ onEvent({ type: "phase", label: "using subscription", detail, state: "running" });
147821
+ const _cliEffortRaw = normalizeEffort(effortRef.current, efforts);
147822
+ if (_cliEffortRaw === null && effortRef.current !== "medium") {
147823
+ const { level: nearest } = clampEffort(effortRef.current, efforts);
147824
+ const hint = efforts.length ? ` — try /effort ${nearest}` : "";
147825
+ throw new Error(`effort "${effortRef.current}" is not supported by ${label ?? binary} (supports: ${efforts.join(", ") || "none"}${hint})`);
147826
+ }
147827
+ const cliEffort = _cliEffortRaw ?? undefined;
147828
+ const activeAccount = getAccount(accountId);
147829
+ const activeName = activeAccount ? accountName(activeAccount).match(/\((.*)\)/)?.[1] : undefined;
147830
+ const reloginCommand = binary.includes("codex") ? `/account add codex${activeName ? ` ${activeName}` : ""}` : `/account add claude${activeName ? ` ${activeName}` : ""}`;
147831
+ let cliPrompt = prompt;
147832
+ if (!cliSessionRef.current) {
147833
+ try {
147834
+ const cwd2 = process.cwd();
147835
+ const allFiles = listProjectFiles(cwd2).slice(0, 300);
147836
+ const map4 = repoMap(cwd2, 3000);
147837
+ const fileList = allFiles.join(`
147838
+ `);
147839
+ cliPrompt = `<project-context cwd="${cwd2}">
147840
+ ` + `<files>
147841
+ ${fileList}
147842
+ </files>
147843
+ ` + (map4 ? `<signatures>
147844
+ ${map4}
147845
+ </signatures>
147846
+ ` : "") + `</project-context>
147847
+
147848
+ ` + prompt;
147849
+ } catch {}
147850
+ }
147851
+ const r2 = await runCliTask({
147852
+ binary,
147853
+ prompt: cliPrompt,
147854
+ messages,
147855
+ onEvent,
147856
+ signal,
147857
+ sessionId: cliSessionRef.current,
147858
+ autoApprove: isYolo(),
147859
+ profile,
147860
+ modelId,
147861
+ effort: cliEffort,
147862
+ accountLabel: activeAccount ? accountLabel(activeAccount) : accountId,
147863
+ reloginCommand,
147864
+ deferTerminal: args.deferTerminal
147865
+ });
147866
+ cliSessionRef.current = r2.sessionId ?? cliSessionRef.current;
147867
+ cliMetaRef.current = { costUSD: r2.costUSD, rates: r2.rates };
147868
+ return { messages: r2.messages, usage: r2.usage, failure: r2.failure };
147869
+ }, []);
146994
147870
  const defaultRunner = import_react26.useCallback(async ({ prompt, messages, onEvent, selector: sel, signal }) => {
146995
147871
  const isAsk = askModeRef.current;
146996
147872
  askModeRef.current = false;
@@ -147001,115 +147877,146 @@ function App2({ selector: initialSelector, runner, fullscreen = false, resumeId
147001
147877
  return { messages, usage: { inputTokens: 0, outputTokens: 0 } };
147002
147878
  }
147003
147879
  const choice3 = new RoutingSelector().select({ prompt, kind: "search" });
147880
+ if (choice3.backend?.kind === "cli") {
147881
+ onEvent({ type: "error", message: "/ask needs an API-key account — it can't run on a subscription. Add one with /account add <key>." });
147882
+ return { messages, usage: { inputTokens: 0, outputTokens: 0 } };
147883
+ }
147004
147884
  routedRef.current = { model: choice3.model, reason: choice3.reason };
147005
147885
  onEvent({ type: "model-pick", model: choice3.model.label, provider: choice3.model.provider, reason: choice3.reason });
147006
- const acct = accountResolver.pick(choice3.model.provider);
147007
- const creds2 = acct ? await resolveCreds(acct) : undefined;
147886
+ const acct = choice3.backend?.kind === "in-loop" && choice3.backend.account || defaultAccount(choice3.model.provider);
147887
+ const creds = acct ? await resolveCreds(acct) : undefined;
147008
147888
  usedAccountRef.current = acct?.id ?? null;
147009
147889
  cliMetaRef.current = null;
147010
147890
  if (acct)
147011
147891
  markUsed(acct.id);
147012
- const r3 = await runCompletion({ model: choice3.model, system: buildAskSystem(docs), prompt, onEvent, signal, creds: creds2 });
147013
- return { messages, usage: r3.usage };
147892
+ const r2 = await runCompletion({ model: choice3.model, system: buildAskSystem(docs), prompt, onEvent, signal, creds });
147893
+ return { messages, usage: r2.usage };
147014
147894
  }
147015
- const cli = activeCliRef.current;
147016
- if (cli) {
147017
- if (activeImagesRef.current.length) {
147018
- onEvent({
147019
- type: "error",
147020
- message: "image attachments need an API-backed model in Gearbox right now. Use `/account off` or an API-key account, then retry with the image path."
147021
- });
147022
- return { messages, usage: { inputTokens: 0, outputTokens: 0 } };
147023
- }
147895
+ const imagesPresent = activeImagesRef.current.length > 0;
147896
+ const cliImageGuard = () => {
147897
+ onEvent({
147898
+ type: "error",
147899
+ message: "image attachments need an API-backed model in Gearbox right now. Use `/account off` or an API-key account, then retry with the image path."
147900
+ });
147901
+ return { messages, usage: { inputTokens: 0, outputTokens: 0 } };
147902
+ };
147903
+ const pin = activeCliRef.current;
147904
+ if (pin) {
147905
+ if (imagesPresent)
147906
+ return cliImageGuard();
147024
147907
  routedRef.current = null;
147025
- usedAccountRef.current = cli.id;
147026
- const modelLabel2 = cliModelLabel(activeCliModelRef.current);
147027
- onEvent({ type: "phase", label: "using subscription", detail: `${cli.binary}${modelLabel2 ? ` · ${modelLabel2}` : ""} owns tools and permissions`, state: "running" });
147028
- const cliChoices = cliModelChoices(cli.binary);
147029
- const cliChoice = cliChoices.find((m2) => m2.id === activeCliModelRef.current) ?? cliChoices[0];
147030
- const _cliEffortRaw = cliChoice ? normalizeEffort(effortRef.current, cliChoice.efforts ?? []) : null;
147031
- if (_cliEffortRaw === null && effortRef.current !== "medium") {
147032
- const supported = cliChoice?.efforts ?? [];
147033
- const { level: nearest } = clampEffort(effortRef.current, supported);
147034
- const hint = supported.length ? ` — try /effort ${nearest}` : "";
147035
- throw new Error(`effort "${effortRef.current}" is not supported by ${cliChoice?.label ?? cli.binary} (supports: ${supported.join(", ") || "none"}${hint})`);
147036
- }
147037
- const cliEffort = _cliEffortRaw ?? undefined;
147038
- const activeAccount = getAccount(cli.id);
147039
- const activeName = activeAccount ? accountName(activeAccount).match(/\((.*)\)/)?.[1] : undefined;
147040
- const reloginCommand = cli.binary.includes("codex") ? `/account add codex${activeName ? ` ${activeName}` : ""}` : `/account add claude${activeName ? ` ${activeName}` : ""}`;
147041
- let cliPrompt = prompt;
147042
- if (!cliSessionRef.current) {
147043
- try {
147044
- const cwd2 = process.cwd();
147045
- const allFiles = listProjectFiles(cwd2).slice(0, 300);
147046
- const map4 = repoMap(cwd2, 3000);
147047
- const fileList = allFiles.join(`
147048
- `);
147049
- cliPrompt = `<project-context cwd="${cwd2}">
147050
- ` + `<files>
147051
- ${fileList}
147052
- </files>
147053
- ` + (map4 ? `<signatures>
147054
- ${map4}
147055
- </signatures>
147056
- ` : "") + `</project-context>
147057
-
147058
- ` + prompt;
147059
- } catch {}
147060
- }
147061
- const r3 = await runCliTask({
147062
- binary: cli.binary,
147063
- prompt: cliPrompt,
147908
+ cliMetaRef.current = null;
147909
+ const choices = cliModelChoices(pin.binary);
147910
+ const cliChoice = choices.find((m2) => m2.id === activeCliModelRef.current) ?? choices[0];
147911
+ return runCliBackend({
147912
+ binary: pin.binary,
147913
+ profile: pin.profile,
147914
+ modelId: activeCliModelRef.current,
147915
+ accountId: pin.id,
147916
+ efforts: cliChoice?.efforts ?? [],
147917
+ label: cliModelLabel(activeCliModelRef.current) || undefined,
147918
+ pinned: true,
147919
+ prompt,
147064
147920
  messages,
147065
147921
  onEvent,
147066
- signal,
147067
- sessionId: cliSessionRef.current,
147068
- autoApprove: isYolo(),
147069
- profile: cli.profile,
147070
- modelId: activeCliModelRef.current,
147071
- effort: cliEffort,
147072
- accountLabel: activeAccount ? accountLabel(activeAccount) : cli.id,
147073
- reloginCommand
147922
+ signal
147074
147923
  });
147075
- cliSessionRef.current = r3.sessionId ?? cliSessionRef.current;
147076
- cliMetaRef.current = { costUSD: r3.costUSD, rates: r3.rates };
147077
- return { messages: r3.messages, usage: r3.usage };
147078
147924
  }
147079
147925
  const plan = modeRef.current === "plan";
147080
- const requires = ["tools", ...activeImagesRef.current.length ? ["images"] : []];
147081
- const choice2 = sel.select({ prompt, kind: plan ? "plan" : undefined, requires });
147082
- const missing = missingRequirements(choice2.model, requires);
147083
- if (missing.length) {
147084
- throw new Error(`${choice2.model.label} cannot run this turn (${missing.join(", ")} unsupported). Use /model auto or pick a compatible model.`);
147085
- }
147086
- routedRef.current = { model: choice2.model, reason: choice2.reason };
147087
- setLastPick({ model: choice2.model, reason: choice2.reason });
147088
- onEvent({ type: "model-pick", model: choice2.model.label, provider: choice2.model.provider, reason: choice2.reason });
147089
- onEvent({ type: "phase", label: "building context", detail: choice2.model.label, state: "running" });
147090
- const userContent = imageContent(prompt, activeImagesRef.current);
147091
- const { system, messages: ctx } = buildContext({ history: messages, userText: prompt, userContent, model: choice2.model, plan });
147092
- const account = accountResolver.pick(choice2.model.provider);
147093
- const creds = account ? await resolveCreds(account) : undefined;
147094
- usedAccountRef.current = account?.id ?? null;
147095
- cliMetaRef.current = null;
147096
- if (account)
147097
- markUsed(account.id);
147098
- const _effortRaw = normalizeEffort(effortRef.current, effortLevels(choice2.model));
147099
- if (_effortRaw === null && effortRef.current !== "medium") {
147100
- const supported = effortLevels(choice2.model);
147101
- const { level: nearest } = clampEffort(effortRef.current, supported);
147102
- const hint = supported.length ? ` — try /effort ${nearest}` : "";
147103
- throw new Error(`effort "${effortRef.current}" is not supported by ${choice2.model.label} (supports: ${supported.join(", ") || "none"}${hint})`);
147104
- }
147105
- const modelEffort = _effortRaw ?? undefined;
147106
- const r2 = await runTask({ model: choice2.model, messages: ctx, onEvent, signal, plan, system, creds, effort: modelEffort });
147107
- const produced = r2.messages.slice(ctx.length);
147108
- const imageNote = activeImagesRef.current.length ? `
147926
+ const requires = ["tools", ...imagesPresent ? ["images"] : []];
147927
+ const emitTerminal = (errored, message, usage) => {
147928
+ if (errored && message)
147929
+ onEvent({ type: "error", message });
147930
+ onEvent({ type: "phase", label: errored ? "blocked" : "finished", state: errored ? "err" : "ok" });
147931
+ onEvent({ type: "done", usage });
147932
+ };
147933
+ const runAttempt = async (choice3) => {
147934
+ if (choice3.backend?.kind === "cli") {
147935
+ const acct = choice3.backend.account;
147936
+ if (imagesPresent)
147937
+ return { ...cliImageGuard(), failure: { message: "image attachments need an API-backed model" }, cooldownKey: acct.id };
147938
+ routedRef.current = { model: choice3.model, reason: choice3.reason };
147939
+ setLastPick({ model: choice3.model, reason: choice3.reason });
147940
+ onEvent({ type: "model-pick", model: choice3.model.label, provider: choice3.model.provider, reason: choice3.reason });
147941
+ const out = await runCliBackend({
147942
+ binary: choice3.backend.binary,
147943
+ profile: choice3.backend.profile,
147944
+ modelId: choice3.model.sdkId,
147945
+ accountId: acct.id,
147946
+ efforts: choice3.model.efforts ?? [],
147947
+ label: choice3.model.label,
147948
+ pinned: false,
147949
+ deferTerminal: true,
147950
+ prompt,
147951
+ messages,
147952
+ onEvent,
147953
+ signal
147954
+ });
147955
+ return { messages: out.messages, usage: out.usage, failure: out.failure, cooldownKey: acct.id };
147956
+ }
147957
+ const missing = missingRequirements(choice3.model, requires);
147958
+ if (missing.length)
147959
+ throw new Error(`${choice3.model.label} cannot run this turn (${missing.join(", ")} unsupported). Use /model auto or pick a compatible model.`);
147960
+ routedRef.current = { model: choice3.model, reason: choice3.reason };
147961
+ setLastPick({ model: choice3.model, reason: choice3.reason });
147962
+ onEvent({ type: "model-pick", model: choice3.model.label, provider: choice3.model.provider, reason: choice3.reason });
147963
+ onEvent({ type: "phase", label: "building context", detail: choice3.model.label, state: "running" });
147964
+ const userContent = imageContent(prompt, activeImagesRef.current);
147965
+ const { system, messages: ctx } = buildContext({ history: messages, userText: prompt, userContent, model: choice3.model, plan });
147966
+ const account = choice3.backend?.kind === "in-loop" && choice3.backend.account || defaultAccount(choice3.model.provider);
147967
+ const creds = account ? await resolveCreds(account) : undefined;
147968
+ usedAccountRef.current = account?.id ?? null;
147969
+ cliMetaRef.current = null;
147970
+ if (account)
147971
+ markUsed(account.id);
147972
+ const _effortRaw = normalizeEffort(effortRef.current, effortLevels(choice3.model));
147973
+ if (_effortRaw === null && effortRef.current !== "medium") {
147974
+ const supported = effortLevels(choice3.model);
147975
+ const { level: nearest } = clampEffort(effortRef.current, supported);
147976
+ const hint = supported.length ? ` — try /effort ${nearest}` : "";
147977
+ throw new Error(`effort "${effortRef.current}" is not supported by ${choice3.model.label} (supports: ${supported.join(", ") || "none"}${hint})`);
147978
+ }
147979
+ const r2 = await runTask({ model: choice3.model, messages: ctx, onEvent, signal, plan, system, creds, effort: _effortRaw ?? undefined, deferTerminal: true });
147980
+ if (account && r2.headers) {
147981
+ const apiRates = parseRateHeaders(account.provider, r2.headers, Date.now());
147982
+ if (apiRates.length)
147983
+ cliMetaRef.current = { costUSD: undefined, rates: apiRates };
147984
+ }
147985
+ const produced = r2.messages.slice(ctx.length);
147986
+ const imageNote = activeImagesRef.current.length ? `
147109
147987
 
147110
147988
  [Attached images: ${activeImagesRef.current.map((img) => basename3(img.path)).join(", ")}]` : "";
147111
- const ledger = sanitizeToolPairs([...messages, { role: "user", content: prompt + imageNote }, ...produced]);
147112
- return { messages: ledger, usage: r2.usage };
147989
+ const ledger = sanitizeToolPairs([...messages, { role: "user", content: prompt + imageNote }, ...produced]);
147990
+ return { messages: ledger, usage: r2.usage, failure: r2.failure, cooldownKey: account?.id ?? `env:${choice3.model.provider}` };
147991
+ };
147992
+ const MAX_FAILOVERS = 2;
147993
+ let choice2 = sel.select({ prompt, kind: plan ? "plan" : undefined, requires });
147994
+ for (let hop = 0;; hop++) {
147995
+ const a = await runAttempt(choice2);
147996
+ if (!a.failure) {
147997
+ emitTerminal(false, undefined, a.usage);
147998
+ return { messages: a.messages, usage: a.usage };
147999
+ }
148000
+ const exhausted = classifyFailure(a.failure.message) === "exhausted";
148001
+ if (!exhausted || hop >= MAX_FAILOVERS) {
148002
+ emitTerminal(true, a.failure.message, a.usage);
148003
+ return { messages: a.messages, usage: a.usage };
148004
+ }
148005
+ markExhausted(a.cooldownKey, DEFAULT_COOLDOWN_MS, a.failure.message);
148006
+ let next = null;
148007
+ try {
148008
+ next = sel.select({ prompt, kind: plan ? "plan" : undefined, requires });
148009
+ } catch {
148010
+ next = null;
148011
+ }
148012
+ const nextKey = next?.backend?.kind === "cli" ? next.backend.account.id : next?.backend?.kind === "in-loop" && next.backend.account ? next.backend.account.id : next ? `env:${next.model.provider}` : null;
148013
+ if (!next || nextKey === a.cooldownKey) {
148014
+ emitTerminal(true, a.failure.message, a.usage);
148015
+ return { messages: a.messages, usage: a.usage };
148016
+ }
148017
+ onEvent({ type: "phase", label: "failover", detail: `${choice2.model.label} ${shortFailure(a.failure.message)} → ${next.model.label}, continuing`, state: "running" });
148018
+ choice2 = next;
148019
+ }
147113
148020
  }, []);
147114
148021
  const compactNow = import_react26.useCallback(async (keepRecent, signal) => {
147115
148022
  let model2;
@@ -147580,17 +148487,39 @@ ${fetched.join(`
147580
148487
  } else {
147581
148488
  accountStatusCacheRef.current[acctId] = { signedIn: true, detail: st.detail };
147582
148489
  }
147583
- activeCliRef.current = { id: acctId, binary: bin, profile };
147584
148490
  cliSessionRef.current = undefined;
147585
148491
  setLastPick(null);
147586
- setActiveCli({ id: acctId, label: shortLabel });
147587
- updatePrefs({ activeAccount: acctId });
147588
- updatePhase(phaseId, "ok", `${accountLabel(res.account)} active`, `using ${bin}${st.detail ? `; ${st.detail}` : ""}. Own tools/permissions; /account off returns to API routing`);
148492
+ const otherUsable = listAccounts().some((a) => a.enabled && a.id !== acctId) || [process.env.ANTHROPIC_API_KEY, process.env.OPENAI_API_KEY, process.env.GOOGLE_GENERATIVE_AI_API_KEY, process.env.DEEPSEEK_API_KEY].some(Boolean);
148493
+ if (otherUsable) {
148494
+ activeCliRef.current = null;
148495
+ setActiveCli(null);
148496
+ updatePrefs({ activeAccount: null });
148497
+ updatePhase(phaseId, "ok", `${accountLabel(res.account)} added`, `routing will prefer this seat (~free) and fall back to your API keys at its limit. /account ${accountSlug(res.account)} to pin it; /account off anytime.`);
148498
+ } else {
148499
+ activeCliRef.current = { id: acctId, binary: bin, profile };
148500
+ setActiveCli({ id: acctId, label: shortLabel });
148501
+ updatePrefs({ activeAccount: acctId });
148502
+ updatePhase(phaseId, "ok", `${accountLabel(res.account)} active`, `using ${bin}${st.detail ? `; ${st.detail}` : ""}. Own tools/permissions; /account off returns to API routing`);
148503
+ }
147589
148504
  } catch (e2) {
147590
148505
  updatePhase(phaseId, "err", `${accountLabel(res.account)} sign-in`, e2?.message ?? String(e2));
147591
148506
  }
147592
148507
  })();
147593
148508
  };
148509
+ const reloginByRef = (arg2) => {
148510
+ const ref = findAccountRef(arg2, listAccounts());
148511
+ const a = ref.account ?? (activeCliRef.current ? getAccount(activeCliRef.current.id) : undefined);
148512
+ if (a && a.exec === "cli") {
148513
+ const nick = accountName(a).match(/\((.*)\)/)?.[1];
148514
+ signInCli(`${a.provider.replace(/-cli$/, "")}${nick ? ` ${nick}` : ""}`.trim());
148515
+ return;
148516
+ }
148517
+ if (a && a.exec !== "cli") {
148518
+ notice(`${accountName(a)} is an API-key account — nothing to re-login. Use /account ${accountSlug(a)} to switch to it, or /account add ${a.provider} <key> to replace the key.`);
148519
+ return;
148520
+ }
148521
+ signInCli(arg2);
148522
+ };
147594
148523
  try {
147595
148524
  switch (name31) {
147596
148525
  case "exit":
@@ -147878,6 +148807,33 @@ ${fetched.join(`
147878
148807
  notice(`remembered: prefer ${pref.modelId} for ${pref.kind} tasks`);
147879
148808
  return;
147880
148809
  }
148810
+ case "budget": {
148811
+ echo(text2);
148812
+ const parts = arg.split(/\s+/).filter(Boolean);
148813
+ if (parts.length === 0) {
148814
+ const b = loadBudgets();
148815
+ const keys2 = Object.keys(b);
148816
+ notice(keys2.length ? `budgets (estimates remaining = budget − tracked spend):
148817
+ ` + keys2.map((k) => ` ${k}: $${b[k].amountUSD} ${b[k].period}`).join(`
148818
+ `) : "no budgets set. /budget <provider|account> <amount> [monthly|total] — lets routing estimate remaining credit for providers that don't expose a balance");
148819
+ return;
148820
+ }
148821
+ const [target, amountRaw, periodRaw] = parts;
148822
+ if (amountRaw && /^off$/i.test(amountRaw)) {
148823
+ setBudget(target, null);
148824
+ notice(`cleared budget for ${target}`);
148825
+ return;
148826
+ }
148827
+ const amount = Number(amountRaw);
148828
+ if (!target || !amountRaw || !Number.isFinite(amount) || amount <= 0) {
148829
+ notice("usage: /budget <provider|account> <amountUSD> [monthly|total] · /budget <target> off");
148830
+ return;
148831
+ }
148832
+ const period = periodRaw && /^total$/i.test(periodRaw) ? "total" : "monthly";
148833
+ setBudget(target, { amountUSD: amount, period });
148834
+ notice(`budget set: ${target} → $${amount} ${period}. Routing will preserve it as it runs low (estimated from your spend).`);
148835
+ return;
148836
+ }
147881
148837
  case "memory": {
147882
148838
  if (arg) {
147883
148839
  echo(text2);
@@ -147916,6 +148872,21 @@ ${fetched.join(`
147916
148872
  push(it);
147917
148873
  return;
147918
148874
  }
148875
+ case "why": {
148876
+ echo(text2);
148877
+ const sel = selectorRef.current;
148878
+ if (!sel.explain) {
148879
+ notice("routing is off — a model or subscription is pinned. Use /model auto to route per task, then /why.");
148880
+ return;
148881
+ }
148882
+ try {
148883
+ const card = sel.explain({ prompt: lastPromptRef.current || "(your next message)", kind: modeRef.current === "plan" ? "plan" : undefined });
148884
+ push({ kind: "scorecard", id: idRef.current++, card });
148885
+ } catch (e2) {
148886
+ notice(e2?.message ?? "couldn't build the scorecard");
148887
+ }
148888
+ return;
148889
+ }
147919
148890
  case "onboard": {
147920
148891
  echo(text2);
147921
148892
  if (arg.trim().toLowerCase() === "providers") {
@@ -147979,6 +148950,10 @@ Example: /mcp add github npx -y @modelcontextprotocol/server-github`);
147979
148950
  case "account": {
147980
148951
  if (!arg.trim() && fullscreen) {
147981
148952
  setPanel({ kind: "accounts", title: "accounts · ⏎ to switch", index: 0 });
148953
+ refreshCliStatuses().then(() => {
148954
+ if (panelRef.current?.kind === "accounts")
148955
+ setPanel({ ...panelRef.current });
148956
+ });
147982
148957
  return;
147983
148958
  }
147984
148959
  echo(text2);
@@ -148016,17 +148991,20 @@ Example: /mcp add github npx -y @modelcontextprotocol/server-github`);
148016
148991
  try {
148017
148992
  const fresh = listAccounts();
148018
148993
  const statuses = await checkCliAccounts(fresh);
148019
- pushAccounts(buildAccountView(fresh, activeCliRef.current?.id ?? null, importableEnvCreds(), statuses));
148994
+ await Promise.all(fresh.filter((a) => a.exec !== "cli" && !isFresh(a.health, Date.now())).map(async (a) => {
148995
+ try {
148996
+ const h2 = await checkHealth(a);
148997
+ recordHealth(a, h2.state, h2.detail);
148998
+ } catch {}
148999
+ }));
149000
+ const withHealth = listAccounts();
149001
+ pushAccounts(buildAccountView(withHealth, activeCliRef.current?.id ?? null, importableEnvCreds(), statuses));
148020
149002
  } catch (e2) {
148021
149003
  notice(`couldn't check subscription accounts — ${e2?.message ?? String(e2)}`);
148022
149004
  pushAccounts(buildAccountView(listAccounts(), activeCliRef.current?.id ?? null, importableEnvCreds(), accountStatusCacheRef.current));
148023
149005
  }
148024
149006
  })();
148025
149007
  };
148026
- const byNumber = (s2) => {
148027
- const n = Number(s2);
148028
- return Number.isInteger(n) && n >= 1 && n <= all.length ? all[n - 1] : undefined;
148029
- };
148030
149008
  const activate = (a) => {
148031
149009
  if (a.exec === "cli") {
148032
149010
  const bin = a.auth.binary;
@@ -148079,18 +149057,11 @@ Example: /mcp add github npx -y @modelcontextprotocol/server-github`);
148079
149057
  notice("left the subscription — back to your API keys");
148080
149058
  return;
148081
149059
  }
148082
- const numbered = byNumber(subL);
148083
- if (numbered) {
148084
- activate(numbered);
148085
- return;
148086
- }
148087
- if (/^\d+$/.test(subL)) {
148088
- notice(all.length ? `there's no account ${subL} — pick 1–${all.length}.
148089
-
148090
- ` + formatAccounts(all, activeId, []) : "no accounts yet — /account add to add one");
149060
+ if (subL === "login") {
149061
+ reloginByRef(parts.slice(1).join(" "));
148091
149062
  return;
148092
149063
  }
148093
- if (!["add", "remove", "rm", "import", "off", "refresh"].includes(subL)) {
149064
+ if (!["add", "remove", "rm", "import", "off", "login", "refresh"].includes(subL)) {
148094
149065
  const ref = findAccountRef(arg, all);
148095
149066
  if (ref.account) {
148096
149067
  activate(ref.account);
@@ -148136,14 +149107,12 @@ Example: /mcp add github npx -y @modelcontextprotocol/server-github`);
148136
149107
  res = await addOpenAICompatAccount(parts[2] ?? "", parts[3] ?? "", parts[4] ?? "", parts.slice(5));
148137
149108
  } else if (catalogProvider(first)?.authKind === "openai-compat" && !catalogProvider(first)?.baseUrl && /^https?:\/\//i.test(parts[2] ?? "")) {
148138
149109
  res = await addOpenAICompatAccount(first, parts[2] ?? "", parts[3] ?? "", parts.slice(4));
149110
+ } else if (["bedrock", "aws"].includes(first)) {
149111
+ res = await addBedrockAccount(parts[2] ?? "", parts[3] ?? "", parts[4] ?? "");
148139
149112
  } else if (provGiven)
148140
149113
  res = await addApiKeyAccount(provGiven, keyVal);
148141
- else if (detectProviderByKey(key))
149114
+ else
148142
149115
  res = await addByPastedKey(key);
148143
- else {
148144
- notice(`"${key}" isn't a recognized key. Try /account add claude, /account add codex, or paste a full API key.`);
148145
- return;
148146
- }
148147
149116
  if (!res.ok || !res.account) {
148148
149117
  notice(res.message);
148149
149118
  return;
@@ -148224,7 +149193,7 @@ Example: /mcp add github npx -y @modelcontextprotocol/server-github`);
148224
149193
  }
148225
149194
  case "login": {
148226
149195
  echo(text2);
148227
- signInCli(arg);
149196
+ reloginByRef(arg);
148228
149197
  return;
148229
149198
  }
148230
149199
  case "cost":
@@ -148518,23 +149487,17 @@ Example: /mcp add github npx -y @modelcontextprotocol/server-github`);
148518
149487
  return;
148519
149488
  }
148520
149489
  if (p.kind === "accounts") {
148521
- const nums = panelAccountNumbersRef.current;
148522
- const n = nums.length;
149490
+ const slugs = panelAccountSlugsRef.current;
149491
+ const n = slugs.length;
148523
149492
  if (key.upArrow)
148524
149493
  setPanel({ ...p, index: clampIndex(p.index - 1, n) });
148525
149494
  else if (key.downArrow)
148526
149495
  setPanel({ ...p, index: clampIndex(p.index + 1, n) });
148527
149496
  else if (key.return) {
148528
- const num = nums[clampIndex(p.index, n)];
149497
+ const slug3 = slugs[clampIndex(p.index, n)];
148529
149498
  setPanel(null);
148530
- if (num)
148531
- handleCommand(`/account ${num}`);
148532
- } else if (/^[1-9]$/.test(input)) {
148533
- const k = Number(input);
148534
- if (k <= n) {
148535
- setPanel(null);
148536
- handleCommand(`/account ${k}`);
148537
- }
149499
+ if (slug3)
149500
+ handleCommand(`/account ${slug3}`);
148538
149501
  }
148539
149502
  return;
148540
149503
  }
@@ -148820,7 +149783,7 @@ Example: /mcp add github npx -y @modelcontextprotocol/server-github`);
148820
149783
  panelMaxScrollRef.current = Math.max(0, panelStaticLines.length - panelBodyHeight(transcriptHeight));
148821
149784
  } else if (panel?.kind === "accounts") {
148822
149785
  panelAccountView = buildAccountView(listAccounts(), activeCliRef.current?.id ?? null, importableEnvCreds(), accountStatusCacheRef.current);
148823
- panelAccountNumbersRef.current = panelAccountView.rows.map((r2) => r2.number);
149786
+ panelAccountSlugsRef.current = panelAccountView.rows.map((r2) => r2.alias);
148824
149787
  } else if (panel?.kind === "models") {
148825
149788
  panelModels = buildPanelModelRows(panelCurrentModel);
148826
149789
  }
@@ -149276,7 +150239,7 @@ async function runCliOnboarding() {
149276
150239
  const { importableEnvCreds: importableEnvCreds2, importEnvCred: importEnvCred2, importableCloudCreds: importableCloudCreds2, importCloudCred: importCloudCred2 } = await Promise.resolve().then(() => (init_detect(), exports_detect));
149277
150240
  const { addApiKeyAccount: addApiKeyAccount2, addAzureAccount: addAzureAccount2, addAzureFoundryAccount: addAzureFoundryAccount2, addByPastedKey: addByPastedKey2, testAccount: testAccount2, addableProviders: addableProviders2, addCliAccount: addCliAccount2, cliAuthStatus: cliAuthStatus2, cliLoginArgs: cliLoginArgs2 } = await Promise.resolve().then(() => (init_onboard(), exports_onboard));
149278
150241
  const { subscriptionEnv: subscriptionEnv2 } = await Promise.resolve().then(() => (init_cli_backend(), exports_cli_backend));
149279
- const { detectProviderByKey: detectProviderByKey2 } = await Promise.resolve().then(() => (init_catalog(), exports_catalog));
150242
+ const { detectProviderByKey: detectProviderByKey3 } = await Promise.resolve().then(() => (init_catalog(), exports_catalog));
149280
150243
  const { which: which2 } = await Promise.resolve().then(() => (init_proc(), exports_proc));
149281
150244
  const pipedAnswers = process.stdin.isTTY ? null : (await readStdin()).split(/\r?\n/);
149282
150245
  const rl = pipedAnswers ? null : createInterface({ input: process.stdin, output: process.stdout });
@@ -149369,7 +150332,7 @@ async function runCliOnboarding() {
149369
150332
  const key = await ask("Paste API key: ");
149370
150333
  if (!key)
149371
150334
  continue;
149372
- const detected = detectProviderByKey2(key);
150335
+ const detected = detectProviderByKey3(key);
149373
150336
  if (!detected) {
149374
150337
  console.log(warn("Could not detect the provider from that key. Use option 3."));
149375
150338
  continue;
@@ -149545,7 +150508,7 @@ if (args[0] === "auth") {
149545
150508
  const { importableEnvCreds: importableEnvCreds2, importEnvCred: importEnvCred2, importableCloudCreds: importableCloudCreds2, importCloudCred: importCloudCred2 } = await Promise.resolve().then(() => (init_detect(), exports_detect));
149546
150509
  const { addApiKeyAccount: addApiKeyAccount2, addByPastedKey: addByPastedKey2, addOpenAICompatAccount: addOpenAICompatAccount2, testAccount: testAccount2, addableProviders: addableProviders2, addCliAccount: addCliAccount2, cliAuthStatus: cliAuthStatus2, cliLoginArgs: cliLoginArgs2 } = await Promise.resolve().then(() => (init_onboard(), exports_onboard));
149547
150510
  const { subscriptionEnv: subscriptionEnv2 } = await Promise.resolve().then(() => (init_cli_backend(), exports_cli_backend));
149548
- const { detectProviderByKey: detectProviderByKey2 } = await Promise.resolve().then(() => (init_catalog(), exports_catalog));
150511
+ const { detectProviderByKey: detectProviderByKey3 } = await Promise.resolve().then(() => (init_catalog(), exports_catalog));
149549
150512
  const sub = args[1];
149550
150513
  const rest2 = args.slice(2);
149551
150514
  if (sub === "list" || !sub) {
@@ -149570,7 +150533,7 @@ Importable from your env (gearbox auth import): ${imp.map((c) => c.envVar).join(
149570
150533
  } else if (sub === "add") {
149571
150534
  const head2 = (rest2[0] ?? "").toLowerCase();
149572
150535
  const cliProvider = head2 === "codex" || head2 === "chatgpt" ? "codex-cli" : head2 === "claude" ? "claude-cli" : "";
149573
- const res = cliProvider ? addCliAccount2(cliProvider, rest2.slice(1).join(" ").trim() || undefined) : ["openai-compat", "openai-compatible", "custom", "proxy"].includes(head2) ? await addOpenAICompatAccount2(rest2[1] ?? "", rest2[2] ?? "", rest2[3] ?? "", rest2.slice(4)) : rest2[0] && !rest2[1] && detectProviderByKey2(rest2[0]) ? await addByPastedKey2(rest2[0]) : rest2[0] && rest2[1] ? await addApiKeyAccount2(rest2[0], rest2[1]) : { ok: false, message: "usage: gearbox auth add <key> | gearbox auth add <provider> <key> | gearbox auth add openai-compat <name> <base-url> <key> <model> | gearbox auth add codex [name]" };
150536
+ const res = cliProvider ? addCliAccount2(cliProvider, rest2.slice(1).join(" ").trim() || undefined) : ["openai-compat", "openai-compatible", "custom", "proxy"].includes(head2) ? await addOpenAICompatAccount2(rest2[1] ?? "", rest2[2] ?? "", rest2[3] ?? "", rest2.slice(4)) : rest2[0] && !rest2[1] && detectProviderByKey3(rest2[0]) ? await addByPastedKey2(rest2[0]) : rest2[0] && rest2[1] ? await addApiKeyAccount2(rest2[0], rest2[1]) : { ok: false, message: "usage: gearbox auth add <key> | gearbox auth add <provider> <key> | gearbox auth add openai-compat <name> <base-url> <key> <model> | gearbox auth add codex [name]" };
149574
150537
  console.log(res.message);
149575
150538
  if (res.ok && res.account) {
149576
150539
  if (res.account.exec === "cli" && res.account.auth.kind === "cli") {