gearbox-code 0.1.37 → 0.2.0

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 +1524 -582
  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();
@@ -142905,7 +143501,7 @@ function Panel({
142905
143501
  }, r2.alias, true, undefined, this);
142906
143502
  })
142907
143503
  }, undefined, false, undefined, this);
142908
- hint = "↑↓ move · ⏎ switch · 1–9 jump · esc close";
143504
+ hint = "↑↓ move · ⏎ switch · esc close";
142909
143505
  } else {
142910
143506
  const rows = filterModelRows(models ?? [], panel.filter);
142911
143507
  const idx = clampIndex(panel.index, rows.length);
@@ -144542,13 +145138,19 @@ var resultSummary = (out) => {
144542
145138
  async function runTask(opts) {
144543
145139
  const { model, messages, onEvent, signal, plan } = opts;
144544
145140
  const usage = { inputTokens: 0, outputTokens: 0 };
145141
+ let failureMessage;
144545
145142
  const providerOptions = opts.effort ? reasoningOptions(model, opts.effort) : {};
144546
145143
  let errored = false;
145144
+ let producedOutput = false;
145145
+ let failureRaw = undefined;
144547
145146
  const emitErr = (err) => {
144548
145147
  if (errored || signal?.aborted)
144549
145148
  return;
144550
145149
  errored = true;
144551
- onEvent({ type: "error", message: unavailableModelHint(cleanError(err), model) });
145150
+ failureMessage = cleanError(err);
145151
+ failureRaw = err;
145152
+ if (!opts.deferTerminal)
145153
+ onEvent({ type: "error", message: unavailableModelHint(failureMessage, model) });
144552
145154
  };
144553
145155
  onEvent({ type: "phase", label: "contacting model", detail: model.label, state: "running" });
144554
145156
  const activeTools = await createToolset(onEvent, { readOnly: Boolean(plan) });
@@ -144603,6 +145205,7 @@ async function runTask(opts) {
144603
145205
  case "text-delta": {
144604
145206
  const t2 = part.text ?? part.textDelta ?? "";
144605
145207
  if (t2) {
145208
+ producedOutput = true;
144606
145209
  onEvent({ type: "text", text: t2 });
144607
145210
  await maybePaint();
144608
145211
  }
@@ -144613,6 +145216,7 @@ async function runTask(opts) {
144613
145216
  const name31 = part.toolName ?? part.name ?? "tool";
144614
145217
  names.set(id, name31);
144615
145218
  started.add(id);
145219
+ producedOutput = true;
144616
145220
  openStream(id, name31);
144617
145221
  onEvent({ type: "tool-start", id, name: name31, arg: "" });
144618
145222
  onEvent({ type: "phase", label: friendlyToolPhase(name31), state: "running" });
@@ -144626,6 +145230,7 @@ async function runTask(opts) {
144626
145230
  const st = streams.get(id) ?? openStream(id, names.get(id) ?? "tool");
144627
145231
  if (!started.has(id)) {
144628
145232
  started.add(id);
145233
+ producedOutput = true;
144629
145234
  onEvent({ type: "tool-start", id, name: st.name, arg: "" });
144630
145235
  }
144631
145236
  st.rawBuf += chunk2;
@@ -144653,6 +145258,7 @@ async function runTask(opts) {
144653
145258
  onEvent({ type: "tool-stream", id, arg });
144654
145259
  } else {
144655
145260
  started.add(id);
145261
+ producedOutput = true;
144656
145262
  onEvent({ type: "tool-start", id, name: name31, arg });
144657
145263
  onEvent({ type: "phase", label: friendlyToolPhase(name31), detail: arg, state: "running" });
144658
145264
  }
@@ -144692,15 +145298,20 @@ async function runTask(opts) {
144692
145298
  emitErr(e2);
144693
145299
  }
144694
145300
  let next = messages;
145301
+ let headers;
144695
145302
  if (result2) {
144696
145303
  try {
144697
145304
  const resp = await result2.response;
144698
145305
  next = [...messages, ...resp.messages];
145306
+ headers = resp.headers;
144699
145307
  } catch {}
144700
145308
  }
144701
- onEvent({ type: "phase", label: errored ? "blocked" : "finished", state: errored ? "err" : "ok" });
144702
- onEvent({ type: "done", usage });
144703
- return { messages: next, usage };
145309
+ const failure = errored ? { message: failureMessage ?? cleanError(failureRaw), raw: failureRaw, producedOutput } : undefined;
145310
+ if (!opts.deferTerminal) {
145311
+ onEvent({ type: "phase", label: errored ? "blocked" : "finished", state: errored ? "err" : "ok" });
145312
+ onEvent({ type: "done", usage });
145313
+ }
145314
+ return { messages: next, usage, headers, failure };
144704
145315
  }
144705
145316
  async function runCompletion(opts) {
144706
145317
  const { model, system, prompt, onEvent, signal } = opts;
@@ -144900,7 +145511,7 @@ bun run typecheck
144900
145511
  },
144901
145512
  {
144902
145513
  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```"
145514
+ 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
145515
  },
144905
145516
  {
144906
145517
  file: "DESIGN.md",
@@ -145354,11 +145965,30 @@ var PROVIDERS = {
145354
145965
  "vercel-gateway": {
145355
145966
  url: "https://ai-gateway.vercel.sh/v1/credits",
145356
145967
  parse: (j) => {
145357
- const bal = j?.balance ?? j?.credits?.balance;
145358
- return typeof bal === "number" ? { remainingUSD: bal } : null;
145968
+ const bal = num2(j?.balance ?? j?.credits?.balance);
145969
+ return bal == null ? null : { remainingUSD: bal };
145970
+ }
145971
+ },
145972
+ deepseek: {
145973
+ url: "https://api.deepseek.com/user/balance",
145974
+ parse: (j) => {
145975
+ const infos = Array.isArray(j?.balance_infos) ? j.balance_infos : [];
145976
+ const pick3 = infos.find((b) => b?.currency === "USD") ?? infos[0];
145977
+ const remaining = num2(pick3?.total_balance);
145978
+ return remaining == null ? null : { remainingUSD: remaining };
145359
145979
  }
145360
145980
  }
145361
145981
  };
145982
+ function num2(v) {
145983
+ if (typeof v === "number" && Number.isFinite(v))
145984
+ return v;
145985
+ if (typeof v === "string") {
145986
+ const n = Number(v);
145987
+ if (Number.isFinite(n))
145988
+ return n;
145989
+ }
145990
+ return;
145991
+ }
145362
145992
  function balanceExposed(provider) {
145363
145993
  return provider in PROVIDERS;
145364
145994
  }
@@ -145708,6 +146338,83 @@ function writeProjectGuide(cwd2 = process.cwd()) {
145708
146338
  return { path, summary: `wrote GEARBOX.md (${diffStat(diff2)})`, diff: diff2 };
145709
146339
  }
145710
146340
 
146341
+ // src/accounts/health.ts
146342
+ init_store();
146343
+ init_onboard();
146344
+ function statusOf(err) {
146345
+ return err?.statusCode ?? err?.status ?? err?.response?.status ?? err?.data?.error?.status;
146346
+ }
146347
+ function textOf2(err) {
146348
+ return String(err?.message ?? err?.error?.message ?? err?.responseBody ?? err?.error ?? err ?? "").toLowerCase();
146349
+ }
146350
+ function classifyError(_provider, err) {
146351
+ const status = statusOf(err);
146352
+ const t2 = textOf2(err);
146353
+ if (/credit balance|insufficient_quota|insufficient funds|billing|payment|quota exceeded/.test(t2))
146354
+ return "no-credit";
146355
+ if (/not logged in|not signed in|re-?authenticate|token (?:has )?expired|expired|session expired|login required|refresh token/.test(t2))
146356
+ return "expired";
146357
+ if (status === 429 || /rate.?limit|too many requests|overloaded|capacity/.test(t2))
146358
+ return "rate-limited";
146359
+ if (status === 401 || status === 403 || /invalid.*(api.?key|x-api-key|credential|token)|incorrect api key|unauthorized|authentication.?fail|permission denied/.test(t2))
146360
+ return "invalid";
146361
+ return "real-error";
146362
+ }
146363
+ var HEALTH_TTL_MS = 5 * 60000;
146364
+ var HEALTH_CHECK_TIMEOUT_MS = 8000;
146365
+ function withTimeout(p, ms, fallback) {
146366
+ return new Promise((resolve12, reject2) => {
146367
+ let done = false;
146368
+ const timer = setTimeout(() => {
146369
+ if (!done) {
146370
+ done = true;
146371
+ resolve12(fallback);
146372
+ }
146373
+ }, ms);
146374
+ p.then((v) => {
146375
+ if (!done) {
146376
+ done = true;
146377
+ clearTimeout(timer);
146378
+ resolve12(v);
146379
+ }
146380
+ }, (e2) => {
146381
+ if (!done) {
146382
+ done = true;
146383
+ clearTimeout(timer);
146384
+ reject2(e2);
146385
+ }
146386
+ });
146387
+ });
146388
+ }
146389
+ function isFresh(h2, now3) {
146390
+ return !!h2 && now3 - h2.checkedAt < HEALTH_TTL_MS;
146391
+ }
146392
+ function recordHealth(account, state, detail) {
146393
+ const at3 = Date.now();
146394
+ const cur = getAccount(account.id) ?? account;
146395
+ putAccount({ ...cur, health: { state, checkedAt: at3, detail } });
146396
+ }
146397
+ function checkHealth(account) {
146398
+ const at3 = Date.now();
146399
+ const probe = (async () => {
146400
+ try {
146401
+ if (account.exec === "cli") {
146402
+ const bin = account.auth.binary;
146403
+ const profile = account.auth.loginProfile;
146404
+ const st = await cliAuthStatus(bin, profile);
146405
+ return { state: st.loggedIn ? "ok" : "expired", checkedAt: at3, detail: st.detail };
146406
+ }
146407
+ const r2 = await testAccount(account);
146408
+ if (r2.ok)
146409
+ return { state: "ok", checkedAt: at3 };
146410
+ return { state: classifyError(account.provider, { message: r2.message }), checkedAt: at3, detail: r2.message };
146411
+ } catch (e2) {
146412
+ return { state: classifyError(account.provider, e2), checkedAt: at3, detail: String(e2?.message ?? e2) };
146413
+ }
146414
+ })();
146415
+ return withTimeout(probe, HEALTH_CHECK_TIMEOUT_MS, { state: "unknown", checkedAt: at3, detail: "health check timed out" });
146416
+ }
146417
+
145711
146418
  // src/ui/App.tsx
145712
146419
  init_mcp();
145713
146420
 
@@ -145917,7 +146624,6 @@ import { basename as basename3, extname, resolve as resolve12 } from "node:path"
145917
146624
  import { existsSync as existsSync11, readFileSync as readFileSync16, statSync as statSync5 } from "node:fs";
145918
146625
  import { writeFile as fsWriteFile } from "node:fs/promises";
145919
146626
  import { spawnSync as nodeSpawnSync2 } from "node:child_process";
145920
- var accountResolver = new AccountResolver;
145921
146627
  var KEYS_HELP = [
145922
146628
  "Keyboard shortcuts",
145923
146629
  " ⏎ send · ⌃J newline · esc interrupt · ⌃C twice to quit",
@@ -145984,6 +146690,18 @@ var FALLBACK_CODEX_MODELS = [
145984
146690
  { id: "gpt-5.4", label: "gpt-5.4", provider: "codex", efforts: ["low", "medium", "high", "xhigh"] },
145985
146691
  { id: "gpt-5.4-mini", label: "gpt-5.4-mini", provider: "codex", efforts: ["low", "medium", "high", "xhigh"] }
145986
146692
  ];
146693
+ function shortFailure(message) {
146694
+ const m2 = (message || "").toLowerCase();
146695
+ if (/\b402\b|credit|payment|billing|out of credit/.test(m2))
146696
+ return "out of credit";
146697
+ if (/over(loaded|capacity)|\b529\b/.test(m2))
146698
+ return "overloaded";
146699
+ if (/usage.?limit/.test(m2))
146700
+ return "at its usage limit";
146701
+ if (/quota|insufficient_quota/.test(m2))
146702
+ return "out of quota";
146703
+ return "rate-limited";
146704
+ }
145987
146705
  var codexModelCache = null;
145988
146706
  function codexCliModels() {
145989
146707
  if (codexModelCache)
@@ -146287,7 +147005,7 @@ function App2({ selector: initialSelector, runner, fullscreen = false, resumeId
146287
147005
  setPanelState(p);
146288
147006
  };
146289
147007
  const panelMaxScrollRef = import_react26.useRef(0);
146290
- const panelAccountNumbersRef = import_react26.useRef([]);
147008
+ const panelAccountSlugsRef = import_react26.useRef([]);
146291
147009
  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
147010
  const openInfoPanel = (title, item) => {
146293
147011
  if (!fullscreen)
@@ -146390,7 +147108,10 @@ function App2({ selector: initialSelector, runner, fullscreen = false, resumeId
146390
147108
  setTimeout(pumpPerm, 0);
146391
147109
  };
146392
147110
  import_react26.useEffect(() => {
146393
- const acctId = loadPrefs().activeAccount;
147111
+ let acctId = loadPrefs().activeAccount;
147112
+ if (!acctId && buildPanelModelRows().length === 0) {
147113
+ acctId = listAccounts().find((a2) => a2.enabled && a2.exec === "cli")?.id;
147114
+ }
146394
147115
  if (!acctId)
146395
147116
  return;
146396
147117
  const a = getAccount(acctId);
@@ -146400,6 +147121,7 @@ function App2({ selector: initialSelector, runner, fullscreen = false, resumeId
146400
147121
  if (activeCliModelRef.current && !cliSupportsModel(bin, activeCliModelRef.current))
146401
147122
  setActiveCliModelId(undefined);
146402
147123
  setActiveCli({ id: a.id, label: bin });
147124
+ updatePrefs({ activeAccount: a.id });
146403
147125
  }
146404
147126
  }, []);
146405
147127
  const discoveryRanRef = import_react26.useRef(false);
@@ -146424,6 +147146,43 @@ function App2({ selector: initialSelector, runner, fullscreen = false, resumeId
146424
147146
  notice(`loaded the real model list for ${learned} account${learned === 1 ? "" : "s"} — /model to see them`);
146425
147147
  })();
146426
147148
  }, []);
147149
+ import_react26.useEffect(() => {
147150
+ let cancelled = false;
147151
+ (async () => {
147152
+ const now3 = Date.now();
147153
+ const stale = listAccounts().filter((a) => !isFresh(a.health, now3));
147154
+ await Promise.all(stale.map(async (a) => {
147155
+ try {
147156
+ const h2 = await checkHealth(a);
147157
+ if (cancelled)
147158
+ return;
147159
+ recordHealth(a, h2.state, h2.detail);
147160
+ } catch {}
147161
+ }));
147162
+ })();
147163
+ return () => {
147164
+ cancelled = true;
147165
+ };
147166
+ }, []);
147167
+ import_react26.useEffect(() => {
147168
+ let alive = true;
147169
+ const refresh = async () => {
147170
+ const targets = listAccounts().filter((a) => a.enabled && a.exec !== "cli" && balanceExposed(a.provider));
147171
+ for (const a of targets) {
147172
+ if (!alive)
147173
+ return;
147174
+ const bal = await fetchBalance(a);
147175
+ if (bal?.remainingUSD != null)
147176
+ recordBalance(a.id, bal);
147177
+ }
147178
+ };
147179
+ refresh();
147180
+ const t2 = setInterval(() => void refresh(), 5 * 60000);
147181
+ return () => {
147182
+ alive = false;
147183
+ clearInterval(t2);
147184
+ };
147185
+ }, []);
146427
147186
  import_react26.useEffect(() => {
146428
147187
  setPermissionHandler((req) => new Promise((resolve13) => {
146429
147188
  if (modeRef.current === "auto-accept" && (req.kind === "write" || req.kind === "edit")) {
@@ -146435,12 +147194,48 @@ function App2({ selector: initialSelector, runner, fullscreen = false, resumeId
146435
147194
  }));
146436
147195
  return () => setPermissionHandler(null);
146437
147196
  }, []);
146438
- const scrollBy = import_react26.useCallback((delta) => {
146439
- const cur = atBottomRef.current ? maxScrollRef.current : scrollTopRef.current;
146440
- const ns = Math.max(0, Math.min(maxScrollRef.current, cur + delta));
146441
- atBottomRef.current = ns >= maxScrollRef.current;
146442
- setScrollTop(ns);
147197
+ const scrollTargetRef = import_react26.useRef(null);
147198
+ const scrollAnimRef = import_react26.useRef(null);
147199
+ const noMotion = process.env.GEARBOX_NO_MOTION === "1";
147200
+ const stopScrollAnim = import_react26.useCallback(() => {
147201
+ if (scrollAnimRef.current) {
147202
+ clearInterval(scrollAnimRef.current);
147203
+ scrollAnimRef.current = null;
147204
+ }
146443
147205
  }, []);
147206
+ const scrollBy = import_react26.useCallback((delta) => {
147207
+ const max2 = maxScrollRef.current;
147208
+ const cur = atBottomRef.current ? max2 : scrollTopRef.current;
147209
+ const target = Math.max(0, Math.min(max2, (scrollTargetRef.current ?? cur) + delta));
147210
+ if (noMotion) {
147211
+ scrollTargetRef.current = null;
147212
+ atBottomRef.current = target >= max2;
147213
+ setScrollTop(target);
147214
+ return;
147215
+ }
147216
+ scrollTargetRef.current = target;
147217
+ if (scrollAnimRef.current)
147218
+ return;
147219
+ let pos = cur;
147220
+ scrollAnimRef.current = setInterval(() => {
147221
+ const m2 = maxScrollRef.current;
147222
+ const tgt = Math.max(0, Math.min(m2, scrollTargetRef.current ?? pos));
147223
+ const diff2 = tgt - pos;
147224
+ if (Math.abs(diff2) < 1) {
147225
+ pos = tgt;
147226
+ scrollTargetRef.current = null;
147227
+ stopScrollAnim();
147228
+ } else {
147229
+ const step = Math.sign(diff2) * Math.max(1, Math.round(Math.abs(diff2) * 0.35));
147230
+ pos += step;
147231
+ if (diff2 > 0 && pos > tgt || diff2 < 0 && pos < tgt)
147232
+ pos = tgt;
147233
+ }
147234
+ atBottomRef.current = pos >= m2;
147235
+ setScrollTop(pos);
147236
+ }, 16);
147237
+ }, [noMotion, stopScrollAnim]);
147238
+ import_react26.useEffect(() => stopScrollAnim, [stopScrollAnim]);
146444
147239
  const copyWithFeedback = import_react26.useCallback((text2) => {
146445
147240
  const clean = text2.replace(/[ \t]+\n/g, `
146446
147241
  `).trim();
@@ -146671,8 +147466,18 @@ function App2({ selector: initialSelector, runner, fullscreen = false, resumeId
146671
147466
  }
146672
147467
  }
146673
147468
  }
146674
- if (delta)
146675
- scrollBy(delta);
147469
+ if (delta) {
147470
+ const p = panelRef.current;
147471
+ if (p) {
147472
+ if (p.kind === "static")
147473
+ setPanel({ ...p, scroll: clampScroll(p.scroll + delta, panelMaxScrollRef.current) });
147474
+ else if (p.kind === "accounts")
147475
+ setPanel({ ...p, index: clampIndex(p.index + delta, panelAccountSlugsRef.current.length) });
147476
+ else
147477
+ setPanel({ ...p, index: clampIndex(p.index + delta, filterModelRows(buildPanelModelRows(), p.filter).length) });
147478
+ } else
147479
+ scrollBy(delta);
147480
+ }
146676
147481
  };
146677
147482
  stdin.on("data", onData);
146678
147483
  return () => {
@@ -146922,10 +147727,10 @@ function App2({ selector: initialSelector, runner, fullscreen = false, resumeId
146922
147727
  const pushUsage = (view) => push({ kind: "usage", id: idRef.current++, view });
146923
147728
  const pushAccounts = (view) => push({ kind: "accounts", id: idRef.current++, view });
146924
147729
  const normalizeAccountRef = (s2) => s2.toLowerCase().replace(/[()]/g, " ").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
146925
- const accountAliases = (a, index2) => {
147730
+ const accountAliases = (a) => {
146926
147731
  const name31 = accountName(a);
146927
147732
  const slug3 = accountSlug(a);
146928
- const aliases = new Set([String(index2 + 1), slug3, normalizeAccountRef(name31), normalizeAccountRef(a.label), normalizeAccountRef(a.id)]);
147733
+ const aliases = new Set([slug3, normalizeAccountRef(name31), normalizeAccountRef(a.label), normalizeAccountRef(a.id)]);
146929
147734
  const nick = name31.match(/\(([^)]+)\)/)?.[1];
146930
147735
  if (nick)
146931
147736
  aliases.add(normalizeAccountRef(nick));
@@ -146938,13 +147743,13 @@ function App2({ selector: initialSelector, runner, fullscreen = false, resumeId
146938
147743
  const findAccountRef = (query, accounts = listAccounts()) => {
146939
147744
  const q = normalizeAccountRef(query);
146940
147745
  if (!q)
146941
- return { error: "which account? use /account <name-or-number>" };
146942
- const exact = accounts.map((a, i2) => ({ a, aliases: accountAliases(a, i2) })).filter(({ aliases }) => aliases.has(q));
147746
+ return { error: "which account? use /account <name>" };
147747
+ const exact = accounts.map((a) => ({ a, aliases: accountAliases(a) })).filter(({ aliases }) => aliases.has(q));
146943
147748
  if (exact.length === 1)
146944
147749
  return { account: exact[0].a };
146945
147750
  if (exact.length > 1)
146946
147751
  return { error: `"${query}" matches ${exact.map(({ a }) => accountName(a)).join(", ")} — use the full alias` };
146947
- const fuzzy = accounts.map((a, i2) => ({ a, aliases: [...accountAliases(a, i2)] })).filter(({ aliases }) => aliases.some((x2) => x2.includes(q)));
147752
+ const fuzzy = accounts.map((a) => ({ a, aliases: [...accountAliases(a)] })).filter(({ aliases }) => aliases.some((x2) => x2.includes(q)));
146948
147753
  if (fuzzy.length === 1)
146949
147754
  return { account: fuzzy[0].a };
146950
147755
  if (fuzzy.length > 1)
@@ -146953,19 +147758,19 @@ function App2({ selector: initialSelector, runner, fullscreen = false, resumeId
146953
147758
  };
146954
147759
  const buildAccountView = (accounts, activeCliId, importable, statuses) => {
146955
147760
  const active = activeCliId ? accounts.find((a) => a.id === activeCliId) : null;
146956
- const rows2 = accounts.map((a, i2) => {
147761
+ const rows2 = accounts.map((a) => {
146957
147762
  const st = statuses[a.id];
146958
147763
  const activeRow = a.id === activeCliId;
146959
- const status = activeRow ? "active" : st?.duplicateOf ? "duplicate" : st?.signedIn === false ? "not signed in" : st?.signedIn === true ? "signed in" : a.exec === "cli" ? "not checked" : "ready";
147764
+ 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);
146960
147765
  return {
146961
147766
  name: accountName(a),
146962
147767
  type: a.exec === "cli" ? "subscription" : "API key",
146963
147768
  status,
146964
147769
  active: activeRow,
146965
147770
  alias: accountSlug(a),
146966
- number: i2 + 1,
146967
147771
  detail: st?.signedIn ? st.detail : undefined,
146968
- duplicateOf: st?.duplicateOf
147772
+ duplicateOf: st?.duplicateOf,
147773
+ health: a.health?.state
146969
147774
  };
146970
147775
  });
146971
147776
  return {
@@ -146977,6 +147782,60 @@ function App2({ selector: initialSelector, runner, fullscreen = false, resumeId
146977
147782
  };
146978
147783
  };
146979
147784
  const askModeRef = import_react26.useRef(false);
147785
+ const runCliBackend = import_react26.useCallback(async (args) => {
147786
+ const { binary, profile, modelId, accountId, efforts, label, pinned, prompt, messages, onEvent, signal } = args;
147787
+ usedAccountRef.current = accountId;
147788
+ const detail = pinned ? `${binary}${label ? ` · ${label}` : ""} owns tools and permissions` : `${binary}${label ? ` · ${label}` : ""} subscription · seat (~free) owns tools and permissions`;
147789
+ onEvent({ type: "phase", label: "using subscription", detail, state: "running" });
147790
+ const _cliEffortRaw = normalizeEffort(effortRef.current, efforts);
147791
+ if (_cliEffortRaw === null && effortRef.current !== "medium") {
147792
+ const { level: nearest } = clampEffort(effortRef.current, efforts);
147793
+ const hint = efforts.length ? ` — try /effort ${nearest}` : "";
147794
+ throw new Error(`effort "${effortRef.current}" is not supported by ${label ?? binary} (supports: ${efforts.join(", ") || "none"}${hint})`);
147795
+ }
147796
+ const cliEffort = _cliEffortRaw ?? undefined;
147797
+ const activeAccount = getAccount(accountId);
147798
+ const activeName = activeAccount ? accountName(activeAccount).match(/\((.*)\)/)?.[1] : undefined;
147799
+ const reloginCommand = binary.includes("codex") ? `/account add codex${activeName ? ` ${activeName}` : ""}` : `/account add claude${activeName ? ` ${activeName}` : ""}`;
147800
+ let cliPrompt = prompt;
147801
+ if (!cliSessionRef.current) {
147802
+ try {
147803
+ const cwd2 = process.cwd();
147804
+ const allFiles = listProjectFiles(cwd2).slice(0, 300);
147805
+ const map4 = repoMap(cwd2, 3000);
147806
+ const fileList = allFiles.join(`
147807
+ `);
147808
+ cliPrompt = `<project-context cwd="${cwd2}">
147809
+ ` + `<files>
147810
+ ${fileList}
147811
+ </files>
147812
+ ` + (map4 ? `<signatures>
147813
+ ${map4}
147814
+ </signatures>
147815
+ ` : "") + `</project-context>
147816
+
147817
+ ` + prompt;
147818
+ } catch {}
147819
+ }
147820
+ const r2 = await runCliTask({
147821
+ binary,
147822
+ prompt: cliPrompt,
147823
+ messages,
147824
+ onEvent,
147825
+ signal,
147826
+ sessionId: cliSessionRef.current,
147827
+ autoApprove: isYolo(),
147828
+ profile,
147829
+ modelId,
147830
+ effort: cliEffort,
147831
+ accountLabel: activeAccount ? accountLabel(activeAccount) : accountId,
147832
+ reloginCommand,
147833
+ deferTerminal: args.deferTerminal
147834
+ });
147835
+ cliSessionRef.current = r2.sessionId ?? cliSessionRef.current;
147836
+ cliMetaRef.current = { costUSD: r2.costUSD, rates: r2.rates };
147837
+ return { messages: r2.messages, usage: r2.usage, failure: r2.failure };
147838
+ }, []);
146980
147839
  const defaultRunner = import_react26.useCallback(async ({ prompt, messages, onEvent, selector: sel, signal }) => {
146981
147840
  const isAsk = askModeRef.current;
146982
147841
  askModeRef.current = false;
@@ -146987,115 +147846,146 @@ function App2({ selector: initialSelector, runner, fullscreen = false, resumeId
146987
147846
  return { messages, usage: { inputTokens: 0, outputTokens: 0 } };
146988
147847
  }
146989
147848
  const choice3 = new RoutingSelector().select({ prompt, kind: "search" });
147849
+ if (choice3.backend?.kind === "cli") {
147850
+ onEvent({ type: "error", message: "/ask needs an API-key account — it can't run on a subscription. Add one with /account add <key>." });
147851
+ return { messages, usage: { inputTokens: 0, outputTokens: 0 } };
147852
+ }
146990
147853
  routedRef.current = { model: choice3.model, reason: choice3.reason };
146991
147854
  onEvent({ type: "model-pick", model: choice3.model.label, provider: choice3.model.provider, reason: choice3.reason });
146992
- const acct = accountResolver.pick(choice3.model.provider);
146993
- const creds2 = acct ? await resolveCreds(acct) : undefined;
147855
+ const acct = choice3.backend?.kind === "in-loop" && choice3.backend.account || defaultAccount(choice3.model.provider);
147856
+ const creds = acct ? await resolveCreds(acct) : undefined;
146994
147857
  usedAccountRef.current = acct?.id ?? null;
146995
147858
  cliMetaRef.current = null;
146996
147859
  if (acct)
146997
147860
  markUsed(acct.id);
146998
- const r3 = await runCompletion({ model: choice3.model, system: buildAskSystem(docs), prompt, onEvent, signal, creds: creds2 });
146999
- return { messages, usage: r3.usage };
147861
+ const r2 = await runCompletion({ model: choice3.model, system: buildAskSystem(docs), prompt, onEvent, signal, creds });
147862
+ return { messages, usage: r2.usage };
147000
147863
  }
147001
- const cli = activeCliRef.current;
147002
- if (cli) {
147003
- if (activeImagesRef.current.length) {
147004
- onEvent({
147005
- type: "error",
147006
- 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."
147007
- });
147008
- return { messages, usage: { inputTokens: 0, outputTokens: 0 } };
147009
- }
147864
+ const imagesPresent = activeImagesRef.current.length > 0;
147865
+ const cliImageGuard = () => {
147866
+ onEvent({
147867
+ type: "error",
147868
+ 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."
147869
+ });
147870
+ return { messages, usage: { inputTokens: 0, outputTokens: 0 } };
147871
+ };
147872
+ const pin = activeCliRef.current;
147873
+ if (pin) {
147874
+ if (imagesPresent)
147875
+ return cliImageGuard();
147010
147876
  routedRef.current = null;
147011
- usedAccountRef.current = cli.id;
147012
- const modelLabel2 = cliModelLabel(activeCliModelRef.current);
147013
- onEvent({ type: "phase", label: "using subscription", detail: `${cli.binary}${modelLabel2 ? ` · ${modelLabel2}` : ""} owns tools and permissions`, state: "running" });
147014
- const cliChoices = cliModelChoices(cli.binary);
147015
- const cliChoice = cliChoices.find((m2) => m2.id === activeCliModelRef.current) ?? cliChoices[0];
147016
- const _cliEffortRaw = cliChoice ? normalizeEffort(effortRef.current, cliChoice.efforts ?? []) : null;
147017
- if (_cliEffortRaw === null && effortRef.current !== "medium") {
147018
- const supported = cliChoice?.efforts ?? [];
147019
- const { level: nearest } = clampEffort(effortRef.current, supported);
147020
- const hint = supported.length ? ` — try /effort ${nearest}` : "";
147021
- throw new Error(`effort "${effortRef.current}" is not supported by ${cliChoice?.label ?? cli.binary} (supports: ${supported.join(", ") || "none"}${hint})`);
147022
- }
147023
- const cliEffort = _cliEffortRaw ?? undefined;
147024
- const activeAccount = getAccount(cli.id);
147025
- const activeName = activeAccount ? accountName(activeAccount).match(/\((.*)\)/)?.[1] : undefined;
147026
- const reloginCommand = cli.binary.includes("codex") ? `/account add codex${activeName ? ` ${activeName}` : ""}` : `/account add claude${activeName ? ` ${activeName}` : ""}`;
147027
- let cliPrompt = prompt;
147028
- if (!cliSessionRef.current) {
147029
- try {
147030
- const cwd2 = process.cwd();
147031
- const allFiles = listProjectFiles(cwd2).slice(0, 300);
147032
- const map4 = repoMap(cwd2, 3000);
147033
- const fileList = allFiles.join(`
147034
- `);
147035
- cliPrompt = `<project-context cwd="${cwd2}">
147036
- ` + `<files>
147037
- ${fileList}
147038
- </files>
147039
- ` + (map4 ? `<signatures>
147040
- ${map4}
147041
- </signatures>
147042
- ` : "") + `</project-context>
147043
-
147044
- ` + prompt;
147045
- } catch {}
147046
- }
147047
- const r3 = await runCliTask({
147048
- binary: cli.binary,
147049
- prompt: cliPrompt,
147877
+ cliMetaRef.current = null;
147878
+ const choices = cliModelChoices(pin.binary);
147879
+ const cliChoice = choices.find((m2) => m2.id === activeCliModelRef.current) ?? choices[0];
147880
+ return runCliBackend({
147881
+ binary: pin.binary,
147882
+ profile: pin.profile,
147883
+ modelId: activeCliModelRef.current,
147884
+ accountId: pin.id,
147885
+ efforts: cliChoice?.efforts ?? [],
147886
+ label: cliModelLabel(activeCliModelRef.current) || undefined,
147887
+ pinned: true,
147888
+ prompt,
147050
147889
  messages,
147051
147890
  onEvent,
147052
- signal,
147053
- sessionId: cliSessionRef.current,
147054
- autoApprove: isYolo(),
147055
- profile: cli.profile,
147056
- modelId: activeCliModelRef.current,
147057
- effort: cliEffort,
147058
- accountLabel: activeAccount ? accountLabel(activeAccount) : cli.id,
147059
- reloginCommand
147891
+ signal
147060
147892
  });
147061
- cliSessionRef.current = r3.sessionId ?? cliSessionRef.current;
147062
- cliMetaRef.current = { costUSD: r3.costUSD, rates: r3.rates };
147063
- return { messages: r3.messages, usage: r3.usage };
147064
147893
  }
147065
147894
  const plan = modeRef.current === "plan";
147066
- const requires = ["tools", ...activeImagesRef.current.length ? ["images"] : []];
147067
- const choice2 = sel.select({ prompt, kind: plan ? "plan" : undefined, requires });
147068
- const missing = missingRequirements(choice2.model, requires);
147069
- if (missing.length) {
147070
- throw new Error(`${choice2.model.label} cannot run this turn (${missing.join(", ")} unsupported). Use /model auto or pick a compatible model.`);
147071
- }
147072
- routedRef.current = { model: choice2.model, reason: choice2.reason };
147073
- setLastPick({ model: choice2.model, reason: choice2.reason });
147074
- onEvent({ type: "model-pick", model: choice2.model.label, provider: choice2.model.provider, reason: choice2.reason });
147075
- onEvent({ type: "phase", label: "building context", detail: choice2.model.label, state: "running" });
147076
- const userContent = imageContent(prompt, activeImagesRef.current);
147077
- const { system, messages: ctx } = buildContext({ history: messages, userText: prompt, userContent, model: choice2.model, plan });
147078
- const account = accountResolver.pick(choice2.model.provider);
147079
- const creds = account ? await resolveCreds(account) : undefined;
147080
- usedAccountRef.current = account?.id ?? null;
147081
- cliMetaRef.current = null;
147082
- if (account)
147083
- markUsed(account.id);
147084
- const _effortRaw = normalizeEffort(effortRef.current, effortLevels(choice2.model));
147085
- if (_effortRaw === null && effortRef.current !== "medium") {
147086
- const supported = effortLevels(choice2.model);
147087
- const { level: nearest } = clampEffort(effortRef.current, supported);
147088
- const hint = supported.length ? ` — try /effort ${nearest}` : "";
147089
- throw new Error(`effort "${effortRef.current}" is not supported by ${choice2.model.label} (supports: ${supported.join(", ") || "none"}${hint})`);
147090
- }
147091
- const modelEffort = _effortRaw ?? undefined;
147092
- const r2 = await runTask({ model: choice2.model, messages: ctx, onEvent, signal, plan, system, creds, effort: modelEffort });
147093
- const produced = r2.messages.slice(ctx.length);
147094
- const imageNote = activeImagesRef.current.length ? `
147895
+ const requires = ["tools", ...imagesPresent ? ["images"] : []];
147896
+ const emitTerminal = (errored, message, usage) => {
147897
+ if (errored && message)
147898
+ onEvent({ type: "error", message });
147899
+ onEvent({ type: "phase", label: errored ? "blocked" : "finished", state: errored ? "err" : "ok" });
147900
+ onEvent({ type: "done", usage });
147901
+ };
147902
+ const runAttempt = async (choice3) => {
147903
+ if (choice3.backend?.kind === "cli") {
147904
+ const acct = choice3.backend.account;
147905
+ if (imagesPresent)
147906
+ return { ...cliImageGuard(), failure: { message: "image attachments need an API-backed model" }, cooldownKey: acct.id };
147907
+ routedRef.current = { model: choice3.model, reason: choice3.reason };
147908
+ setLastPick({ model: choice3.model, reason: choice3.reason });
147909
+ onEvent({ type: "model-pick", model: choice3.model.label, provider: choice3.model.provider, reason: choice3.reason });
147910
+ const out = await runCliBackend({
147911
+ binary: choice3.backend.binary,
147912
+ profile: choice3.backend.profile,
147913
+ modelId: choice3.model.sdkId,
147914
+ accountId: acct.id,
147915
+ efforts: choice3.model.efforts ?? [],
147916
+ label: choice3.model.label,
147917
+ pinned: false,
147918
+ deferTerminal: true,
147919
+ prompt,
147920
+ messages,
147921
+ onEvent,
147922
+ signal
147923
+ });
147924
+ return { messages: out.messages, usage: out.usage, failure: out.failure, cooldownKey: acct.id };
147925
+ }
147926
+ const missing = missingRequirements(choice3.model, requires);
147927
+ if (missing.length)
147928
+ throw new Error(`${choice3.model.label} cannot run this turn (${missing.join(", ")} unsupported). Use /model auto or pick a compatible model.`);
147929
+ routedRef.current = { model: choice3.model, reason: choice3.reason };
147930
+ setLastPick({ model: choice3.model, reason: choice3.reason });
147931
+ onEvent({ type: "model-pick", model: choice3.model.label, provider: choice3.model.provider, reason: choice3.reason });
147932
+ onEvent({ type: "phase", label: "building context", detail: choice3.model.label, state: "running" });
147933
+ const userContent = imageContent(prompt, activeImagesRef.current);
147934
+ const { system, messages: ctx } = buildContext({ history: messages, userText: prompt, userContent, model: choice3.model, plan });
147935
+ const account = choice3.backend?.kind === "in-loop" && choice3.backend.account || defaultAccount(choice3.model.provider);
147936
+ const creds = account ? await resolveCreds(account) : undefined;
147937
+ usedAccountRef.current = account?.id ?? null;
147938
+ cliMetaRef.current = null;
147939
+ if (account)
147940
+ markUsed(account.id);
147941
+ const _effortRaw = normalizeEffort(effortRef.current, effortLevels(choice3.model));
147942
+ if (_effortRaw === null && effortRef.current !== "medium") {
147943
+ const supported = effortLevels(choice3.model);
147944
+ const { level: nearest } = clampEffort(effortRef.current, supported);
147945
+ const hint = supported.length ? ` — try /effort ${nearest}` : "";
147946
+ throw new Error(`effort "${effortRef.current}" is not supported by ${choice3.model.label} (supports: ${supported.join(", ") || "none"}${hint})`);
147947
+ }
147948
+ const r2 = await runTask({ model: choice3.model, messages: ctx, onEvent, signal, plan, system, creds, effort: _effortRaw ?? undefined, deferTerminal: true });
147949
+ if (account && r2.headers) {
147950
+ const apiRates = parseRateHeaders(account.provider, r2.headers, Date.now());
147951
+ if (apiRates.length)
147952
+ cliMetaRef.current = { costUSD: undefined, rates: apiRates };
147953
+ }
147954
+ const produced = r2.messages.slice(ctx.length);
147955
+ const imageNote = activeImagesRef.current.length ? `
147095
147956
 
147096
147957
  [Attached images: ${activeImagesRef.current.map((img) => basename3(img.path)).join(", ")}]` : "";
147097
- const ledger = sanitizeToolPairs([...messages, { role: "user", content: prompt + imageNote }, ...produced]);
147098
- return { messages: ledger, usage: r2.usage };
147958
+ const ledger = sanitizeToolPairs([...messages, { role: "user", content: prompt + imageNote }, ...produced]);
147959
+ return { messages: ledger, usage: r2.usage, failure: r2.failure, cooldownKey: account?.id ?? `env:${choice3.model.provider}` };
147960
+ };
147961
+ const MAX_FAILOVERS = 2;
147962
+ let choice2 = sel.select({ prompt, kind: plan ? "plan" : undefined, requires });
147963
+ for (let hop = 0;; hop++) {
147964
+ const a = await runAttempt(choice2);
147965
+ if (!a.failure) {
147966
+ emitTerminal(false, undefined, a.usage);
147967
+ return { messages: a.messages, usage: a.usage };
147968
+ }
147969
+ const exhausted = classifyFailure(a.failure.message) === "exhausted";
147970
+ if (!exhausted || hop >= MAX_FAILOVERS) {
147971
+ emitTerminal(true, a.failure.message, a.usage);
147972
+ return { messages: a.messages, usage: a.usage };
147973
+ }
147974
+ markExhausted(a.cooldownKey, DEFAULT_COOLDOWN_MS, a.failure.message);
147975
+ let next = null;
147976
+ try {
147977
+ next = sel.select({ prompt, kind: plan ? "plan" : undefined, requires });
147978
+ } catch {
147979
+ next = null;
147980
+ }
147981
+ 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;
147982
+ if (!next || nextKey === a.cooldownKey) {
147983
+ emitTerminal(true, a.failure.message, a.usage);
147984
+ return { messages: a.messages, usage: a.usage };
147985
+ }
147986
+ onEvent({ type: "phase", label: "failover", detail: `${choice2.model.label} ${shortFailure(a.failure.message)} → ${next.model.label}, continuing`, state: "running" });
147987
+ choice2 = next;
147988
+ }
147099
147989
  }, []);
147100
147990
  const compactNow = import_react26.useCallback(async (keepRecent, signal) => {
147101
147991
  let model2;
@@ -147566,17 +148456,39 @@ ${fetched.join(`
147566
148456
  } else {
147567
148457
  accountStatusCacheRef.current[acctId] = { signedIn: true, detail: st.detail };
147568
148458
  }
147569
- activeCliRef.current = { id: acctId, binary: bin, profile };
147570
148459
  cliSessionRef.current = undefined;
147571
148460
  setLastPick(null);
147572
- setActiveCli({ id: acctId, label: shortLabel });
147573
- updatePrefs({ activeAccount: acctId });
147574
- updatePhase(phaseId, "ok", `${accountLabel(res.account)} active`, `using ${bin}${st.detail ? `; ${st.detail}` : ""}. Own tools/permissions; /account off returns to API routing`);
148461
+ 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);
148462
+ if (otherUsable) {
148463
+ activeCliRef.current = null;
148464
+ setActiveCli(null);
148465
+ updatePrefs({ activeAccount: null });
148466
+ 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.`);
148467
+ } else {
148468
+ activeCliRef.current = { id: acctId, binary: bin, profile };
148469
+ setActiveCli({ id: acctId, label: shortLabel });
148470
+ updatePrefs({ activeAccount: acctId });
148471
+ updatePhase(phaseId, "ok", `${accountLabel(res.account)} active`, `using ${bin}${st.detail ? `; ${st.detail}` : ""}. Own tools/permissions; /account off returns to API routing`);
148472
+ }
147575
148473
  } catch (e2) {
147576
148474
  updatePhase(phaseId, "err", `${accountLabel(res.account)} sign-in`, e2?.message ?? String(e2));
147577
148475
  }
147578
148476
  })();
147579
148477
  };
148478
+ const reloginByRef = (arg2) => {
148479
+ const ref = findAccountRef(arg2, listAccounts());
148480
+ const a = ref.account ?? (activeCliRef.current ? getAccount(activeCliRef.current.id) : undefined);
148481
+ if (a && a.exec === "cli") {
148482
+ const nick = accountName(a).match(/\((.*)\)/)?.[1];
148483
+ signInCli(`${a.provider.replace(/-cli$/, "")}${nick ? ` ${nick}` : ""}`.trim());
148484
+ return;
148485
+ }
148486
+ if (a && a.exec !== "cli") {
148487
+ 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.`);
148488
+ return;
148489
+ }
148490
+ signInCli(arg2);
148491
+ };
147580
148492
  try {
147581
148493
  switch (name31) {
147582
148494
  case "exit":
@@ -147755,7 +148667,7 @@ ${fetched.join(`
147755
148667
  return;
147756
148668
  }
147757
148669
  case "model":
147758
- if ((!arg || arg.toLowerCase() === "all") && !activeCliRef.current && fullscreen) {
148670
+ if ((!arg || arg.toLowerCase() === "all") && !activeCliRef.current && fullscreen && buildPanelModelRows().length > 0) {
147759
148671
  setPanel({ kind: "models", title: "models · ⏎ to pin", index: 0, filter: "" });
147760
148672
  return;
147761
148673
  }
@@ -147864,6 +148776,33 @@ ${fetched.join(`
147864
148776
  notice(`remembered: prefer ${pref.modelId} for ${pref.kind} tasks`);
147865
148777
  return;
147866
148778
  }
148779
+ case "budget": {
148780
+ echo(text2);
148781
+ const parts = arg.split(/\s+/).filter(Boolean);
148782
+ if (parts.length === 0) {
148783
+ const b = loadBudgets();
148784
+ const keys2 = Object.keys(b);
148785
+ notice(keys2.length ? `budgets (estimates remaining = budget − tracked spend):
148786
+ ` + keys2.map((k) => ` ${k}: $${b[k].amountUSD} ${b[k].period}`).join(`
148787
+ `) : "no budgets set. /budget <provider|account> <amount> [monthly|total] — lets routing estimate remaining credit for providers that don't expose a balance");
148788
+ return;
148789
+ }
148790
+ const [target, amountRaw, periodRaw] = parts;
148791
+ if (amountRaw && /^off$/i.test(amountRaw)) {
148792
+ setBudget(target, null);
148793
+ notice(`cleared budget for ${target}`);
148794
+ return;
148795
+ }
148796
+ const amount = Number(amountRaw);
148797
+ if (!target || !amountRaw || !Number.isFinite(amount) || amount <= 0) {
148798
+ notice("usage: /budget <provider|account> <amountUSD> [monthly|total] · /budget <target> off");
148799
+ return;
148800
+ }
148801
+ const period = periodRaw && /^total$/i.test(periodRaw) ? "total" : "monthly";
148802
+ setBudget(target, { amountUSD: amount, period });
148803
+ notice(`budget set: ${target} → $${amount} ${period}. Routing will preserve it as it runs low (estimated from your spend).`);
148804
+ return;
148805
+ }
147867
148806
  case "memory": {
147868
148807
  if (arg) {
147869
148808
  echo(text2);
@@ -147902,6 +148841,21 @@ ${fetched.join(`
147902
148841
  push(it);
147903
148842
  return;
147904
148843
  }
148844
+ case "why": {
148845
+ echo(text2);
148846
+ const sel = selectorRef.current;
148847
+ if (!sel.explain) {
148848
+ notice("routing is off — a model or subscription is pinned. Use /model auto to route per task, then /why.");
148849
+ return;
148850
+ }
148851
+ try {
148852
+ const card = sel.explain({ prompt: lastPromptRef.current || "(your next message)", kind: modeRef.current === "plan" ? "plan" : undefined });
148853
+ push({ kind: "scorecard", id: idRef.current++, card });
148854
+ } catch (e2) {
148855
+ notice(e2?.message ?? "couldn't build the scorecard");
148856
+ }
148857
+ return;
148858
+ }
147905
148859
  case "onboard": {
147906
148860
  echo(text2);
147907
148861
  if (arg.trim().toLowerCase() === "providers") {
@@ -148002,17 +148956,20 @@ Example: /mcp add github npx -y @modelcontextprotocol/server-github`);
148002
148956
  try {
148003
148957
  const fresh = listAccounts();
148004
148958
  const statuses = await checkCliAccounts(fresh);
148005
- pushAccounts(buildAccountView(fresh, activeCliRef.current?.id ?? null, importableEnvCreds(), statuses));
148959
+ await Promise.all(fresh.filter((a) => a.exec !== "cli" && !isFresh(a.health, Date.now())).map(async (a) => {
148960
+ try {
148961
+ const h2 = await checkHealth(a);
148962
+ recordHealth(a, h2.state, h2.detail);
148963
+ } catch {}
148964
+ }));
148965
+ const withHealth = listAccounts();
148966
+ pushAccounts(buildAccountView(withHealth, activeCliRef.current?.id ?? null, importableEnvCreds(), statuses));
148006
148967
  } catch (e2) {
148007
148968
  notice(`couldn't check subscription accounts — ${e2?.message ?? String(e2)}`);
148008
148969
  pushAccounts(buildAccountView(listAccounts(), activeCliRef.current?.id ?? null, importableEnvCreds(), accountStatusCacheRef.current));
148009
148970
  }
148010
148971
  })();
148011
148972
  };
148012
- const byNumber = (s2) => {
148013
- const n = Number(s2);
148014
- return Number.isInteger(n) && n >= 1 && n <= all.length ? all[n - 1] : undefined;
148015
- };
148016
148973
  const activate = (a) => {
148017
148974
  if (a.exec === "cli") {
148018
148975
  const bin = a.auth.binary;
@@ -148065,18 +149022,11 @@ Example: /mcp add github npx -y @modelcontextprotocol/server-github`);
148065
149022
  notice("left the subscription — back to your API keys");
148066
149023
  return;
148067
149024
  }
148068
- const numbered = byNumber(subL);
148069
- if (numbered) {
148070
- activate(numbered);
148071
- return;
148072
- }
148073
- if (/^\d+$/.test(subL)) {
148074
- notice(all.length ? `there's no account ${subL} — pick 1–${all.length}.
148075
-
148076
- ` + formatAccounts(all, activeId, []) : "no accounts yet — /account add to add one");
149025
+ if (subL === "login") {
149026
+ reloginByRef(parts.slice(1).join(" "));
148077
149027
  return;
148078
149028
  }
148079
- if (!["add", "remove", "rm", "import", "off", "refresh"].includes(subL)) {
149029
+ if (!["add", "remove", "rm", "import", "off", "login", "refresh"].includes(subL)) {
148080
149030
  const ref = findAccountRef(arg, all);
148081
149031
  if (ref.account) {
148082
149032
  activate(ref.account);
@@ -148122,14 +149072,12 @@ Example: /mcp add github npx -y @modelcontextprotocol/server-github`);
148122
149072
  res = await addOpenAICompatAccount(parts[2] ?? "", parts[3] ?? "", parts[4] ?? "", parts.slice(5));
148123
149073
  } else if (catalogProvider(first)?.authKind === "openai-compat" && !catalogProvider(first)?.baseUrl && /^https?:\/\//i.test(parts[2] ?? "")) {
148124
149074
  res = await addOpenAICompatAccount(first, parts[2] ?? "", parts[3] ?? "", parts.slice(4));
149075
+ } else if (["bedrock", "aws"].includes(first)) {
149076
+ res = await addBedrockAccount(parts[2] ?? "", parts[3] ?? "", parts[4] ?? "");
148125
149077
  } else if (provGiven)
148126
149078
  res = await addApiKeyAccount(provGiven, keyVal);
148127
- else if (detectProviderByKey(key))
149079
+ else
148128
149080
  res = await addByPastedKey(key);
148129
- else {
148130
- notice(`"${key}" isn't a recognized key. Try /account add claude, /account add codex, or paste a full API key.`);
148131
- return;
148132
- }
148133
149081
  if (!res.ok || !res.account) {
148134
149082
  notice(res.message);
148135
149083
  return;
@@ -148210,7 +149158,7 @@ Example: /mcp add github npx -y @modelcontextprotocol/server-github`);
148210
149158
  }
148211
149159
  case "login": {
148212
149160
  echo(text2);
148213
- signInCli(arg);
149161
+ reloginByRef(arg);
148214
149162
  return;
148215
149163
  }
148216
149164
  case "cost":
@@ -148504,23 +149452,17 @@ Example: /mcp add github npx -y @modelcontextprotocol/server-github`);
148504
149452
  return;
148505
149453
  }
148506
149454
  if (p.kind === "accounts") {
148507
- const nums = panelAccountNumbersRef.current;
148508
- const n = nums.length;
149455
+ const slugs = panelAccountSlugsRef.current;
149456
+ const n = slugs.length;
148509
149457
  if (key.upArrow)
148510
149458
  setPanel({ ...p, index: clampIndex(p.index - 1, n) });
148511
149459
  else if (key.downArrow)
148512
149460
  setPanel({ ...p, index: clampIndex(p.index + 1, n) });
148513
149461
  else if (key.return) {
148514
- const num = nums[clampIndex(p.index, n)];
149462
+ const slug3 = slugs[clampIndex(p.index, n)];
148515
149463
  setPanel(null);
148516
- if (num)
148517
- handleCommand(`/account ${num}`);
148518
- } else if (/^[1-9]$/.test(input)) {
148519
- const k = Number(input);
148520
- if (k <= n) {
148521
- setPanel(null);
148522
- handleCommand(`/account ${k}`);
148523
- }
149464
+ if (slug3)
149465
+ handleCommand(`/account ${slug3}`);
148524
149466
  }
148525
149467
  return;
148526
149468
  }
@@ -148806,7 +149748,7 @@ Example: /mcp add github npx -y @modelcontextprotocol/server-github`);
148806
149748
  panelMaxScrollRef.current = Math.max(0, panelStaticLines.length - panelBodyHeight(transcriptHeight));
148807
149749
  } else if (panel?.kind === "accounts") {
148808
149750
  panelAccountView = buildAccountView(listAccounts(), activeCliRef.current?.id ?? null, importableEnvCreds(), accountStatusCacheRef.current);
148809
- panelAccountNumbersRef.current = panelAccountView.rows.map((r2) => r2.number);
149751
+ panelAccountSlugsRef.current = panelAccountView.rows.map((r2) => r2.alias);
148810
149752
  } else if (panel?.kind === "models") {
148811
149753
  panelModels = buildPanelModelRows(panelCurrentModel);
148812
149754
  }
@@ -149262,7 +150204,7 @@ async function runCliOnboarding() {
149262
150204
  const { importableEnvCreds: importableEnvCreds2, importEnvCred: importEnvCred2, importableCloudCreds: importableCloudCreds2, importCloudCred: importCloudCred2 } = await Promise.resolve().then(() => (init_detect(), exports_detect));
149263
150205
  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));
149264
150206
  const { subscriptionEnv: subscriptionEnv2 } = await Promise.resolve().then(() => (init_cli_backend(), exports_cli_backend));
149265
- const { detectProviderByKey: detectProviderByKey2 } = await Promise.resolve().then(() => (init_catalog(), exports_catalog));
150207
+ const { detectProviderByKey: detectProviderByKey3 } = await Promise.resolve().then(() => (init_catalog(), exports_catalog));
149266
150208
  const { which: which2 } = await Promise.resolve().then(() => (init_proc(), exports_proc));
149267
150209
  const pipedAnswers = process.stdin.isTTY ? null : (await readStdin()).split(/\r?\n/);
149268
150210
  const rl = pipedAnswers ? null : createInterface({ input: process.stdin, output: process.stdout });
@@ -149355,7 +150297,7 @@ async function runCliOnboarding() {
149355
150297
  const key = await ask("Paste API key: ");
149356
150298
  if (!key)
149357
150299
  continue;
149358
- const detected = detectProviderByKey2(key);
150300
+ const detected = detectProviderByKey3(key);
149359
150301
  if (!detected) {
149360
150302
  console.log(warn("Could not detect the provider from that key. Use option 3."));
149361
150303
  continue;
@@ -149531,7 +150473,7 @@ if (args[0] === "auth") {
149531
150473
  const { importableEnvCreds: importableEnvCreds2, importEnvCred: importEnvCred2, importableCloudCreds: importableCloudCreds2, importCloudCred: importCloudCred2 } = await Promise.resolve().then(() => (init_detect(), exports_detect));
149532
150474
  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));
149533
150475
  const { subscriptionEnv: subscriptionEnv2 } = await Promise.resolve().then(() => (init_cli_backend(), exports_cli_backend));
149534
- const { detectProviderByKey: detectProviderByKey2 } = await Promise.resolve().then(() => (init_catalog(), exports_catalog));
150476
+ const { detectProviderByKey: detectProviderByKey3 } = await Promise.resolve().then(() => (init_catalog(), exports_catalog));
149535
150477
  const sub = args[1];
149536
150478
  const rest2 = args.slice(2);
149537
150479
  if (sub === "list" || !sub) {
@@ -149556,7 +150498,7 @@ Importable from your env (gearbox auth import): ${imp.map((c) => c.envVar).join(
149556
150498
  } else if (sub === "add") {
149557
150499
  const head2 = (rest2[0] ?? "").toLowerCase();
149558
150500
  const cliProvider = head2 === "codex" || head2 === "chatgpt" ? "codex-cli" : head2 === "claude" ? "claude-cli" : "";
149559
- 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]" };
150501
+ 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]" };
149560
150502
  console.log(res.message);
149561
150503
  if (res.ok && res.account) {
149562
150504
  if (res.account.exec === "cli" && res.account.auth.kind === "cli") {