@mrciphersmith/keryx 0.3.128 → 0.3.130

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.
package/dist/cli.js CHANGED
@@ -23370,7 +23370,7 @@ var package_default;
23370
23370
  var init_package = __esm(() => {
23371
23371
  package_default = {
23372
23372
  name: "@mrciphersmith/keryx",
23373
- version: "0.3.128",
23373
+ version: "0.3.130",
23374
23374
  description: "Version-controlled project context for AI coding agents: code graph, architecture wiki, project memory, relevant tests, quality signals, and task flows.",
23375
23375
  private: false,
23376
23376
  publishConfig: {
@@ -160929,7 +160929,7 @@ var init_help_groups = __esm(() => {
160929
160929
  group: "Connect a model provider",
160930
160930
  summary: "Category -> model routing table (list, set, unset, trust), the model-profile catalogue (profile list, profile set), and real measured task cost (stats)."
160931
160931
  },
160932
- { kind: "slash", name: "/connect", group: "Connect a model provider", summary: "Switch provider; row buttons test/disconnect it." },
160932
+ { kind: "slash", name: "/connect", group: "Connect a model provider", summary: "Providers dialog: switch, test or disconnect a connected provider, or add one (Connected / Add provider tabs)." },
160933
160933
  {
160934
160934
  kind: "slash",
160935
160935
  name: "/routing",
@@ -160948,12 +160948,6 @@ var init_help_groups = __esm(() => {
160948
160948
  group: "Connect a model provider",
160949
160949
  summary: "Select a connected web search provider."
160950
160950
  },
160951
- {
160952
- kind: "slash",
160953
- name: "/provider",
160954
- group: "Connect a model provider",
160955
- summary: "Switch provider \u2014 /provider <name>, or no arg to re-select."
160956
- },
160957
160951
  {
160958
160952
  kind: "cli",
160959
160953
  name: "external",
@@ -163885,6 +163879,9 @@ function mountTab(state, input, tabId) {
163885
163879
  unmountActiveTab(state);
163886
163880
  state.active = tabId;
163887
163881
  paintTabs(state);
163882
+ if (input.footerFor !== undefined) {
163883
+ paintFooter(state, input.footerFor(tabId) ?? input.footer);
163884
+ }
163888
163885
  const availWidth = resolveModalAvailableWidth(state.chrome);
163889
163886
  const size = resolveModalPanelSize(availWidth, state.chrome.renderer.height, input.contentRows);
163890
163887
  const panelWidth = Math.min(availWidth, size.width);
@@ -166962,7 +166959,7 @@ var init_command_gateway = __esm(() => {
166962
166959
  { name: "status", kind: "text", description: "Show the session: model, mode, queue, usage" },
166963
166960
  { name: "doctor", kind: "text", description: "Run the health checks" },
166964
166961
  { name: "model", kind: "picker", description: "Pick another model with buttons" },
166965
- { name: "connect", kind: "picker", description: "Switch to a connected provider with buttons" },
166962
+ { name: "connect", kind: "picker", description: "Switch to a connected provider with buttons; also add a provider (device login only) and test it" },
166966
166963
  { name: "resume", kind: "picker", description: "Return to an earlier session with buttons" },
166967
166964
  { name: "sessions", kind: "builtin", description: "List recent sessions" },
166968
166965
  { name: "history", kind: "builtin", description: "Post the last messages of this session here: /history [N], 1 to 20" },
@@ -166993,7 +166990,6 @@ var init_command_gateway = __esm(() => {
166993
166990
  exit: "it would close the shell",
166994
166991
  quit: "it would close the shell",
166995
166992
  channels: "it manages the Telegram connection itself",
166996
- provider: "it asks for provider keys and addresses, which are never typed into a chat",
166997
166993
  "search-provider": "it asks for provider keys, which are never typed into a chat",
166998
166994
  "search-connect": "it changes where searches are sent",
166999
166995
  "remote-control": "turning remote control off would archive this topic, and its panel is local",
@@ -167472,11 +167468,18 @@ Now: ${current.label}` : ""}`;
167472
167468
  return "failed";
167473
167469
  }
167474
167470
  const providers = await host.listProviders();
167471
+ const canAdd = host.listAddableProviders !== undefined;
167475
167472
  if (providers.length === 0) {
167473
+ if (canAdd) {
167474
+ return await this.pickAddableProvider(fromId);
167475
+ }
167476
167476
  await this.ctx.reply("No provider is connected yet. Connecting one needs a key, which is never typed into a chat; use the shell.").catch(() => false);
167477
167477
  return "failed";
167478
167478
  }
167479
167479
  const options = providers.map((provider) => ({ id: provider.id, label: provider.current === true ? `* ${provider.label}` : provider.label }));
167480
+ if (canAdd) {
167481
+ options.push({ id: ADD_PROVIDER_ID, label: "Add provider" });
167482
+ }
167480
167483
  const chosen = await this.pick("connect", "Connected providers. Pick one to switch to:", options, fromId);
167481
167484
  if (chosen === "refused") {
167482
167485
  return "failed";
@@ -167485,8 +167488,92 @@ Now: ${current.label}` : ""}`;
167485
167488
  this.ctx.record("/connect not answered in time; nothing changed");
167486
167489
  return "failed";
167487
167490
  }
167491
+ if (chosen.id === ADD_PROVIDER_ID) {
167492
+ return await this.pickAddableProvider(fromId);
167493
+ }
167488
167494
  return await this.pickModel(chosen.id, fromId);
167489
167495
  }
167496
+ async pickAddableProvider(fromId) {
167497
+ const host = this.ctx.host;
167498
+ if (host.listAddableProviders === undefined) {
167499
+ await this.ctx.reply("/connect is not available from this shell.").catch(() => false);
167500
+ return "failed";
167501
+ }
167502
+ const addable = await host.listAddableProviders();
167503
+ if (addable.length === 0) {
167504
+ await this.ctx.reply("There are no providers to add from this shell.").catch(() => false);
167505
+ return "failed";
167506
+ }
167507
+ const options = addable.map((provider) => ({ id: provider.id, label: provider.connected ? `${provider.label} (connected)` : provider.label }));
167508
+ const chosen = await this.pick("connect", "Add provider. Pick one to test or connect:", options, fromId);
167509
+ if (chosen === "refused") {
167510
+ return "failed";
167511
+ }
167512
+ if (chosen === undefined) {
167513
+ this.ctx.record("/connect not answered in time; nothing changed");
167514
+ return "failed";
167515
+ }
167516
+ const provider = addable.find((entry) => entry.id === chosen.id);
167517
+ if (provider === undefined) {
167518
+ return "failed";
167519
+ }
167520
+ const action = await this.ask("connect", this.ctx.compose(`${provider.label}: test the stored credential, or connect it?`), [["Test", "Connect"]], fromId);
167521
+ if (action === "refused") {
167522
+ return "failed";
167523
+ }
167524
+ if (action === undefined) {
167525
+ this.ctx.record("/connect not answered in time; nothing changed");
167526
+ return "failed";
167527
+ }
167528
+ return action === 0 ? await this.testAddable(provider) : await this.connectAddable(provider);
167529
+ }
167530
+ async testAddable(provider) {
167531
+ const host = this.ctx.host;
167532
+ if (host.testProvider === undefined) {
167533
+ await this.ctx.reply("Testing a provider is not available from this shell.").catch(() => false);
167534
+ return "failed";
167535
+ }
167536
+ const outcome = await host.testProvider(provider.id);
167537
+ this.ctx.record(`/connect test ${provider.id}: ${outcome.ok ? "ok" : "failed"}`);
167538
+ await this.ctx.reply(this.ctx.compose(`${provider.label}: ${outcome.message}`)).catch(() => false);
167539
+ return outcome.ok ? "done" : "failed";
167540
+ }
167541
+ async connectAddable(provider) {
167542
+ const host = this.ctx.host;
167543
+ if (provider.connected) {
167544
+ this.ctx.record(`/connect ${provider.id}: already connected`);
167545
+ await this.ctx.reply(this.ctx.compose(`${provider.label} is already connected. Use Test, or reconnect in the shell (/connect).`)).catch(() => false);
167546
+ return "failed";
167547
+ }
167548
+ if (!provider.deviceLogin) {
167549
+ this.ctx.record(`/connect ${provider.id}: needs a key, entered in the shell`);
167550
+ await this.ctx.reply(this.ctx.compose(`${provider.label} needs a key. The key is entered in the shell with /connect (Add provider tab), never in this chat.`)).catch(() => false);
167551
+ return "failed";
167552
+ }
167553
+ if (host.connectWithDeviceLogin === undefined) {
167554
+ await this.ctx.reply("Connecting a provider is not available from this shell.").catch(() => false);
167555
+ return "failed";
167556
+ }
167557
+ const busy = this.busyReason("/connect", "connect");
167558
+ if (busy !== undefined) {
167559
+ this.ctx.record(`/connect not run: ${busy}`);
167560
+ await this.ctx.reply(`/connect was not run: ${busy}.`).catch(() => false);
167561
+ return "failed";
167562
+ }
167563
+ this.ctx.record(`/connect ${provider.id}: device login started`);
167564
+ const notify = async (text) => {
167565
+ await this.ctx.reply(this.ctx.compose(text)).catch(() => false);
167566
+ };
167567
+ let outcome;
167568
+ try {
167569
+ outcome = await host.connectWithDeviceLogin(provider.id, notify);
167570
+ } catch {
167571
+ outcome = { ok: false, message: "Device login failed. The details are in the shell." };
167572
+ }
167573
+ this.ctx.record(`/connect ${provider.id}: device login ${outcome.ok ? "done" : "failed"}`);
167574
+ await this.ctx.reply(this.ctx.compose(`${provider.label}: ${outcome.message}`)).catch(() => false);
167575
+ return outcome.ok ? "done" : "failed";
167576
+ }
167490
167577
  async pickSession(fromId, end) {
167491
167578
  const host = this.ctx.host;
167492
167579
  if (host.listSessions === undefined || host.resumeSession === undefined) {
@@ -167535,7 +167622,7 @@ ${lines.join(`
167535
167622
  `)}`)).catch(() => false);
167536
167623
  }
167537
167624
  }
167538
- var DEFAULT_ROUTER_LIMITS, PICKER_PAGE_SIZE = 7, LABEL_CHARS = 56, SESSION_COMMANDS;
167625
+ var DEFAULT_ROUTER_LIMITS, PICKER_PAGE_SIZE = 7, LABEL_CHARS = 56, ADD_PROVIDER_ID = "::add-provider", SESSION_COMMANDS;
167539
167626
  var init_command_router = __esm(() => {
167540
167627
  init_command_gateway();
167541
167628
  init_history2();
@@ -167916,6 +168003,9 @@ class RemoteBridge {
167916
168003
  ...this.host.busyRefusal !== undefined ? { busyRefusal: (line) => this.host.busyRefusal(line) } : {},
167917
168004
  ...this.host.listModels !== undefined ? { listModels: (id) => this.host.listModels(id) } : {},
167918
168005
  ...this.host.listProviders !== undefined ? { listProviders: () => this.host.listProviders() } : {},
168006
+ ...this.host.listAddableProviders !== undefined ? { listAddableProviders: () => this.host.listAddableProviders() } : {},
168007
+ ...this.host.testProvider !== undefined ? { testProvider: (id) => this.host.testProvider(id) } : {},
168008
+ ...this.host.connectWithDeviceLogin !== undefined ? { connectWithDeviceLogin: (id, notify) => this.host.connectWithDeviceLogin(id, notify) } : {},
167919
168009
  ...this.host.switchModel !== undefined ? { switchModel: (model, provider) => this.host.switchModel(model, provider) } : {},
167920
168010
  ...this.host.listSessions !== undefined ? { listSessions: () => this.host.listSessions() } : {},
167921
168011
  ...this.host.resumeSession !== undefined ? { resumeSession: (id) => this.host.resumeSession(id) } : {}
@@ -168800,6 +168890,9 @@ function stepConnectNavAction(selected, direction) {
168800
168890
  const nextIdx = direction === "left" ? idx > 0 ? idx - 1 : count - 1 : idx < count - 1 ? idx + 1 : 0;
168801
168891
  return CONNECT_NAV_ACTIONS[nextIdx];
168802
168892
  }
168893
+ function stepAddNavAction(selected, _direction) {
168894
+ return selected === "connect" ? "test" : "connect";
168895
+ }
168803
168896
  var QUEUE_NAV_ACTIONS, CONNECT_NAV_ACTIONS;
168804
168897
  var init_queue_nav = __esm(() => {
168805
168898
  QUEUE_NAV_ACTIONS = ["force", "edit", "delete"];
@@ -170449,6 +170542,9 @@ function describeUnavailableCommand(line, mode) {
170449
170542
  return `${command.name} is only available in ${where} \u2014 this is ${mode} mode. ` + `Type /help for the commands available here.
170450
170543
  `;
170451
170544
  }
170545
+ function removedCommandHint(line) {
170546
+ return REMOVED_COMMAND_HINTS[commandToken(line)];
170547
+ }
170452
170548
  function renderCommandHelp(mode, only, maxColumns) {
170453
170549
  const allow = only === undefined ? undefined : new Set(only);
170454
170550
  const options = commandsForMode(mode).filter((c) => allow === undefined || allow.has(c.name));
@@ -170478,7 +170574,7 @@ function wrapWords2(line, budget) {
170478
170574
  out.push(cur);
170479
170575
  return out;
170480
170576
  }
170481
- var CHAT_ONLY, AGENT_ONLY, BOTH, AGENT_SLASH_COMMANDS, DELEGATE_USAGE = "/delegate <agent> <task> (e.g. `/delegate claude <task>`; agents: claude, codex, agy, see `keryx agents external list`; not enabled yet? `/external-agents on`)", DEMOTE_USAGE = "/demote <task_id> (the task_id shell_exec returned)", MIN_HELP_WRAP_COLUMNS = 10;
170577
+ var CHAT_ONLY, AGENT_ONLY, BOTH, AGENT_SLASH_COMMANDS, DELEGATE_USAGE = "/delegate <agent> <task> (e.g. `/delegate claude <task>`; agents: claude, codex, agy, see `keryx agents external list`; not enabled yet? `/external-agents on`)", DEMOTE_USAGE = "/demote <task_id> (the task_id shell_exec returned)", REMOVED_COMMAND_HINTS, MIN_HELP_WRAP_COLUMNS = 10;
170482
170578
  var init_agent_commands = __esm(() => {
170483
170579
  init_registry2();
170484
170580
  CHAT_ONLY = ["chat"];
@@ -170502,11 +170598,11 @@ var init_agent_commands = __esm(() => {
170502
170598
  },
170503
170599
  {
170504
170600
  name: "/connect",
170505
- description: "Switch provider / API key",
170601
+ description: "Providers \u2014 switch, test or disconnect a connected one, or add a new one",
170506
170602
  modes: BOTH,
170507
170603
  modeDescriptions: {
170508
- agent: "Connect to an already configured provider (interactive picker)",
170509
- chat: "Switch to a connected provider (picker when available)"
170604
+ agent: "Providers dialog: Connected (switch, Test, Disconnect) and Add provider (key or login)",
170605
+ chat: "Providers picker: switch to a connected provider or add one (picker when available)"
170510
170606
  }
170511
170607
  },
170512
170608
  {
@@ -170519,14 +170615,6 @@ var init_agent_commands = __esm(() => {
170519
170615
  description: "Select a connected web search provider",
170520
170616
  modes: AGENT_ONLY
170521
170617
  },
170522
- {
170523
- name: "/provider",
170524
- description: "Switch provider \u2014 /provider <name>, or no arg to re-select",
170525
- modes: BOTH,
170526
- modeDescriptions: {
170527
- agent: "Add or configure a provider (interactive picker)"
170528
- }
170529
- },
170530
170618
  {
170531
170619
  name: "/think",
170532
170620
  description: "Expand the last reasoning block \u2014 /think [auto|expand|hide|collapse]",
@@ -170842,6 +170930,9 @@ var init_agent_commands = __esm(() => {
170842
170930
  modeDescriptions: { agent: "Leave agent mode (/quit works too)" }
170843
170931
  }
170844
170932
  ];
170933
+ REMOVED_COMMAND_HINTS = {
170934
+ "/provider": "/provider was merged into /connect \u2014 run /connect (Connected and Add provider tabs)."
170935
+ };
170845
170936
  });
170846
170937
 
170847
170938
  // src/tui/busy-dispatch.ts
@@ -182874,6 +182965,129 @@ var init_routing_inspector = __esm(() => {
182874
182965
  ];
182875
182966
  });
182876
182967
 
182968
+ // src/tui/remote-provider-hooks.ts
182969
+ function remoteFailureText(failure) {
182970
+ switch (failure.kind) {
182971
+ case "rejected":
182972
+ return `rejected the credential (HTTP ${Number.isInteger(failure.status) ? failure.status : "error"})`;
182973
+ case "http":
182974
+ return `could not list models (HTTP ${Number.isInteger(failure.status) ? failure.status : "error"})`;
182975
+ case "unreachable":
182976
+ return "could not be reached";
182977
+ case "empty":
182978
+ return "reports no models";
182979
+ }
182980
+ }
182981
+ async function testProviderForRemote(providerId, deps) {
182982
+ const outcome = await deps.probe(providerId);
182983
+ switch (outcome.kind) {
182984
+ case "unknown":
182985
+ return { ok: false, message: "unknown provider, cannot test" };
182986
+ case "no-credential":
182987
+ return { ok: false, message: NO_CREDENTIAL_TEXT };
182988
+ case "live":
182989
+ return { ok: true, message: `ok \u2014 ${outcome.modelCount} model(s)` };
182990
+ case "failed":
182991
+ return { ok: false, message: remoteFailureText(outcome.failure) };
182992
+ }
182993
+ }
182994
+ function createRemoteDeviceLogin(deps = {}) {
182995
+ const login = deps.login ?? loginDeviceCode;
182996
+ const applyEnv = deps.applyEnv ?? applyOAuthAccessToEnv;
182997
+ const allows = deps.allows ?? ((id) => catalogAllows(id, "device-code"));
182998
+ const timeoutMs = deps.timeoutMs ?? REMOTE_DEVICE_LOGIN_LIMIT_MS;
182999
+ let current;
183000
+ let stoppedByCaller = false;
183001
+ return {
183002
+ get running() {
183003
+ return current !== undefined;
183004
+ },
183005
+ abort() {
183006
+ if (current !== undefined) {
183007
+ stoppedByCaller = true;
183008
+ current.abort();
183009
+ }
183010
+ },
183011
+ async connect(providerId, notify) {
183012
+ if (!allows(providerId)) {
183013
+ return { ok: false, message: "this provider has no device login; its key is entered in the shell with /connect" };
183014
+ }
183015
+ if (current !== undefined) {
183016
+ return { ok: false, message: "a device login is already running" };
183017
+ }
183018
+ const controller = new AbortController;
183019
+ current = controller;
183020
+ stoppedByCaller = false;
183021
+ let timedOut = false;
183022
+ const timer = setTimeout(() => {
183023
+ timedOut = true;
183024
+ controller.abort();
183025
+ }, timeoutMs);
183026
+ const endedEarly = () => ({ ok: false, message: timedOut ? LOGIN_TIMED_OUT_TEXT : stoppedByCaller ? LOGIN_ABORTED_TEXT : LOGIN_FAILED_TEXT });
183027
+ try {
183028
+ const result = await login({
183029
+ provider: providerId,
183030
+ fetch: (input, init) => (deps.fetch ?? globalThis.fetch)(input, init),
183031
+ signal: controller.signal,
183032
+ ...deps.configDir !== undefined ? { dir: deps.configDir } : {},
183033
+ onChallenge: (challenge) => {
183034
+ notify(`Open ${challenge.verificationUri} and enter the code ${challenge.userCode}`).catch(() => {
183035
+ return;
183036
+ });
183037
+ }
183038
+ });
183039
+ if (!result.ok) {
183040
+ return endedEarly();
183041
+ }
183042
+ if (controller.signal.aborted) {
183043
+ return { ok: false, message: LOGIN_NOT_APPLIED_TEXT };
183044
+ }
183045
+ if (deps.isBusy?.() === true) {
183046
+ return { ok: false, message: "signed in, but main is busy, so the login was not applied to this session; run /connect in the shell when it is idle" };
183047
+ }
183048
+ applyEnv(deps.configDir);
183049
+ deps.onConnected?.(providerId);
183050
+ return { ok: true, message: "connected. The session and model are unchanged." };
183051
+ } catch {
183052
+ return endedEarly();
183053
+ } finally {
183054
+ clearTimeout(timer);
183055
+ current = undefined;
183056
+ }
183057
+ }
183058
+ };
183059
+ }
183060
+ function createConnectedProvidersCache(load, options = {}) {
183061
+ const ttlMs = options.ttlMs ?? 15000;
183062
+ const now = options.now ?? Date.now;
183063
+ let entry;
183064
+ return {
183065
+ get() {
183066
+ if (entry !== undefined && now() - entry.at < ttlMs) {
183067
+ return entry.value;
183068
+ }
183069
+ const value = load();
183070
+ const mine = { at: now(), value };
183071
+ entry = mine;
183072
+ value.catch(() => {
183073
+ if (entry === mine)
183074
+ entry = undefined;
183075
+ });
183076
+ return value;
183077
+ },
183078
+ invalidate() {
183079
+ entry = undefined;
183080
+ }
183081
+ };
183082
+ }
183083
+ var NO_CREDENTIAL_TEXT = "no credential, Connect first", REMOTE_DEVICE_LOGIN_LIMIT_MS, LOGIN_FAILED_TEXT = "Device login failed. Try /connect in the shell.", LOGIN_NOT_APPLIED_TEXT = "Signed in, but not applied to this session; run /connect again or restart.", LOGIN_TIMED_OUT_TEXT = "Device login timed out; nothing was saved.", LOGIN_ABORTED_TEXT = "Device login was stopped; nothing was saved.";
183084
+ var init_remote_provider_hooks = __esm(() => {
183085
+ init_catalog3();
183086
+ init_grants();
183087
+ init_login();
183088
+ REMOTE_DEVICE_LOGIN_LIMIT_MS = 10 * 60000;
183089
+ });
183090
+
182877
183091
  // src/tui/ci-triage-inspector.ts
182878
183092
  function isCiTriageCommand(line) {
182879
183093
  const token = line.trim().split(/\s+/)[0] ?? "";
@@ -190124,7 +190338,7 @@ async function runGameModelTurn(game, state, opts) {
190124
190338
  return { move: undefined, error: `model error: ${turn.error.message}`, stats };
190125
190339
  }
190126
190340
  if (!turn.credentialAvailable && opts.providerFactory === undefined) {
190127
- return { move: undefined, error: "no model credential \u2014 configure a provider first (/provider)", stats };
190341
+ return { move: undefined, error: "no model credential \u2014 configure a provider first (/connect)", stats };
190128
190342
  }
190129
190343
  return { move: game.parseMove(turn.text, state), error: undefined, stats };
190130
190344
  }
@@ -197075,7 +197289,7 @@ async function promptCustomProviderWizard(otui, target) {
197075
197289
  while (true) {
197076
197290
  const nameStep = await promptCustomFieldStep(otui, target, {
197077
197291
  title: "Provider name",
197078
- note: "unique id used in /provider (letters, digits, - _)"
197292
+ note: "unique id used in /connect (letters, digits, - _)"
197079
197293
  });
197080
197294
  if (nameStep.kind === "back") {
197081
197295
  return;
@@ -197656,6 +197870,146 @@ function openStepSurface(otui, target, opts) {
197656
197870
  }
197657
197871
  };
197658
197872
  }
197873
+ function tabDigit(key) {
197874
+ const digit = key.sequence.length === 1 ? key.sequence : key.name;
197875
+ return digit.length === 1 && digit >= "1" && digit <= "9" ? Number(digit) : undefined;
197876
+ }
197877
+ function openTabbedSurface(otui, target, opts) {
197878
+ const r = stepRenderer(target);
197879
+ if (isModalChrome(target)) {
197880
+ let closedByStep = false;
197881
+ const tabIds = opts.tabs.map((tab) => tab.id);
197882
+ const handle = openModal(otui, target, {
197883
+ title: opts.title,
197884
+ tabs: opts.tabs,
197885
+ initialTab: opts.initialTab,
197886
+ footer: opts.footerFor(opts.initialTab),
197887
+ footerFor: opts.footerFor,
197888
+ contentRows: opts.contentRows,
197889
+ renderTab: (tabId, tabBody, ctx) => {
197890
+ opts.renderTab(tabId, tabBody, ctx);
197891
+ },
197892
+ onArrowKeys: opts.onArrowKeys,
197893
+ claimKey: (key) => {
197894
+ if (key.name !== "tab" || handle === undefined)
197895
+ return false;
197896
+ const at = tabIds.indexOf(handle.activeTab());
197897
+ const next = tabIds[(at + 1) % tabIds.length];
197898
+ if (next !== undefined)
197899
+ handle.setTab(next);
197900
+ return true;
197901
+ },
197902
+ ownsKeys: opts.ownsKeys,
197903
+ onClose: () => {
197904
+ if (!closedByStep)
197905
+ opts.onEscape();
197906
+ }
197907
+ });
197908
+ if (handle !== undefined) {
197909
+ const opened = handle;
197910
+ return {
197911
+ modal: true,
197912
+ activeTab: () => opened.activeTab(),
197913
+ setTab: (id) => opened.setTab(id),
197914
+ close: () => {
197915
+ if (closedByStep)
197916
+ return;
197917
+ closedByStep = true;
197918
+ opened.close({ restoreFocus: false });
197919
+ }
197920
+ };
197921
+ }
197922
+ }
197923
+ const theme = () => getTheme();
197924
+ const box = overlayBox(otui, r, opts.id);
197925
+ r.root.add(box);
197926
+ box.add(new otui.TextRenderable(r, { id: `${opts.id}-title`, content: otui.t`${boldChunk(otui, opts.title)} ${dimChunk(otui, "(esc close)")}` }));
197927
+ const strip = new otui.BoxRenderable(r, { id: `${opts.id}-tabs`, width: "100%", height: 1, flexShrink: 0, flexDirection: "row" });
197928
+ const body = new otui.BoxRenderable(r, { id: `${opts.id}-body`, width: "100%", flexGrow: 1, minHeight: 0, flexDirection: "column" });
197929
+ const footer = new otui.TextRenderable(r, { id: `${opts.id}-footer`, content: "", flexShrink: 0 });
197930
+ box.add(strip);
197931
+ box.add(body);
197932
+ box.add(footer);
197933
+ let active = opts.initialTab;
197934
+ let closed = false;
197935
+ const paintStrip = () => {
197936
+ clearTranscriptChildren(strip);
197937
+ for (const [index, tab] of opts.tabs.entries()) {
197938
+ const isActive = tab.id === active;
197939
+ const label = isActive ? `[${tab.label}]` : ` ${tab.label} `;
197940
+ const prefix = index === 0 ? "" : " ";
197941
+ strip.add(new otui.TextRenderable(r, {
197942
+ id: `${opts.id}-tab-${tab.id}`,
197943
+ content: isActive ? otui.t`${prefix}${boldChunk(otui, label)}` : otui.t`${prefix}${dimChunk(otui, label)}`,
197944
+ fg: isActive ? theme().focus : theme().muted,
197945
+ onMouseDown: () => {
197946
+ if (!closed && tab.id !== active)
197947
+ mount(tab.id);
197948
+ }
197949
+ }));
197950
+ }
197951
+ };
197952
+ function mount(tabId) {
197953
+ clearTranscriptChildren(body);
197954
+ active = tabId;
197955
+ paintStrip();
197956
+ footer.content = otui.t`${dimChunk(otui, formatModalFooter(opts.footerFor(tabId)))}`;
197957
+ opts.renderTab(tabId, body, { width: Math.max(20, r.width - 4), height: Math.max(4, r.height - 6) });
197958
+ }
197959
+ const cycle = () => {
197960
+ const at = opts.tabs.findIndex((tab) => tab.id === active);
197961
+ const next = opts.tabs[(at + 1) % opts.tabs.length];
197962
+ if (next !== undefined)
197963
+ mount(next.id);
197964
+ };
197965
+ const close = () => {
197966
+ if (closed)
197967
+ return;
197968
+ closed = true;
197969
+ unsub();
197970
+ r.root.remove(box);
197971
+ };
197972
+ const stop = (key) => {
197973
+ key.preventDefault();
197974
+ key.stopPropagation();
197975
+ };
197976
+ const unsub = onKeypress4(r, (key) => {
197977
+ if (closed || opts.ownsKeys())
197978
+ return;
197979
+ if (key.name === "escape" || key.name === "x" || key.sequence === "x") {
197980
+ close();
197981
+ opts.onEscape();
197982
+ stop(key);
197983
+ return;
197984
+ }
197985
+ if ((key.name === "left" || key.name === "right") && opts.onArrowKeys(key, key.name)) {
197986
+ stop(key);
197987
+ return;
197988
+ }
197989
+ if (key.name === "tab") {
197990
+ cycle();
197991
+ stop(key);
197992
+ return;
197993
+ }
197994
+ const jump = tabDigit(key);
197995
+ const to = jump === undefined ? undefined : opts.tabs[jump - 1];
197996
+ if (to !== undefined) {
197997
+ if (to.id !== active)
197998
+ mount(to.id);
197999
+ stop(key);
198000
+ }
198001
+ });
198002
+ mount(opts.initialTab);
198003
+ return {
198004
+ modal: false,
198005
+ activeTab: () => active,
198006
+ setTab: (id) => {
198007
+ if (!closed && id !== active && opts.tabs.some((tab) => tab.id === id))
198008
+ mount(id);
198009
+ },
198010
+ close
198011
+ };
198012
+ }
197659
198013
  function selectStepFooter(escLabel) {
197660
198014
  return [
197661
198015
  { key: "\u2191/\u2193", label: "select" },
@@ -198218,102 +198572,151 @@ function pickProviderStep(otui, target, detected) {
198218
198572
  });
198219
198573
  });
198220
198574
  }
198221
- function pickConnectedProviderStep(otui, target, detected, opts) {
198575
+ function clipText(text, max) {
198576
+ if (max <= 1)
198577
+ return "";
198578
+ return text.length <= max ? text : `${text.slice(0, max - 1)}\u2026`;
198579
+ }
198580
+ async function probeProviderConnection(name, deps) {
198581
+ const provider = connectionProviderByName(name, deps.configDir);
198582
+ if (provider === undefined)
198583
+ return { kind: "unknown" };
198584
+ const env = deps.env ?? process.env;
198585
+ if (deps.requireCredential && (provider.requiresApiKey ?? true) && classifyProviderConnection(name, env, deps.configDir).kind === "no-credential") {
198586
+ return { kind: "no-credential" };
198587
+ }
198588
+ deps.onStart?.();
198589
+ let profileSummary;
198590
+ const result = await testProviderConnection(provider, deps.fetch ?? globalThis.fetch, env, {
198591
+ ...deps.configDir !== undefined ? { dir: deps.configDir } : {},
198592
+ onSummary: (s) => {
198593
+ profileSummary = s;
198594
+ }
198595
+ });
198596
+ if (result.source === "live") {
198597
+ return { kind: "live", modelCount: result.models.length, ...profileSummary !== undefined ? { profileSummary } : {} };
198598
+ }
198599
+ return { kind: "failed", failure: result.failure ?? { kind: "empty" } };
198600
+ }
198601
+ function pickProvidersTabbedStep(otui, target, opts) {
198222
198602
  const r = stepRenderer(target);
198223
198603
  return new Promise((resolve) => {
198224
- let rows = [...detected];
198225
- let selectedIndex = 0;
198226
- let selectedAction = "label";
198604
+ const connected = opts.connected;
198605
+ const addRows = () => [...opts.all, CUSTOM_ADD_ENTRY];
198606
+ const rowsOf = (tabId) => tabId === "connected" ? connected : addRows();
198607
+ const isConnected = (name) => connected.some((d) => d.name === name);
198608
+ let tab = connected.length === 0 ? "add" : opts.initialTab;
198609
+ const selected = { connected: 0, add: 0 };
198610
+ const windowStart = { connected: 0, add: 0 };
198611
+ let connectAction = "label";
198612
+ let addAction = "connect";
198227
198613
  let pendingDisconnect;
198228
198614
  let resolved = false;
198229
- let rowBlocks = [];
198615
+ let notice = opts.notice;
198616
+ let nextNotice;
198617
+ let rendered = false;
198618
+ let bodyBox;
198619
+ let addHint;
198620
+ const addHintContent = (total) => otui.t`${dimChunk(otui, `Connect saves a key or login \xB7 Test checks it ${total === 0 ? 0 : selected.add + 1}/${total}`)}`;
198621
+ let bodyWidth = 70;
198622
+ let bodyHeight = 15;
198623
+ const statuses = new Map;
198624
+ let blocks = [];
198230
198625
  const labelOf = (d) => d.label ?? d.name;
198231
198626
  const catalogCache = loadProviderCatalogCache(opts.configDir);
198232
198627
  const noteOf = (d) => {
198233
198628
  const base = d.note ?? `${d.models.length} model(s)`;
198234
198629
  const entry = catalogCache?.providers[d.name];
198235
- if (entry === undefined)
198236
- return base;
198237
- const balanceNote = entry.balance !== undefined ? ` \xB7 ${formatBalance(entry.balance)}` : "";
198238
- return `${base} \xB7 ${describeCatalogStatus(entry.status)}${balanceNote} \xB7 ${formatCatalogAge(entry.fetchedAt)}`;
198239
- };
198240
- const unsubEscapeGuard = onKeypress4(r, (key) => {
198241
- if (resolved || key.name !== "escape" || pendingDisconnect === undefined)
198242
- return;
198243
- clearPending();
198244
- applyHighlight();
198245
- key.preventDefault();
198246
- key.stopPropagation();
198247
- });
198248
- const surface = openStepSurface(otui, target, {
198249
- id: "connect-picker",
198250
- title: "Connected providers",
198251
- tab: "Providers",
198252
- hint: "(\u2191/\u2193 row \xB7 \u2190/\u2192 Label/Test/Disconnect \xB7 Enter \xB7 Esc to cancel)",
198253
- footer: [
198254
- { key: "\u2191/\u2193", label: "row" },
198255
- { key: "\u2190/\u2192", label: "Label/Test/Disconnect" },
198256
- { key: "Enter", label: "select/run" },
198257
- { key: "esc", label: "cancel" }
198258
- ],
198259
- contentRows: selectBoxHeight(Math.max(rows.length, 1), true),
198260
- onEscape: () => finish(undefined)
198261
- });
198262
- const body = surface.body;
198263
- function finish(value) {
198264
- if (resolved)
198265
- return;
198266
- resolved = true;
198267
- unsubEscapeGuard();
198268
- unsub();
198269
- surface.close();
198270
- resolve(value);
198630
+ const full = entry === undefined ? base : `${base} \xB7 ${describeCatalogStatus(entry.status)}${entry.balance !== undefined ? ` \xB7 ${formatBalance(entry.balance)}` : ""} \xB7 ${formatCatalogAge(entry.fetchedAt)}`;
198631
+ return clipText(full, bodyWidth - 24 - labelOf(d).length);
198632
+ };
198633
+ const addNoteOf = (d) => {
198634
+ if (d.name === CUSTOM_PROVIDER_ADD_ID)
198635
+ return "OpenAI-compatible \xB7 name, URL, key, models";
198636
+ const room = bodyWidth - 24 - labelOf(d).length - (isConnected(d.name) ? 13 : 0);
198637
+ return clipText(d.note ?? `${d.models.length} model(s)`, room);
198638
+ };
198639
+ const statusKey = (tabId, name) => `${tabId}:${name}`;
198640
+ function setStatus(tabId, name, content) {
198641
+ statuses.set(statusKey(tabId, name), content);
198642
+ const block = blocks.find((b) => b.tab === tabId && b.name === name);
198643
+ if (block !== undefined)
198644
+ block.status.content = content;
198645
+ }
198646
+ function labelContent(block, d) {
198647
+ const active = block.index === selected[block.tab];
198648
+ if (block.tab === "connected") {
198649
+ const head = active && connectAction === "label" ? boldChunk(otui, labelOf(d)) : labelOf(d);
198650
+ return otui.t`${head} ${dimChunk(otui, noteOf(d))}`;
198651
+ }
198652
+ const head = active ? boldChunk(otui, labelOf(d)) : labelOf(d);
198653
+ const mark = isConnected(d.name) ? roleChunk(otui, "ok", " \u2713 connected") : "";
198654
+ return otui.t`${head}${mark} ${dimChunk(otui, addNoteOf(d))}`;
198271
198655
  }
198272
198656
  function clearPending() {
198273
198657
  if (pendingDisconnect === undefined)
198274
198658
  return;
198275
- const armed = rowBlocks.find((b) => b.name === pendingDisconnect);
198659
+ const armed = pendingDisconnect;
198276
198660
  pendingDisconnect = undefined;
198277
- if (armed !== undefined)
198278
- armed.status.content = "";
198661
+ setStatus("connected", armed, "");
198279
198662
  }
198280
198663
  function applyHighlight() {
198281
- for (let i = 0;i < rowBlocks.length; i++) {
198282
- const block = rowBlocks[i];
198283
- if (block === undefined)
198664
+ if (tab === "add" && addHint !== undefined)
198665
+ addHint.content = addHintContent(rowsOf("add").length);
198666
+ for (const block of blocks) {
198667
+ const d = rowsOf(block.tab)[block.index];
198668
+ if (d === undefined)
198284
198669
  continue;
198285
- const active = i === selectedIndex;
198670
+ const active = block.index === selected[block.tab];
198286
198671
  block.outer.backgroundColor = active ? getTheme().highlight : undefined;
198287
- block.labelText.content = active && selectedAction === "label" ? otui.t`${boldChunk(otui, `${labelOf(rows[i])}`)} ${dimChunk(otui, noteOf(rows[i]))}` : otui.t`${labelOf(rows[i])} ${dimChunk(otui, noteOf(rows[i]))}`;
198288
- block.test.setActive(active && selectedAction === "test");
198289
- block.disconnect.setActive(active && selectedAction === "disconnect");
198672
+ block.labelText.content = labelContent(block, d);
198673
+ if (block.tab === "connected") {
198674
+ block.first.setActive(active && connectAction === "test");
198675
+ block.second?.setActive(active && connectAction === "disconnect");
198676
+ } else {
198677
+ block.first.setActive(active && (addAction === "connect" || block.second === undefined));
198678
+ block.second?.setActive(active && addAction === "test");
198679
+ }
198290
198680
  }
198291
198681
  }
198292
- async function runTest(name) {
198293
- const block = rowBlocks.find((b) => b.name === name);
198294
- const provider = connectionProviderByName(name, opts.configDir);
198295
- if (block === undefined)
198682
+ function finish(value) {
198683
+ if (resolved)
198296
198684
  return;
198297
- if (provider === undefined) {
198298
- block.status.content = otui.t`${roleChunk(otui, "error", "\u2717")} unknown provider \u2014 cannot test`;
198685
+ resolved = true;
198686
+ unsub();
198687
+ surface?.close();
198688
+ resolve(value);
198689
+ }
198690
+ async function runTest(tabId, name) {
198691
+ const d = rowsOf(tabId).find((row) => row.name === name);
198692
+ if (d === undefined)
198299
198693
  return;
198300
- }
198301
- block.status.content = otui.t`${dimChunk(otui, "testing\u2026")}`;
198302
- let profileSummary;
198303
- const result = await testProviderConnection(provider, opts.fetch ?? globalThis.fetch, opts.env ?? process.env, {
198304
- ...opts.configDir !== undefined ? { dir: opts.configDir } : {},
198305
- onSummary: (s) => {
198306
- profileSummary = s;
198307
- }
198694
+ const outcome = await probeProviderConnection(name, {
198695
+ ...opts.fetch !== undefined ? { fetch: opts.fetch } : {},
198696
+ ...opts.env !== undefined ? { env: opts.env } : {},
198697
+ ...opts.configDir !== undefined ? { configDir: opts.configDir } : {},
198698
+ requireCredential: tabId === "add",
198699
+ onStart: () => setStatus(tabId, name, otui.t`${dimChunk(otui, "testing\u2026")}`)
198308
198700
  });
198309
- const current = rowBlocks.find((b) => b.name === name);
198310
- if (current === undefined || resolved)
198701
+ if (resolved)
198311
198702
  return;
198312
- current.status.content = result.source === "live" ? otui.t`${roleChunk(otui, "ok", "\u2713")} ok \u2014 ${String(result.models.length)} model(s)${formatProfileSummarySuffix(profileSummary)}` : otui.t`${roleChunk(otui, "error", "\u2717")} ${modelsFailureLine(labelOf(rows.find((d) => d.name === name) ?? { name, models: [] }), result.failure ?? { kind: "empty" })}`;
198703
+ switch (outcome.kind) {
198704
+ case "unknown":
198705
+ setStatus(tabId, name, otui.t`${roleChunk(otui, "error", "\u2717")} unknown provider \u2014 cannot test`);
198706
+ return;
198707
+ case "no-credential":
198708
+ setStatus(tabId, name, otui.t`${roleChunk(otui, "attention", "\xB7")} ${NO_CREDENTIAL_TEXT}`);
198709
+ return;
198710
+ case "live":
198711
+ setStatus(tabId, name, otui.t`${roleChunk(otui, "ok", "\u2713")} ok \u2014 ${String(outcome.modelCount)} model(s)${formatProfileSummarySuffix(outcome.profileSummary)}`);
198712
+ return;
198713
+ case "failed":
198714
+ setStatus(tabId, name, otui.t`${roleChunk(otui, "error", "\u2717")} ${modelsFailureLine(labelOf(d), outcome.failure)}`);
198715
+ return;
198716
+ }
198313
198717
  }
198314
198718
  function armOrConfirmDisconnect(name) {
198315
- const block = rowBlocks.find((b) => b.name === name);
198316
- if (block === undefined)
198719
+ if (!blocks.some((b) => b.tab === "connected" && b.name === name))
198317
198720
  return;
198318
198721
  if (pendingDisconnect !== name) {
198319
198722
  clearPending();
@@ -198321,157 +198724,510 @@ function pickConnectedProviderStep(otui, target, detected, opts) {
198321
198724
  const env = opts.env ?? process.env;
198322
198725
  const classification = classifyProviderConnection(name, env, opts.configDir);
198323
198726
  const warning = sharedCredentialWarning(classification.sharedWith, classification.envKey);
198324
- const label = labelOf(rows.find((d) => d.name === name) ?? { name, models: [] });
198325
- block.status.content = warning === undefined ? otui.t`${roleChunk(otui, "attention", "?")} disconnect '${label}'? ${dimChunk(otui, "click/Enter Disconnect again to confirm, Esc to cancel")}` : otui.t`${roleChunk(otui, "attention", "?")} disconnect '${label}'? ${roleChunk(otui, "attention", `Note: ${warning}.`)} ${dimChunk(otui, "click/Enter Disconnect again to confirm, Esc to cancel")}`;
198727
+ const label = labelOf(connected.find((d) => d.name === name) ?? { name, models: [] });
198728
+ setStatus("connected", name, warning === undefined ? otui.t`${roleChunk(otui, "attention", "?")} disconnect '${label}'? ${dimChunk(otui, "click/Enter Disconnect again to confirm, Esc to cancel")}` : otui.t`${roleChunk(otui, "attention", "?")} disconnect '${label}'? ${roleChunk(otui, "attention", `Note: ${warning}.`)} ${dimChunk(otui, "click/Enter Disconnect again to confirm, Esc to cancel")}`);
198326
198729
  return;
198327
198730
  }
198328
198731
  pendingDisconnect = undefined;
198329
198732
  const result = disconnectProvider(name, opts.env ?? process.env, opts.configDir);
198330
198733
  if (!result.ok) {
198331
- block.status.content = otui.t`${roleChunk(otui, "error", "\u2717")} not removed \u2014 ${result.reason ?? "unknown reason"}`;
198734
+ setStatus("connected", name, otui.t`${roleChunk(otui, "error", "\u2717")} not removed \u2014 ${result.reason ?? "unknown reason"}`);
198332
198735
  return;
198333
198736
  }
198334
198737
  opts.onDisconnected?.(name, result.sharedWith);
198335
- rows = rows.filter((d) => d.name !== name);
198738
+ const at = connected.findIndex((d) => d.name === name);
198739
+ if (at >= 0)
198740
+ connected.splice(at, 1);
198741
+ statuses.delete(statusKey("connected", name));
198742
+ if (connected.length === 0) {
198743
+ switchTab("add", { tone: "info", text: "No providers connected. Add one below." });
198744
+ return;
198745
+ }
198336
198746
  paint();
198337
198747
  }
198338
- function paint() {
198339
- for (const block of rowBlocks) {
198340
- try {
198341
- body.remove(block.outer);
198342
- } catch {}
198748
+ function switchTab(id, withNotice) {
198749
+ if (tab === id)
198750
+ return;
198751
+ nextNotice = withNotice;
198752
+ surface?.setTab(id);
198753
+ }
198754
+ function moveSelection(direction) {
198755
+ const total = rowsOf(tab).length;
198756
+ selected[tab] = stepQueueNavIndex(selected[tab], total, direction);
198757
+ if (addCustomRowSelected())
198758
+ addAction = "connect";
198759
+ const start = windowStart[tab];
198760
+ if (selected[tab] < start || selected[tab] >= start + capacityRows())
198761
+ paint();
198762
+ else
198763
+ applyHighlight();
198764
+ }
198765
+ function addCustomRowSelected() {
198766
+ return tab === "add" && rowsOf("add")[selected.add]?.name === CUSTOM_PROVIDER_ADD_ID;
198767
+ }
198768
+ function capacityRows() {
198769
+ return Math.max(1, Math.floor((bodyHeight - 2 - (notice === undefined ? 0 : 1)) / 2));
198770
+ }
198771
+ function runAddSelection(d) {
198772
+ if (d.name === CUSTOM_PROVIDER_ADD_ID) {
198773
+ finish({ kind: "custom" });
198774
+ return;
198343
198775
  }
198344
- rowBlocks = [];
198345
- if (rows.length === 0) {
198346
- finish(undefined);
198776
+ finish({ kind: "connect", provider: d });
198777
+ }
198778
+ function paint() {
198779
+ if (bodyBox === undefined)
198347
198780
  return;
198781
+ const body = bodyBox;
198782
+ clearTranscriptChildren(body);
198783
+ blocks = [];
198784
+ const rows = rowsOf(tab);
198785
+ selected[tab] = clampQueueNavIndex(selected[tab], rows.length);
198786
+ const header = new otui.BoxRenderable(r, { id: `prov-${tab}-header`, width: "100%", flexDirection: "row", flexShrink: 0 });
198787
+ if (tab === "connected") {
198788
+ const addButton = smallActionButton(otui, r, "Add provider", "cp-add-provider", getTheme().focus, () => {
198789
+ clearPending();
198790
+ switchTab("add");
198791
+ });
198792
+ addButton.box.marginLeft = 0;
198793
+ header.add(addButton.box);
198794
+ header.add(new otui.TextRenderable(r, { id: "cp-count", marginLeft: 1, content: otui.t`${dimChunk(otui, `${connected.length} connected`)}` }));
198795
+ } else {
198796
+ addHint = new otui.TextRenderable(r, { id: "ap-hint", content: addHintContent(rows.length) });
198797
+ header.add(addHint);
198798
+ }
198799
+ body.add(header);
198800
+ if (notice !== undefined) {
198801
+ const icon = notice.tone === "ok" ? roleChunk(otui, "ok", "\u2713") : notice.tone === "error" ? roleChunk(otui, "error", "\u2717") : dimChunk(otui, "\xB7");
198802
+ body.add(new otui.TextRenderable(r, { id: "prov-notice", content: otui.t`${icon} ${notice.text}`, flexShrink: 0 }));
198348
198803
  }
198349
- selectedIndex = clampQueueNavIndex(selectedIndex, rows.length);
198350
- for (let i = 0;i < rows.length; i++) {
198804
+ if (rows.length === 0) {
198805
+ body.add(new otui.TextRenderable(r, { id: "cp-empty", content: otui.t`${dimChunk(otui, "Nothing connected yet. Press a, or open Add provider.")}` }));
198806
+ return;
198807
+ }
198808
+ const cap = capacityRows();
198809
+ let start = windowStart[tab];
198810
+ if (selected[tab] < start)
198811
+ start = selected[tab];
198812
+ else if (selected[tab] >= start + cap)
198813
+ start = selected[tab] - cap + 1;
198814
+ start = Math.max(0, Math.min(start, Math.max(0, rows.length - cap)));
198815
+ windowStart[tab] = start;
198816
+ const prefix = tab === "connected" ? "cp" : "ap";
198817
+ for (let i = start;i < Math.min(rows.length, start + cap); i++) {
198351
198818
  const d = rows[i];
198352
- const outer = new otui.BoxRenderable(r, { id: `cp-${d.name}`, width: "100%", flexDirection: "column" });
198353
- const row = new otui.BoxRenderable(r, { id: `cp-${d.name}-row`, width: "100%", flexDirection: "row" });
198354
- const labelText = new otui.TextRenderable(r, {
198355
- id: `cp-${d.name}-label-t`,
198356
- content: otui.t`${labelOf(d)} ${dimChunk(otui, noteOf(d))}`
198357
- });
198819
+ const isCustom = d.name === CUSTOM_PROVIDER_ADD_ID;
198820
+ const outer = new otui.BoxRenderable(r, { id: `${prefix}-${d.name}`, width: "100%", flexDirection: "column", flexShrink: 0 });
198821
+ const row = new otui.BoxRenderable(r, { id: `${prefix}-${d.name}-row`, width: "100%", flexDirection: "row" });
198822
+ const base = { tab, index: i, name: d.name };
198823
+ const labelText = new otui.TextRenderable(r, { id: `${prefix}-${d.name}-label-t`, content: labelContent(base, d) });
198358
198824
  const label = new otui.BoxRenderable(r, {
198359
- id: `cp-${d.name}-label`,
198825
+ id: `${prefix}-${d.name}-label`,
198360
198826
  flexGrow: 1,
198361
198827
  minWidth: 0,
198362
198828
  onMouseDown: (event) => {
198363
198829
  event.stopPropagation();
198364
198830
  clearPending();
198365
- selectedIndex = i;
198366
- selectedAction = "label";
198367
- finish(d);
198831
+ selected[tab] = i;
198832
+ if (tab === "connected") {
198833
+ connectAction = "label";
198834
+ finish({ kind: "pick", provider: d });
198835
+ return;
198836
+ }
198837
+ if (isCustom)
198838
+ addAction = "connect";
198839
+ applyHighlight();
198368
198840
  }
198369
198841
  });
198370
198842
  label.add(labelText);
198371
198843
  row.add(label);
198372
- const test = smallActionButton(otui, r, "Test", `cp-test-${d.name}`, getTheme().focus, () => {
198373
- clearPending();
198374
- selectedIndex = i;
198375
- selectedAction = "test";
198376
- applyHighlight();
198377
- runTest(d.name);
198378
- });
198379
- const disconnect = smallActionButton(otui, r, "Disconnect", `cp-disc-${d.name}`, getTheme().error, () => {
198380
- selectedIndex = i;
198381
- selectedAction = "disconnect";
198382
- applyHighlight();
198383
- armOrConfirmDisconnect(d.name);
198384
- });
198385
- row.add(test.box);
198386
- row.add(disconnect.box);
198844
+ let first;
198845
+ let second;
198846
+ if (tab === "connected") {
198847
+ first = smallActionButton(otui, r, "Test", `cp-test-${d.name}`, getTheme().focus, () => {
198848
+ clearPending();
198849
+ selected.connected = i;
198850
+ connectAction = "test";
198851
+ applyHighlight();
198852
+ runTest("connected", d.name);
198853
+ });
198854
+ second = smallActionButton(otui, r, "Disconnect", `cp-disc-${d.name}`, getTheme().error, () => {
198855
+ selected.connected = i;
198856
+ connectAction = "disconnect";
198857
+ applyHighlight();
198858
+ armOrConfirmDisconnect(d.name);
198859
+ });
198860
+ } else {
198861
+ first = smallActionButton(otui, r, "Connect", `ap-connect-${d.name}`, getTheme().focus, () => {
198862
+ selected.add = i;
198863
+ addAction = "connect";
198864
+ runAddSelection(d);
198865
+ });
198866
+ if (!isCustom) {
198867
+ second = smallActionButton(otui, r, "Test", `ap-test-${d.name}`, getTheme().focus, () => {
198868
+ selected.add = i;
198869
+ addAction = "test";
198870
+ applyHighlight();
198871
+ runTest("add", d.name);
198872
+ });
198873
+ }
198874
+ }
198875
+ row.add(first.box);
198876
+ if (second !== undefined)
198877
+ row.add(second.box);
198387
198878
  outer.add(row);
198388
- const status = new otui.TextRenderable(r, { id: `cp-${d.name}-status`, content: "", marginLeft: 1 });
198879
+ const status = new otui.TextRenderable(r, { id: `${prefix}-${d.name}-status`, content: statuses.get(statusKey(tab, d.name)) ?? "", marginLeft: 1 });
198389
198880
  outer.add(status);
198390
198881
  body.add(outer);
198391
- rowBlocks.push({ name: d.name, outer, label, labelText, status, test, disconnect });
198882
+ blocks.push({ tab, index: i, name: d.name, outer, labelText, status, first, ...second !== undefined ? { second } : {} });
198392
198883
  }
198393
198884
  applyHighlight();
198394
198885
  }
198395
- const unsub = onKeypress4(r, (key) => {
198396
- if (resolved)
198397
- return;
198398
- if (key.name === "up") {
198399
- clearPending();
198400
- selectedIndex = stepQueueNavIndex(selectedIndex, rows.length, "up");
198886
+ function renderTab(tabId, body, ctx) {
198887
+ const id = tabId === "add" ? "add" : "connected";
198888
+ if (rendered && id !== tab)
198889
+ notice = nextNotice;
198890
+ nextNotice = undefined;
198891
+ rendered = true;
198892
+ if (pendingDisconnect !== undefined) {
198893
+ const armed = pendingDisconnect;
198894
+ pendingDisconnect = undefined;
198895
+ statuses.set(statusKey("connected", armed), "");
198896
+ }
198897
+ tab = id;
198898
+ bodyBox = body;
198899
+ bodyWidth = ctx.width;
198900
+ bodyHeight = ctx.height;
198901
+ if (opts.focusName !== undefined && !focusApplied.has(id)) {
198902
+ focusApplied.add(id);
198903
+ const at = rowsOf(id).findIndex((d) => d.name === opts.focusName);
198904
+ if (at >= 0)
198905
+ selected[id] = at;
198906
+ }
198907
+ paint();
198908
+ }
198909
+ const focusApplied = new Set;
198910
+ const rowCount = Math.max(connected.length, opts.all.length + 1);
198911
+ const surface = openTabbedSurface(otui, target, {
198912
+ id: "providers",
198913
+ title: "Providers",
198914
+ tabs: PROVIDERS_TABS,
198915
+ initialTab: tab,
198916
+ footerFor: (tabId) => PROVIDERS_FOOTERS[tabId === "add" ? "add" : "connected"],
198917
+ contentRows: Math.min(24, rowCount * 2 + 3),
198918
+ renderTab,
198919
+ ownsKeys: () => pendingDisconnect !== undefined,
198920
+ onArrowKeys: (key, direction) => {
198921
+ if (resolved)
198922
+ return false;
198923
+ if (tab === "connected")
198924
+ connectAction = stepConnectNavAction(connectAction, direction);
198925
+ else if (!addCustomRowSelected())
198926
+ addAction = stepAddNavAction(addAction, direction);
198401
198927
  applyHighlight();
198402
198928
  key.preventDefault();
198403
198929
  key.stopPropagation();
198930
+ return true;
198931
+ },
198932
+ onEscape: () => finish({ kind: "close" })
198933
+ });
198934
+ const unsub = onKeypress4(r, (key) => {
198935
+ if (resolved)
198404
198936
  return;
198405
- }
198406
- if (key.name === "down") {
198937
+ if (key.name === "escape") {
198938
+ if (pendingDisconnect === undefined)
198939
+ return;
198407
198940
  clearPending();
198408
- selectedIndex = stepQueueNavIndex(selectedIndex, rows.length, "down");
198409
198941
  applyHighlight();
198410
198942
  key.preventDefault();
198411
198943
  key.stopPropagation();
198412
198944
  return;
198413
198945
  }
198414
- if (key.name === "left") {
198946
+ if (key.name === "up" || key.name === "down") {
198415
198947
  clearPending();
198416
- selectedAction = stepConnectNavAction(selectedAction, "left");
198417
- applyHighlight();
198948
+ moveSelection(key.name);
198418
198949
  key.preventDefault();
198419
198950
  key.stopPropagation();
198420
198951
  return;
198421
198952
  }
198422
- if (key.name === "right") {
198953
+ if (key.name === "left" || key.name === "right") {
198423
198954
  clearPending();
198424
- selectedAction = stepConnectNavAction(selectedAction, "right");
198955
+ if (tab === "connected")
198956
+ connectAction = stepConnectNavAction(connectAction, key.name);
198957
+ else if (!addCustomRowSelected())
198958
+ addAction = stepAddNavAction(addAction, key.name);
198425
198959
  applyHighlight();
198426
198960
  key.preventDefault();
198427
198961
  key.stopPropagation();
198428
198962
  return;
198429
198963
  }
198430
198964
  if (key.name === "return" || key.name === "linefeed" || key.name === "kpenter") {
198431
- const row = rows[selectedIndex];
198965
+ const row = rowsOf(tab)[selected[tab]];
198432
198966
  if (row === undefined)
198433
198967
  return;
198434
- if (selectedAction === "label") {
198435
- clearPending();
198436
- finish(row);
198437
- } else if (selectedAction === "test") {
198438
- clearPending();
198439
- runTest(row.name);
198968
+ if (tab === "connected") {
198969
+ if (connectAction === "label") {
198970
+ clearPending();
198971
+ finish({ kind: "pick", provider: row });
198972
+ } else if (connectAction === "test") {
198973
+ clearPending();
198974
+ runTest("connected", row.name);
198975
+ } else {
198976
+ armOrConfirmDisconnect(row.name);
198977
+ }
198978
+ } else if (addAction === "test" && row.name !== CUSTOM_PROVIDER_ADD_ID) {
198979
+ runTest("add", row.name);
198440
198980
  } else {
198441
- armOrConfirmDisconnect(row.name);
198981
+ runAddSelection(row);
198442
198982
  }
198443
198983
  key.preventDefault();
198444
198984
  key.stopPropagation();
198985
+ return;
198986
+ }
198987
+ if (tab === "connected" && key.sequence === "a" && !key.ctrl && !key.meta) {
198988
+ clearPending();
198989
+ switchTab("add");
198990
+ key.preventDefault();
198991
+ key.stopPropagation();
198445
198992
  }
198446
198993
  });
198447
- paint();
198448
198994
  });
198449
198995
  }
198996
+ async function runProviderConnectSequence(otui, target, prov, deps, setup, requireKey = false) {
198997
+ const lockDiscoveredHost = ["github-copilot", "openai", "openai-codex"].includes(prov.name);
198998
+ let selectedBaseUrl = !setup || prov.baseUrl === undefined || lockDiscoveredHost ? prov.baseUrl : await promptBaseUrlStep(otui, target, prov.label ?? prov.name, prov.baseUrl);
198999
+ if (setup && !lockDiscoveredHost && prov.baseUrl !== undefined && selectedBaseUrl === undefined) {
199000
+ return { kind: "back" };
199001
+ }
199002
+ if (setup && !lockDiscoveredHost && selectedBaseUrl !== undefined) {
199003
+ saveProviderBaseUrl(prov.name, selectedBaseUrl, deps.configDir);
199004
+ }
199005
+ let selectedProvider = selectedBaseUrl === undefined ? prov : { ...prov, baseUrl: selectedBaseUrl };
199006
+ const envKey = prov.envKey;
199007
+ const label = prov.label ?? prov.name;
199008
+ const collectCredential = async (notice) => {
199009
+ if (!setup || envKey === undefined && !catalogAllows(prov.name, "device-code")) {
199010
+ return "ok";
199011
+ }
199012
+ if (notice === undefined) {
199013
+ const existingKey = envKey === undefined ? undefined : (deps.env ?? process.env)[envKey] ?? loadShellConfig(deps.configDir).apiKeys?.[envKey];
199014
+ const hasOauth = prov.name !== "openai" && oauthAccessToken(prov.name, deps.configDir) !== undefined;
199015
+ if (existingKey !== undefined && existingKey.length > 0 || hasOauth) {
199016
+ return "ok";
199017
+ }
199018
+ }
199019
+ const offered = [];
199020
+ if (catalogAllows(prov.name, "device-code"))
199021
+ offered.push("device-code");
199022
+ if (catalogMethods(prov.name).includes("api-key"))
199023
+ offered.push("api-key");
199024
+ let method = offered.length === 1 ? offered[0] : undefined;
199025
+ if (offered.length > 1) {
199026
+ method = await pickAuthMethodStep(otui, target, label, offered);
199027
+ if (method === undefined) {
199028
+ return "back";
199029
+ }
199030
+ }
199031
+ if (method === "device-code") {
199032
+ if (!await runDeviceLoginInTui(otui, target, prov.name, deps.configDir, deps.fetch)) {
199033
+ return "back";
199034
+ }
199035
+ const saved = loadShellConfig(deps.configDir).baseUrls?.[prov.name];
199036
+ if (typeof saved === "string" && saved.length > 0) {
199037
+ selectedBaseUrl = saved;
199038
+ selectedProvider = { ...prov, baseUrl: saved };
199039
+ }
199040
+ return "ok";
199041
+ }
199042
+ if (envKey === undefined)
199043
+ return "back";
199044
+ const kr = await promptApiKeyStep(otui, target, {
199045
+ label: prov.name === "openai" ? "OpenAI" : label,
199046
+ envKey,
199047
+ ...notice === undefined ? {} : { notice }
199048
+ });
199049
+ if (kr.kind === "back") {
199050
+ return "back";
199051
+ }
199052
+ if (kr.kind === "key") {
199053
+ process.env[envKey] = kr.value;
199054
+ noteSavedCredentialEnv([envKey]);
199055
+ saveApiKey(envKey, kr.value, deps.configDir);
199056
+ }
199057
+ if (kr.kind === "skip" && requireKey && (connectionProviderByName(prov.name, deps.configDir)?.requiresApiKey ?? true) && classifyProviderConnection(prov.name, deps.env ?? process.env, deps.configDir).kind === "no-credential") {
199058
+ return "no-key";
199059
+ }
199060
+ return "ok";
199061
+ };
199062
+ const firstCredential = await collectCredential();
199063
+ if (firstCredential === "back") {
199064
+ return { kind: "back" };
199065
+ }
199066
+ if (firstCredential === "no-key") {
199067
+ return { kind: "back", notice: NO_KEY_ENTERED_TEXT };
199068
+ }
199069
+ const modelDeps = {
199070
+ ...deps.fetch === undefined ? {} : { fetch: deps.fetch },
199071
+ ...deps.env === undefined ? {} : { env: deps.env },
199072
+ ...deps.configDir === undefined ? {} : { configDir: deps.configDir }
199073
+ };
199074
+ let models = await modelsForPicker(selectedProvider, modelDeps);
199075
+ if (models.failure?.kind === "rejected") {
199076
+ const retried = await collectCredential(modelsFailureLine(label, models.failure));
199077
+ if (retried === "back") {
199078
+ return { kind: "back" };
199079
+ }
199080
+ if (retried === "no-key") {
199081
+ return { kind: "back", notice: NO_KEY_ENTERED_TEXT };
199082
+ }
199083
+ models = await modelsForPicker(selectedProvider, modelDeps);
199084
+ }
199085
+ if (setup && !lockDiscoveredHost && selectedProvider.baseUrl !== undefined && endpointMayBeAtFault(models.failure)) {
199086
+ const corrected = await promptBaseUrlStep(otui, target, label, selectedProvider.baseUrl, modelsFailureLine(label, models.failure));
199087
+ if (corrected === undefined) {
199088
+ return { kind: "back" };
199089
+ }
199090
+ if (corrected !== selectedProvider.baseUrl) {
199091
+ saveProviderBaseUrl(prov.name, corrected, deps.configDir);
199092
+ selectedBaseUrl = corrected;
199093
+ selectedProvider = { ...prov, baseUrl: corrected };
199094
+ models = await modelsForPicker(selectedProvider, modelDeps);
199095
+ }
199096
+ }
199097
+ return { kind: "ready", provider: selectedProvider, baseUrl: selectedBaseUrl, models, label };
199098
+ }
199099
+ function saveCustomProviderFromWizard(created, configDir) {
199100
+ saveCustomCompatProvider({
199101
+ name: created.name,
199102
+ label: created.name,
199103
+ baseUrl: created.baseUrl,
199104
+ ...created.apiKey !== undefined ? { apiKey: created.apiKey } : {},
199105
+ models: created.models,
199106
+ requiresApiKey: false,
199107
+ ...created.temperature !== undefined ? { temperature: created.temperature } : {},
199108
+ ...created.maxOutputTokens !== undefined ? { maxOutputTokens: created.maxOutputTokens } : {},
199109
+ ...created.timeoutMs !== undefined ? { timeoutMs: created.timeoutMs } : {}
199110
+ }, configDir);
199111
+ return { name: created.name, models: created.models, baseUrl: created.baseUrl, label: created.name, note: "custom" };
199112
+ }
199113
+ function upsertProvider(list, entry) {
199114
+ const at = list.findIndex((d) => d.name === entry.name);
199115
+ if (at >= 0)
199116
+ list[at] = entry;
199117
+ else
199118
+ list.push(entry);
199119
+ }
199120
+ async function pickModelForProvider(otui, target, ready, providerName) {
199121
+ const model = await pickModelInTui(otui, target, ready.models.models, modelPickerNotice(ready.label, ready.models), {
199122
+ escLabel: "back"
199123
+ });
199124
+ if (model === undefined)
199125
+ return;
199126
+ return ready.baseUrl === undefined ? { provider: providerName, model } : { provider: providerName, model, baseUrl: ready.baseUrl };
199127
+ }
199128
+ async function runProvidersModal(otui, target, detected, options) {
199129
+ const deps = {
199130
+ ...options.fetch !== undefined ? { fetch: options.fetch } : {},
199131
+ ...options.env !== undefined ? { env: options.env } : {},
199132
+ ...options.configDir !== undefined ? { configDir: options.configDir } : {}
199133
+ };
199134
+ const all = [...detected];
199135
+ const connected = await filterConnectedDetectedProviders(detected, deps);
199136
+ let tab = connected.length > 0 ? "connected" : "add";
199137
+ let notice;
199138
+ let focusName;
199139
+ while (true) {
199140
+ const action = await pickProvidersTabbedStep(otui, target, {
199141
+ connected,
199142
+ all,
199143
+ initialTab: tab,
199144
+ ...notice !== undefined ? { notice } : {},
199145
+ ...focusName !== undefined ? { focusName } : {},
199146
+ ...deps,
199147
+ ...options.onDisconnected !== undefined ? { onDisconnected: options.onDisconnected } : {}
199148
+ });
199149
+ notice = undefined;
199150
+ focusName = undefined;
199151
+ if (action.kind === "close")
199152
+ return;
199153
+ try {
199154
+ if (action.kind === "pick") {
199155
+ const ready = await runProviderConnectSequence(otui, target, action.provider, deps, false);
199156
+ if (ready.kind === "ready") {
199157
+ const selection = await pickModelForProvider(otui, target, ready, action.provider.name);
199158
+ if (selection !== undefined)
199159
+ return selection;
199160
+ }
199161
+ tab = "connected";
199162
+ focusName = action.provider.name;
199163
+ continue;
199164
+ }
199165
+ if (action.kind === "connect") {
199166
+ const result = await runProviderConnectSequence(otui, target, action.provider, deps, true, true);
199167
+ focusName = action.provider.name;
199168
+ if (result.kind === "back") {
199169
+ tab = "add";
199170
+ if (result.notice !== undefined)
199171
+ notice = { tone: "error", text: `${action.provider.label ?? action.provider.name}: ${result.notice}` };
199172
+ continue;
199173
+ }
199174
+ if (result.models.failure === undefined && result.models.models.length > 0) {
199175
+ upsertProvider(connected, { ...result.provider, models: result.models.models });
199176
+ options.onConnected?.(action.provider.name);
199177
+ tab = "connected";
199178
+ notice = { tone: "ok", text: `${result.label} connected \u2014 ${result.models.models.length} model(s)` };
199179
+ } else {
199180
+ tab = "add";
199181
+ notice = {
199182
+ tone: "error",
199183
+ text: result.models.failure !== undefined ? modelsFailureLine(result.label, result.models.failure) : `${result.label}: no models returned`
199184
+ };
199185
+ }
199186
+ continue;
199187
+ }
199188
+ tab = "add";
199189
+ const created = await promptCustomProviderWizard(otui, target);
199190
+ if (created === undefined)
199191
+ continue;
199192
+ const entry = saveCustomProviderFromWizard(created, options.configDir);
199193
+ upsertProvider(all, entry);
199194
+ focusName = entry.name;
199195
+ const probed = await modelsForPicker(entry, deps);
199196
+ if (probed.failure === undefined && probed.models.length > 0) {
199197
+ upsertProvider(connected, { ...entry, models: probed.models });
199198
+ options.onConnected?.(entry.name);
199199
+ tab = "connected";
199200
+ notice = { tone: "ok", text: `${entry.label ?? entry.name} connected \u2014 ${probed.models.length} model(s)` };
199201
+ } else {
199202
+ notice = {
199203
+ tone: "error",
199204
+ text: probed.failure !== undefined ? modelsFailureLine(entry.label ?? entry.name, probed.failure) : `${entry.label ?? entry.name}: no models returned`
199205
+ };
199206
+ }
199207
+ } catch (error) {
199208
+ tab = action.kind === "pick" ? "connected" : "add";
199209
+ notice = { tone: "error", text: `could not finish (${error instanceof Error ? error.name : "unexpected error"}) \u2014 try again` };
199210
+ }
199211
+ }
199212
+ }
198450
199213
  function selectProviderModelInTui(otui, rOrChrome, detected, options = {}) {
199214
+ if (options.onlyConnected) {
199215
+ return runProvidersModal(otui, rOrChrome, detected, options);
199216
+ }
198451
199217
  return new Promise((resolve) => {
198452
199218
  if (detected.length === 0) {
198453
199219
  resolve(undefined);
198454
199220
  return;
198455
199221
  }
198456
- const candidatesPromise = options.onlyConnected ? filterConnectedDetectedProviders(detected, {
198457
- ...options.fetch !== undefined ? { fetch: options.fetch } : {},
198458
- ...options.env !== undefined ? { env: options.env } : {},
198459
- ...options.configDir !== undefined ? { configDir: options.configDir } : {}
198460
- }) : Promise.resolve(detected);
198461
199222
  (async () => {
198462
- const candidates = await candidatesPromise;
198463
- if (candidates.length === 0) {
198464
- resolve(undefined);
198465
- return;
198466
- }
198467
- const allCandidates = options.onlyConnected ? candidates : [...candidates, CUSTOM_ADD_ENTRY];
199223
+ const allCandidates = [...detected, CUSTOM_ADD_ENTRY];
199224
+ const deps = {
199225
+ ...options.fetch !== undefined ? { fetch: options.fetch } : {},
199226
+ ...options.env !== undefined ? { env: options.env } : {},
199227
+ ...options.configDir !== undefined ? { configDir: options.configDir } : {}
199228
+ };
198468
199229
  while (true) {
198469
- const prov = options.onlyConnected ? await pickConnectedProviderStep(otui, rOrChrome, allCandidates, {
198470
- ...options.fetch !== undefined ? { fetch: options.fetch } : {},
198471
- ...options.env !== undefined ? { env: options.env } : {},
198472
- ...options.configDir !== undefined ? { configDir: options.configDir } : {},
198473
- ...options.onDisconnected !== undefined ? { onDisconnected: options.onDisconnected } : {}
198474
- }) : await pickProviderStep(otui, rOrChrome, allCandidates);
199230
+ const prov = await pickProviderStep(otui, rOrChrome, allCandidates);
198475
199231
  if (prov === undefined) {
198476
199232
  resolve(undefined);
198477
199233
  return;
@@ -198479,123 +199235,17 @@ function selectProviderModelInTui(otui, rOrChrome, detected, options = {}) {
198479
199235
  if (prov.name === CUSTOM_PROVIDER_ADD_ID) {
198480
199236
  const created = await promptCustomProviderWizard(otui, rOrChrome);
198481
199237
  if (created !== undefined) {
198482
- saveCustomCompatProvider({
198483
- name: created.name,
198484
- label: created.name,
198485
- baseUrl: created.baseUrl,
198486
- ...created.apiKey !== undefined ? { apiKey: created.apiKey } : {},
198487
- models: created.models,
198488
- requiresApiKey: false,
198489
- ...created.temperature !== undefined ? { temperature: created.temperature } : {},
198490
- ...created.maxOutputTokens !== undefined ? { maxOutputTokens: created.maxOutputTokens } : {},
198491
- ...created.timeoutMs !== undefined ? { timeoutMs: created.timeoutMs } : {}
198492
- });
198493
- allCandidates.push({
198494
- name: created.name,
198495
- models: created.models,
198496
- baseUrl: created.baseUrl,
198497
- label: created.name,
198498
- note: "custom"
198499
- });
199238
+ allCandidates.push(saveCustomProviderFromWizard(created, options.configDir));
198500
199239
  }
198501
199240
  continue;
198502
199241
  }
198503
- const lockDiscoveredHost = ["github-copilot", "openai", "openai-codex"].includes(prov.name);
198504
- let selectedBaseUrl = options.onlyConnected || prov.baseUrl === undefined || lockDiscoveredHost ? prov.baseUrl : await promptBaseUrlStep(otui, rOrChrome, prov.label ?? prov.name, prov.baseUrl);
198505
- if (!options.onlyConnected && !lockDiscoveredHost && prov.baseUrl !== undefined && selectedBaseUrl === undefined) {
198506
- continue;
198507
- }
198508
- if (!options.onlyConnected && !lockDiscoveredHost && selectedBaseUrl !== undefined) {
198509
- saveProviderBaseUrl(prov.name, selectedBaseUrl, options.configDir);
198510
- }
198511
- let selectedProvider = selectedBaseUrl === undefined ? prov : { ...prov, baseUrl: selectedBaseUrl };
198512
- const envKey = prov.envKey;
198513
- const label = prov.label ?? prov.name;
198514
- const collectCredential = async (notice) => {
198515
- if (options.onlyConnected || envKey === undefined && !catalogAllows(prov.name, "device-code")) {
198516
- return "ok";
198517
- }
198518
- if (notice === undefined) {
198519
- const existingKey = envKey === undefined ? undefined : (options.env ?? process.env)[envKey] ?? loadShellConfig(options.configDir).apiKeys?.[envKey];
198520
- const hasOauth = prov.name !== "openai" && oauthAccessToken(prov.name, options.configDir) !== undefined;
198521
- if (existingKey !== undefined && existingKey.length > 0 || hasOauth) {
198522
- return "ok";
198523
- }
198524
- }
198525
- const offered = [];
198526
- if (catalogAllows(prov.name, "device-code"))
198527
- offered.push("device-code");
198528
- if (catalogMethods(prov.name).includes("api-key"))
198529
- offered.push("api-key");
198530
- let method = offered.length === 1 ? offered[0] : undefined;
198531
- if (offered.length > 1) {
198532
- method = await pickAuthMethodStep(otui, rOrChrome, label, offered);
198533
- if (method === undefined) {
198534
- return "back";
198535
- }
198536
- }
198537
- if (method === "device-code") {
198538
- if (!await runDeviceLoginInTui(otui, rOrChrome, prov.name, options.configDir, options.fetch)) {
198539
- return "back";
198540
- }
198541
- const saved = loadShellConfig(options.configDir).baseUrls?.[prov.name];
198542
- if (typeof saved === "string" && saved.length > 0) {
198543
- selectedBaseUrl = saved;
198544
- selectedProvider = { ...prov, baseUrl: saved };
198545
- }
198546
- return "ok";
198547
- }
198548
- if (envKey === undefined)
198549
- return "back";
198550
- const kr = await promptApiKeyStep(otui, rOrChrome, {
198551
- label: prov.name === "openai" ? "OpenAI" : label,
198552
- envKey,
198553
- ...notice === undefined ? {} : { notice }
198554
- });
198555
- if (kr.kind === "back") {
198556
- return "back";
198557
- }
198558
- if (kr.kind === "key") {
198559
- process.env[envKey] = kr.value;
198560
- noteSavedCredentialEnv([envKey]);
198561
- saveApiKey(envKey, kr.value, options.configDir);
198562
- }
198563
- return "ok";
198564
- };
198565
- if (await collectCredential() === "back") {
199242
+ const ready = await runProviderConnectSequence(otui, rOrChrome, prov, deps, true);
199243
+ if (ready.kind === "back")
198566
199244
  continue;
198567
- }
198568
- const modelDeps = {
198569
- ...options.fetch === undefined ? {} : { fetch: options.fetch },
198570
- ...options.env === undefined ? {} : { env: options.env },
198571
- ...options.configDir === undefined ? {} : { configDir: options.configDir }
198572
- };
198573
- let models = await modelsForPicker(selectedProvider, modelDeps);
198574
- if (models.failure?.kind === "rejected") {
198575
- if (await collectCredential(modelsFailureLine(label, models.failure)) === "back") {
198576
- continue;
198577
- }
198578
- models = await modelsForPicker(selectedProvider, modelDeps);
198579
- }
198580
- if (!options.onlyConnected && !lockDiscoveredHost && selectedProvider.baseUrl !== undefined && endpointMayBeAtFault(models.failure)) {
198581
- const corrected = await promptBaseUrlStep(otui, rOrChrome, label, selectedProvider.baseUrl, modelsFailureLine(label, models.failure));
198582
- if (corrected === undefined) {
198583
- continue;
198584
- }
198585
- if (corrected !== selectedProvider.baseUrl) {
198586
- saveProviderBaseUrl(prov.name, corrected, options.configDir);
198587
- selectedBaseUrl = corrected;
198588
- selectedProvider = { ...prov, baseUrl: corrected };
198589
- models = await modelsForPicker(selectedProvider, modelDeps);
198590
- }
198591
- }
198592
- const model = await pickModelInTui(otui, rOrChrome, models.models, modelPickerNotice(label, models), {
198593
- escLabel: "back"
198594
- });
198595
- if (model === undefined) {
199245
+ const selection = await pickModelForProvider(otui, rOrChrome, ready, prov.name);
199246
+ if (selection === undefined)
198596
199247
  continue;
198597
- }
198598
- resolve(selectedBaseUrl === undefined ? { provider: prov.name, model } : { provider: prov.name, model, baseUrl: selectedBaseUrl });
199248
+ resolve(selection);
198599
199249
  return;
198600
199250
  }
198601
199251
  })();
@@ -198866,6 +199516,11 @@ async function launchTuiAgentShell(opts) {
198866
199516
  let liveAutoReview;
198867
199517
  let liveReviewsPanel;
198868
199518
  let remoteBridge;
199519
+ let remoteDeviceLogin;
199520
+ const connectedProviders = createConnectedProvidersCache(async () => {
199521
+ const detected = opts.redetect !== undefined ? await opts.redetect() : opts.detected;
199522
+ return { detected, connected: await filterConnectedDetectedProviders(detected, {}) };
199523
+ });
198869
199524
  let liveRemotePanel;
198870
199525
  const operatorCards = new OperatorConfirmationLog;
198871
199526
  const operatorCardLines = () => confirmationLines(operatorCards.entries(), Date.now());
@@ -198909,6 +199564,7 @@ async function launchTuiAgentShell(opts) {
198909
199564
  liveReviewsPanel?.dispose();
198910
199565
  liveRemotePanel?.dispose();
198911
199566
  liveChannelsPanel?.dispose();
199567
+ remoteDeviceLogin?.abort();
198912
199568
  remoteBridge?.disable();
198913
199569
  liveApprovals?.dispose();
198914
199570
  liveIntake?.dispose();
@@ -203184,28 +203840,28 @@ ${formatThemeList(getThemeId())}`);
203184
203840
  runDelegate(line);
203185
203841
  return;
203186
203842
  }
203187
- if (command.name === "/connect" || command.name === "/provider") {
203843
+ if (command.name === "/connect") {
203188
203844
  detachSlashWork((async () => {
203189
203845
  const detected = opts.redetect !== undefined ? await opts.redetect() : opts.detected;
203190
- const ns = await chrome.withOverlay(() => command.name === "/connect" ? selectProviderModelInTui(otui, chrome, detected, {
203846
+ const ns = await chrome.withOverlay(() => selectProviderModelInTui(otui, chrome, detected, {
203191
203847
  onlyConnected: true,
203192
203848
  env: process.env,
203849
+ onConnected: () => connectedProviders.invalidate(),
203193
203850
  onDisconnected: (name, sharedWith) => {
203851
+ connectedProviders.invalidate();
203194
203852
  if (sharedWith.length > 0) {
203195
203853
  io.onSystem?.(`\u25C7 ${name} disconnected \u2014 this also disconnected: ${sharedWith.join(", ")}.
203196
203854
  `);
203197
203855
  }
203198
203856
  if (name === currentSel.provider) {
203199
- io.onSystem?.(name === "openai-codex" ? `\u25C7 ${name} disconnected \u2014 it stays selected, but the next turn requires a new login. Use /provider to reconnect or /connect to switch.
203857
+ io.onSystem?.(name === "openai-codex" ? `\u25C7 ${name} disconnected \u2014 it stays selected, but the next turn requires a new login. Run /connect to log in again or to switch.
203200
203858
  ` : `\u25C7 ${name} disconnected \u2014 this session keeps its already-loaded credential until you /connect another provider or restart.
203201
203859
  `);
203202
203860
  }
203203
203861
  }
203204
- }) : selectProviderModelInTui(otui, chrome, detected));
203862
+ }));
203205
203863
  if (ns !== undefined) {
203206
203864
  await switchTo(ns);
203207
- } else if (command.name === "/connect") {
203208
- chrome.showToast("No connected providers found. Run /provider to configure one first.");
203209
203865
  }
203210
203866
  input.focus();
203211
203867
  })());
@@ -203215,7 +203871,8 @@ ${formatThemeList(getThemeId())}`);
203215
203871
  return;
203216
203872
  }
203217
203873
  if (line.startsWith("/")) {
203218
- io.onSystem?.(describeUnavailableCommand(line, "agent") ?? `Unknown command: ${line}
203874
+ const removedHint = removedCommandHint(line);
203875
+ io.onSystem?.(describeUnavailableCommand(line, "agent") ?? `Unknown command: ${line}${removedHint === undefined ? "" : `. ${removedHint}`}
203219
203876
  `);
203220
203877
  const help = renderCommandHelp("agent");
203221
203878
  addBlock({ kind: "output", fullText: help, summary: "Available commands", lineCount: help.trimEnd().split(`
@@ -203726,11 +204383,12 @@ Turn: ${chrome.isBusy() ? "running" : "idle"} \xB7 queue: ${turnLoop.queue.lengt
203726
204383
  }
203727
204384
  return await captureShellOutput(() => runLine(line, "operator", TG_SOURCE));
203728
204385
  };
204386
+ const shellIsBusy = () => chrome.isBusy() || foregroundOperation.isActive || forceHandoff.isAwaitingSettlement || turnLoop.queue.length > 0 || leaseView()?.held() === true;
203729
204387
  remoteBridge = new RemoteBridge({
203730
204388
  host: {
203731
204389
  sessionId: () => liveSession.summary.id,
203732
204390
  project: () => sessionCwd,
203733
- isBusy: () => chrome.isBusy() || foregroundOperation.isActive || forceHandoff.isAwaitingSettlement || turnLoop.queue.length > 0 || leaseView()?.held() === true,
204391
+ isBusy: shellIsBusy,
203734
204392
  turnRunning: () => chrome.isBusy() || foregroundOperation.isActive,
203735
204393
  sessionState: () => ({ queued: turnLoop.queue.length, mode: formatModeInForce(modeForTurn(true)) }),
203736
204394
  runLine: (text) => remoteQueue.runTelegramLine(text),
@@ -203746,6 +204404,7 @@ Turn: ${chrome.isBusy() ? "running" : "idle"} \xB7 queue: ${turnLoop.queue.lengt
203746
204404
  } catch {}
203747
204405
  },
203748
204406
  recordOff: () => {
204407
+ remoteDeviceLogin?.abort();
203749
204408
  if (!sessionLease.canPersist())
203750
204409
  return;
203751
204410
  try {
@@ -203769,14 +204428,25 @@ Turn: ${chrome.isBusy() ? "running" : "idle"} \xB7 queue: ${turnLoop.queue.lengt
203769
204428
  return decision === "deferred" || decision === "not-a-command" ? BUSY_REASON : undefined;
203770
204429
  },
203771
204430
  listProviders: async () => {
203772
- const detected = opts.redetect !== undefined ? await opts.redetect() : opts.detected;
203773
- const connected = await filterConnectedDetectedProviders(detected, {});
204431
+ const { connected } = await connectedProviders.get();
203774
204432
  return connected.map((prov) => ({
203775
204433
  id: prov.name,
203776
204434
  label: prov.label ?? prov.name,
203777
204435
  ...prov.name === currentSel.provider ? { current: true } : {}
203778
204436
  }));
203779
204437
  },
204438
+ listAddableProviders: async () => {
204439
+ const { detected, connected } = await connectedProviders.get();
204440
+ const connectedNames = new Set(connected.map((prov) => prov.name));
204441
+ return detected.filter((prov) => prov.name !== "fake").map((prov) => ({
204442
+ id: prov.name,
204443
+ label: prov.label ?? prov.name,
204444
+ connected: connectedNames.has(prov.name),
204445
+ deviceLogin: catalogAllows(prov.name, "device-code")
204446
+ }));
204447
+ },
204448
+ testProvider: (providerId) => testProviderForRemote(providerId, { probe: (id) => probeProviderConnection(id, { requireCredential: true }) }),
204449
+ connectWithDeviceLogin: (providerId, notify) => (remoteDeviceLogin ??= createRemoteDeviceLogin({ isBusy: shellIsBusy, onConnected: () => connectedProviders.invalidate() })).connect(providerId, notify),
203780
204450
  listModels: async (providerId) => {
203781
204451
  const detected = opts.redetect !== undefined ? await opts.redetect() : opts.detected;
203782
204452
  const name = providerId ?? currentSel.provider;
@@ -203876,7 +204546,7 @@ Turn: ${chrome.isBusy() ? "running" : "idle"} \xB7 queue: ${turnLoop.queue.lengt
203876
204546
  `);
203877
204547
  }
203878
204548
  }
203879
- var SESSION_PREVIEW_MESSAGE_COUNT = 200, SIDE_WORKER_DENIED_TOOL_NAMES, CUSTOM_PROVIDER_ADD_ID = "__add_custom_provider__", CUSTOM_ADD_ENTRY, REASONING_PREVIEW_THROTTLE_MS = 100, OPENAI_CACHED_INPUT_DISCOUNT = 0.5, ANTHROPIC_CACHE_READ_MULTIPLIER = 0.1, ANTHROPIC_CACHE_WRITE_MULTIPLIER = 1.25, MODE_PICKER_DESCRIPTIONS;
204549
+ var SESSION_PREVIEW_MESSAGE_COUNT = 200, SIDE_WORKER_DENIED_TOOL_NAMES, CUSTOM_PROVIDER_ADD_ID = "__add_custom_provider__", CUSTOM_ADD_ENTRY, REASONING_PREVIEW_THROTTLE_MS = 100, OPENAI_CACHED_INPUT_DISCOUNT = 0.5, ANTHROPIC_CACHE_READ_MULTIPLIER = 0.1, ANTHROPIC_CACHE_WRITE_MULTIPLIER = 1.25, PROVIDERS_TABS, PROVIDERS_FOOTERS, NO_KEY_ENTERED_TEXT = "no key entered", MODE_PICKER_DESCRIPTIONS;
203880
204550
  var init_tui_shell = __esm(() => {
203881
204551
  init_shell_startup_trace();
203882
204552
  init_service5();
@@ -203925,6 +204595,7 @@ var init_tui_shell = __esm(() => {
203925
204595
  init_workspace_inspector();
203926
204596
  init_review_inspector();
203927
204597
  init_routing_inspector();
204598
+ init_remote_provider_hooks();
203928
204599
  init_model_profile();
203929
204600
  init_task_cost();
203930
204601
  init_ci_triage_inspector();
@@ -204057,6 +204728,23 @@ var init_tui_shell = __esm(() => {
204057
204728
  label: "\uFF0B Add custom provider (OpenAI-compatible)",
204058
204729
  note: "name \xB7 URL \xB7 key \xB7 models \u2192 saved to llm-providers.json"
204059
204730
  };
204731
+ PROVIDERS_TABS = [
204732
+ { id: "connected", label: "Connected" },
204733
+ { id: "add", label: "Add provider" }
204734
+ ];
204735
+ PROVIDERS_FOOTERS = {
204736
+ connected: [
204737
+ { key: "\u2191/\u2193", label: "row" },
204738
+ { key: "\u2190/\u2192", label: "action" },
204739
+ { key: "Enter", label: "run" },
204740
+ { key: "a", label: "add" }
204741
+ ],
204742
+ add: [
204743
+ { key: "\u2191/\u2193", label: "row" },
204744
+ { key: "\u2190/\u2192", label: "Connect/Test" },
204745
+ { key: "Enter", label: "run" }
204746
+ ]
204747
+ };
204060
204748
  MODE_PICKER_DESCRIPTIONS = {
204061
204749
  ask: "Every mutating action asks first (today's default)",
204062
204750
  trust: "Auto-approves safe actions; still asks for destructive/credential ones",
@@ -205494,28 +206182,6 @@ Kept the current session.
205494
206182
  await selectAndApply({ onlyProvider: providerName });
205495
206183
  continue;
205496
206184
  }
205497
- if (command === "/provider") {
205498
- if (argument.length > 0) {
205499
- if (argument === "openai-codex") {
205500
- if (deps.selectProviderModel === undefined) {
205501
- system("Interactive subscription selection is not available. Run `keryx auth login openai-codex` and select an authorized model.\n");
205502
- } else {
205503
- await selectAndApply({ onlyProvider: "openai-codex" });
205504
- }
205505
- continue;
205506
- }
205507
- providerName = argument;
205508
- provider = makeActive();
205509
- continue;
205510
- }
205511
- if (deps.selectProviderModel === undefined) {
205512
- system(`Interactive provider selection is not available in this session.
205513
- `);
205514
- continue;
205515
- }
205516
- await selectAndApply({});
205517
- continue;
205518
- }
205519
206185
  if (command === "/connect") {
205520
206186
  if (deps.selectProviderModel === undefined) {
205521
206187
  system(CONNECT_GUIDANCE);
@@ -205529,7 +206195,7 @@ Kept the current session.
205529
206195
  system(wrongMode);
205530
206196
  continue;
205531
206197
  }
205532
- system(`Unknown command: ${command}. Type /help for commands.
206198
+ system(`Unknown command: ${command}. ${removedCommandHint(command) ?? "Type /help for commands."}
205533
206199
  `);
205534
206200
  continue;
205535
206201
  }
@@ -207097,7 +207763,7 @@ ${formatThemeList(getThemeId())}`);
207097
207763
  const rows = buildSettingsRows(await loadSettingsSnapshot(sessionCwd, { permissionMode, plan: readOnly, reasoningOverride: reasoningSessionOverride }, configDir));
207098
207764
  agentIo.onSystem?.(formatSettingsTable(rows, { available: new Set(READLINE_AGENT_COMMANDS) }));
207099
207765
  } else {
207100
- agentIo.onSystem?.(describeUnavailableCommand(command, "agent") ?? `Unknown command: ${command}. Type /help.
207766
+ agentIo.onSystem?.(describeUnavailableCommand(command, "agent") ?? `Unknown command: ${command}. ${removedCommandHint(command) ?? "Type /help."}
207101
207767
  `);
207102
207768
  }
207103
207769
  printPromptWithBusNotice();
@@ -208063,7 +208729,7 @@ var init_shell = __esm(() => {
208063
208729
  " export ANTHROPIC_API_KEY=<key>",
208064
208730
  "",
208065
208731
  "keryx reads ANTHROPIC_API_KEY from the environment only \u2014 it never stores,",
208066
- "logs, or echoes the key. Then run /provider to pick the anthropic provider.",
208732
+ "logs, or echoes the key. Then run /connect to pick the anthropic provider.",
208067
208733
  ""
208068
208734
  ].join(`
208069
208735
  `);