@tiens.nguyen/gu-cli 1.0.876 → 1.0.878

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/gu-repl.mjs +186 -65
  2. package/package.json +1 -1
package/gu-repl.mjs CHANGED
@@ -547,6 +547,10 @@ const waitPaint = (n, s) => (REPL_COLOR ? color256(n, s) : white(s));
547
547
  // cyan only with `gu --color`.
548
548
  const thoughtPaint = (s) => (REPL_COLOR ? accent(s) : white(s));
549
549
  const orange = (s) => color256(208, s); // peak-token label (task #84)
550
+ // A menu's ACTION rows (pick another model…, API keys…, add a provider…) in orange, apart from the
551
+ // models above them (#248). \x1b[22m first: an unselected picker row is wrapped in dim, which would
552
+ // turn the orange grey. Plain text when colour is off (NO_COLOR, TERM=dumb).
553
+ const actionRow = (s) => (dim("x") === "x" ? s : `\x1b[22m${orange(s)}`);
550
554
  // Compact token count: 950 → "950", 12300 → "12.3k", 1500000 → "1.5M" (task #83).
551
555
  const fmtTokens = (n) => {
552
556
  if (n < 1000) return String(n);
@@ -564,6 +568,10 @@ const fmtTokens = (n) => {
564
568
  * naming it here would imply the picker is something you have to break out of.
565
569
  */
566
570
  const PICK_HINT = "↑/↓ then Enter · Esc to cancel";
571
+ // #249: a list whose last rows are orange actions says Esc UNDER them (pickFromList's footer);
572
+ // its header keeps only the arrows, so the instruction is not given twice.
573
+ const PICK_ARROWS = "↑/↓ then Enter";
574
+ const ESC_FOOTER = "Esc to cancel";
567
575
  const PICK_MANY_HINT = "↑/↓ · space toggles · ctrl+a all · Enter saves · Esc cancels";
568
576
  // How many EXTRA models one server offers alongside its default. Task #245 made it a rule, not a
569
577
  // readability cap: a server URL offers AT MOST 3 models, default included (MAX_MODELS_PER_SERVER
@@ -6318,7 +6326,7 @@ async function manageCodingProviders({ back = false } = {}) {
6318
6326
  `${p.model || dim("(no model set)")}${dim(` · ${p.host}`)}${p.enabled ? "" : dim(" · off")}`
6319
6327
  );
6320
6328
  const ADD = labels.length;
6321
- labels.push("add another provider — Claude API, OpenAI, Kimi…");
6329
+ labels.push(actionRow("add another provider — Claude API, OpenAI, Kimi…"));
6322
6330
  console.log(dim(` Providers — pick one to change it, or add another (${PICK_HINT}):`));
6323
6331
  const idx = await pickFromList(labels, selAt, { back });
6324
6332
  if (idx === PICK_BACK) return BACK;
@@ -6355,11 +6363,12 @@ async function manageCodingProviders({ back = false } = {}) {
6355
6363
  }
6356
6364
  }
6357
6365
 
6358
- /** Point a provider at a different model. */
6359
- async function changeProviderModel(ps, rows, p) {
6360
- const md = await import("./model-doctor.mjs");
6366
+ /**
6367
+ * What provider `p` offers, asked with ITS OWN key (#266) — its problems printed as it goes.
6368
+ * Shared by "change the model…" and /model's "pick another model on <host>…" (#248). → [ids]
6369
+ */
6370
+ async function listProviderModels(ps, p) {
6361
6371
  const { proxyFetch } = await import("./proxy-dispatcher.mjs");
6362
-
6363
6372
  // LISTED WITH THIS PROVIDER'S OWN KEY (#266). This used to probe with NO key, on purpose: the
6364
6373
  // only key the terminal could fetch was the ACCOUNT DEFAULT, which may belong to another provider.
6365
6374
  // Since #245 a key belongs to its server, so the key used is the one bound to THIS row — the same
@@ -6407,6 +6416,13 @@ async function changeProviderModel(ps, rows, p) {
6407
6416
  }
6408
6417
  }
6409
6418
 
6419
+ return models;
6420
+ }
6421
+
6422
+ /** Point a provider at a different model. */
6423
+ async function changeProviderModel(ps, rows, p) {
6424
+ const md = await import("./model-doctor.mjs");
6425
+ const models = await listProviderModels(ps, p);
6410
6426
  let model = "";
6411
6427
  if (models.length) {
6412
6428
  model = await chooseCodingModelFrom(ps, models, `Which model should ${p.host} use?`, p.model, { back: true });
@@ -7118,29 +7134,48 @@ async function chooseModel() {
7118
7134
  // OpenAI-compatible providers are both "OpenAI-compatible", so the kind names neither.
7119
7135
  const { labels, tags, showTag } = codingChoiceLabels(choices, def);
7120
7136
  const activeIdx = choices.findIndex((c) => c.id === active);
7137
+ // THE CURRENT CODER'S KEYS, ON THIS LIST (#248) — the manager ctrl+k opens, for a provider that
7138
+ // takes a key (OpenAI-compatible, ollama.com). Before the add row, so no model row moves.
7139
+ const activeChoice = choices[activeIdx >= 0 ? activeIdx : choices.findIndex((c) => c.id === def)];
7140
+ const activeUrl = String(activeChoice?.url ?? "");
7141
+ const keyHost = activeChoice && ((activeChoice.kind ?? "") === "openai" || /(^|\.)ollama\.com$/i.test(hostOf(activeUrl))) ? hostOf(activeUrl) : "";
7142
+ // …and the rest of that server's models, the same row /agent-model has (#248). Not for local MLX.
7143
+ const modelsHost = activeChoice && ["openai", "ollama"].includes(activeChoice.kind ?? "") && activeUrl ? hostOf(activeUrl) : "";
7144
+ const MODELS = modelsHost ? labels.length : -1;
7145
+ if (modelsHost) labels.push(actionRow(`pick another model on ${modelsHost}…`));
7146
+ const KEYS = keyHost ? labels.length : -1;
7147
+ if (keyHost) labels.push(actionRow(`API keys for ${keyHost} — switch, add or remove…`));
7121
7148
  // Task #223. Appended LAST, after the models: a new row must never shift an index someone
7122
7149
  // already has muscle memory for.
7123
7150
  //
7124
7151
  // "add or change" rather than "add", because add-only was a one-way door: a model id typed
7125
7152
  // wrong could not be corrected from anywhere, here or in web Settings. The entry leads to the
7126
7153
  // add flow directly when there is nothing to change, so it costs a word and no extra step.
7127
- labels.push(
7154
+ labels.push(actionRow(
7128
7155
  choices.some((c) => (c.kind ?? "") === "openai")
7129
7156
  ? "add or change a provider — Claude API, OpenAI, Kimi…"
7130
7157
  : "add a provider — Claude API, OpenAI, Kimi…"
7131
- );
7158
+ ));
7132
7159
  // THREE MODELS ARE IN PLAY AND THIS COMMAND ONLY CHANGES ONE (task #169 req 5). "/model" has
7133
7160
  // meant the CODING model since task #44, but "agent model" and "agent coding model" are close
7134
7161
  // enough in name to have already been mixed up. The fix is to say which one THIS is — not to
7135
7162
  // print the other two here: the agent model and the embedder are independent settings with
7136
7163
  // their own commands, and listing them together implies a relationship that does not exist.
7137
- console.log(dim(` Choose the CODING model — the one that writes code (${PICK_HINT}):`));
7138
- const idx = await pickFromList(labels, activeIdx >= 0 ? activeIdx : 0);
7164
+ console.log(dim(` Choose the CODING model — the one that writes code (${PICK_ARROWS}):`));
7165
+ const idx = await pickFromList(labels, activeIdx >= 0 ? activeIdx : 0, { footer: ESC_FOOTER });
7139
7166
  if (idx < 0) {
7140
7167
  console.log(dim(" (cancelled)\n"));
7141
7168
  return;
7142
7169
  }
7143
- // The one appended entry sits past the last model.
7170
+ if (idx === MODELS) {
7171
+ if ((await pickModelOnCoderServer(activeChoice, def)) === BACK) return chooseModel();
7172
+ return;
7173
+ }
7174
+ if (idx === KEYS) {
7175
+ if ((await replaceCodingKey({ back: true })) === BACK) return chooseModel();
7176
+ return;
7177
+ }
7178
+ // The add entry sits past the last model (and the keys row).
7144
7179
  if (idx >= choices.length) {
7145
7180
  if ((await manageCodingProviders({ back: true })) === BACK) return chooseModel();
7146
7181
  return;
@@ -7148,6 +7183,62 @@ async function chooseModel() {
7148
7183
  await applyChosenCoder(choices[idx], { def, tag: showTag ? tags[idx] : "" });
7149
7184
  }
7150
7185
 
7186
+ /**
7187
+ * /model's "pick another model on <host>…" (#248): the current coder's server, asked for everything
7188
+ * it has (with its own key). The pick is ADDED to that provider's models — its main model stays —
7189
+ * and becomes the coder. `← back` returns BACK to /model.
7190
+ */
7191
+ async function pickModelOnCoderServer(active, def) {
7192
+ const ps = await import("./coding-provider-setup.mjs");
7193
+ const settings = await withPendingStatus("reading your providers", fetchWorkerSettings);
7194
+ if (!settings) {
7195
+ console.log(red(" couldn't reach the API to read your providers.\n"));
7196
+ return;
7197
+ }
7198
+ const rows = ps.existingBackendRows(settings);
7199
+ const same = (u) => ps.ensureV1Root(String(u ?? "")).toLowerCase();
7200
+ const p = ps.manageableProviders(rows).find((x) => same(x.url) === same(active.url));
7201
+ // No saved row behind this coder (an account default from elsewhere): the provider manager.
7202
+ if (!p) return manageCodingProviders({ back: true });
7203
+ const models = await listProviderModels(ps, p);
7204
+ let model;
7205
+ if (models.length) {
7206
+ model = await chooseCodingModelFrom(ps, models, `Which model on ${p.host}?`, active.model, { back: true });
7207
+ if (model === BACK) return BACK;
7208
+ } else {
7209
+ // Not listed (no key, refused, down) — the lines above said so: the name is typed instead.
7210
+ model = (await ask(" model name (Enter to cancel): ")).trim() || null;
7211
+ }
7212
+ if (model === null) {
7213
+ console.log(dim(" (nothing changed)\n"));
7214
+ return;
7215
+ }
7216
+ const row = rows.find((b) => (b.id ?? b.kind) === p.id);
7217
+ const extras = Array.isArray(row?.models) ? row.models : [];
7218
+ if (model !== p.model && !extras.includes(model)) {
7219
+ const md = await import("./model-doctor.mjs");
7220
+ const saved = await withPendingStatus("saving", () =>
7221
+ md.saveWorkerSettings(apiBase, workerKey, {
7222
+ agentCodingBackends: rows.map((b) => ((b.id ?? b.kind) === p.id ? { ...b, models: [...extras, model] } : b)),
7223
+ })
7224
+ );
7225
+ if (!saved) {
7226
+ console.log(red(" couldn't save that — nothing changed.\n"));
7227
+ return;
7228
+ }
7229
+ }
7230
+ // The choice as the API now lists it — its id is the API's to make, not this side's.
7231
+ const probe = await withPendingStatus("loading models", () => fetchAgentPayload([{ role: "user", content: "ping" }])).catch(() => null);
7232
+ const chosen = (Array.isArray(probe?.codingChoices) ? probe.codingChoices : []).find(
7233
+ (c) => c.model === model && same(c.url) === same(p.url)
7234
+ );
7235
+ if (!chosen) {
7236
+ console.log(accent(` ✓ ${model} added to ${p.host}`) + dim(" · pick it in /model\n"));
7237
+ return;
7238
+ }
7239
+ await applyChosenCoder(chosen, { def: String(probe?.codingDefault ?? def ?? "").trim() });
7240
+ }
7241
+
7151
7242
  /**
7152
7243
  * Make `chosen` ({ id, model, kind, url }) the coding model: session state, saved choice, the key
7153
7244
  * check and the lifetime token total. The tail of /model, extracted so /quick-models' ← / → (#233)
@@ -8746,25 +8837,6 @@ async function confirmEmbedSwitch(ec, current, chosen) {
8746
8837
  return { changed: true, plan };
8747
8838
  }
8748
8839
 
8749
- // WHICH EMBEDDER, ASKED ONCE. The model sub-flow of /embed-model: the curated picker, then the
8750
- // shared recognition step. Returns { changed, chosen, plan }.
8751
- async function pickEmbedModel(ec, current, { back = false } = {}) {
8752
- const rows = ec.embedChoiceRows(current);
8753
- console.log(dim(` Choose the embedder (${PICK_HINT}):`));
8754
- const labels = rows.map(
8755
- (r) => `${r.model}${r.dims ? dim(` · ${r.dims}d`) : ""}${dim(` · ${r.note}`)}` + (r.current ? accent(" (current)") : "")
8756
- );
8757
- const idx = await pickFromList(labels, Math.max(0, rows.findIndex((r) => r.current)), { back });
8758
- if (idx === PICK_BACK) return BACK;
8759
- if (idx < 0) {
8760
- console.log(dim(" (cancelled)\n"));
8761
- return { changed: false, chosen: "", plan: null };
8762
- }
8763
- const chosen = rows[idx].model;
8764
- const sw = await confirmEmbedSwitch(ec, current, chosen);
8765
- return { changed: sw.changed, chosen: sw.changed ? chosen : "", plan: sw.plan };
8766
- }
8767
-
8768
8840
  // WHAT A SAVED CHANGE SOUNDS LIKE. Success lines used to live at the end of one long function;
8769
8841
  // four exit paths now say them, so they are said once here: the embedder line, the rebuild note
8770
8842
  // that only a REAL model change earns, and the pull hint — Ollama serves the embedder itself, so
@@ -8791,31 +8863,57 @@ async function chooseEmbedModel() {
8791
8863
  const current = ec.currentEmbedModel(settings);
8792
8864
  const currentUrl = String(settings.ragEmbedUrl || "").trim() || "http://127.0.0.1:11434";
8793
8865
  const host = hostOf(currentUrl);
8794
- // WHEN TO SHOW THE KEY ROW (embedMenuOptions' keyRequired): a key is clearly the question when
8866
+ // WHEN TO SHOW THE KEY ROW (keyRequired): a key is clearly the question when
8795
8867
  // the server is ollama.com (it rejects anonymous calls) or already looks OpenAI-compatible (the
8796
8868
  // /v1 shape), or when one is already bound — replacing the key people can no longer read is the
8797
8869
  // very thing they come back for. A keyless localhost Ollama sees three rows instead.
8798
8870
  const keyRequired = isOllamaCloudUrl(currentUrl) || /\/v1\/?$/i.test(currentUrl) ||
8799
8871
  Boolean(String(settings.ragEmbedKeyId ?? "").trim());
8800
- const menu = ec.embedMenuOptions({ current, host, keyRequired });
8801
- console.log(dim(` Embedding model (currently ${current} on ${host || currentUrl})`));
8802
- const mi = await pickFromList(menu.map((o) => o.label), 0);
8803
- const action = mi >= 0 ? menu[mi].action : "cancel";
8804
- if (action === "cancel") {
8872
+ // ONE LIST, THE SHAPE OF /model AND /agent-model (#248): the embedders, the current one marked,
8873
+ // then this server's own rows in orange — its other models, its keys — and add a provider. It was
8874
+ // a three-row menu first ("switch the embedder model…", "use another provider…", the key), one
8875
+ // step more than the other two commands.
8876
+ // "(current)" only for an embedder that was SET — nothing set means the default, which its own
8877
+ // note already says. Right after the name: at the end of the row an 80-column clip cut it off.
8878
+ const isSet = Boolean(String(settings.ragEmbedModel ?? "").trim());
8879
+ const rows = ec.embedChoiceRows(isSet ? current : "");
8880
+ const labels = rows.map(
8881
+ (r) => `${r.model}${r.current ? accent(" (current)") : ""}${r.dims ? dim(` · ${r.dims}d`) : ""}${dim(` · ${r.note}`)}`
8882
+ );
8883
+ const MODELS = labels.length;
8884
+ labels.push(actionRow(`pick another model on ${host || currentUrl}…`));
8885
+ const KEYS = keyRequired ? labels.length : -1;
8886
+ if (keyRequired) labels.push(actionRow(`API keys for ${host || currentUrl} — switch, add or remove…`));
8887
+ const ADD = labels.length;
8888
+ labels.push(actionRow("add a provider — OpenAI, Ollama…"));
8889
+ const head = ` Embedding model (now ${current} on ${host || currentUrl})`;
8890
+ const hint = `(${PICK_ARROWS}):`;
8891
+ const cols = Math.max(20, process.stdout.columns || 80);
8892
+ console.log(dim(head.length + 2 + hint.length < cols ? `${head} ${hint}` : `${head}\n ${hint}`));
8893
+ // The cursor starts on the one in use: the set one, else the default.
8894
+ const inUse = rows.findIndex((r) => ec.sameEmbedModel(r.model, current));
8895
+ const mi = await pickFromList(labels, Math.max(0, inUse), { footer: ESC_FOOTER });
8896
+ if (mi < 0) {
8805
8897
  console.log(dim(" (cancelled)\n"));
8806
8898
  return;
8807
8899
  }
8900
+ const action = mi === MODELS ? "server" : mi === KEYS ? "key" : mi === ADD ? "provider" : "model";
8808
8901
 
8809
8902
  // EVERY ARM IS THE ONE ADD-SERVER SCREEN (task #245) — the same URL → key → models → save as
8810
- // /model and /agent-model. The curated arm has already chosen the model (and passed the
8903
+ // /model and /agent-model. A listed embedder has been chosen already (and passed the
8811
8904
  // recognition wall), so it only asks WHERE; the other two list what the server has.
8812
- // "← back" in any arm returns to this menu (#242).
8905
+ // "← back" in any arm returns to this list (#242).
8813
8906
  if (action === "model") {
8814
- const picked = await pickEmbedModel(ec, current, { back: true });
8815
- if (picked === BACK) return chooseEmbedModel();
8816
- const { changed, chosen, plan } = picked;
8817
- if (!changed) return;
8818
- await addServer({ role: "embed", dialect: "ollama", model: chosen, plan });
8907
+ const chosen = rows[mi].model;
8908
+ const sw = await confirmEmbedSwitch(ec, current, chosen);
8909
+ if (!sw.changed) return;
8910
+ await addServer({ role: "embed", dialect: "ollama", model: chosen, plan: sw.plan });
8911
+ return;
8912
+ }
8913
+ if (action === "server") {
8914
+ // That server's own list, with its key — the add-server screen with the URL known.
8915
+ const dialect = /\/v1\/?$/i.test(currentUrl) ? "openai" : "ollama";
8916
+ if ((await addServer({ role: "embed", dialect, url: currentUrl, back: true })) === BACK) return chooseEmbedModel();
8819
8917
  return;
8820
8918
  }
8821
8919
  if (action === "provider") {
@@ -8970,24 +9068,37 @@ async function chooseAgentModel() {
8970
9068
  if (!saved.length) return agentModelOnServer();
8971
9069
  const current = am.currentAgentModel(settings);
8972
9070
  const curHost = settings.agentModelUrl ? hostOf(String(settings.agentModelUrl)) : "";
9071
+ // THE CURRENT PROVIDER'S OWN ROWS, ON THIS LIST (#248). They were one step down, behind "other
9072
+ // models, providers and API keys…" — which read like the "add a provider" row under it.
9073
+ const source = am.agentListSource(settings);
9074
+ const needsKey = am.agentSourceNeedsKey(source);
8973
9075
  for (;;) {
8974
9076
  const labels = saved.map((c) => c.label + (c.current ? accent(" (current)") : ""));
8975
- const MORE = labels.length;
8976
- labels.push("other models, providers and API keys…");
9077
+ const MODELS = curHost ? labels.length : -1;
9078
+ if (curHost) labels.push(actionRow(`pick another model on ${curHost}…`));
9079
+ const KEYS = curHost && needsKey ? labels.length : -1;
9080
+ if (KEYS >= 0) labels.push(actionRow(`API keys for ${curHost} — switch, add or remove…`));
8977
9081
  const ADD = labels.length;
8978
- labels.push("add a provider — Claude API, OpenAI, Kimi, Ollama…");
9082
+ labels.push(actionRow("add a provider — Claude API, OpenAI, Kimi, Ollama…"));
8979
9083
  // The hint drops to its own row when the header would wrap (80 columns over SSH).
8980
9084
  const head = ` Agent model — the one that TALKS (now ${current || "unset"}${curHost ? ` on ${curHost}` : ""})`;
8981
- const hint = `(${PICK_HINT}):`;
9085
+ const hint = `(${PICK_ARROWS}):`;
8982
9086
  const cols = Math.max(20, process.stdout.columns || 80);
8983
9087
  console.log(dim(head.length + 2 + hint.length < cols ? `${head} ${hint}` : `${head}\n ${hint}`));
8984
- const idx = await pickFromList(labels, Math.max(0, saved.findIndex((c) => c.current)));
9088
+ const idx = await pickFromList(labels, Math.max(0, saved.findIndex((c) => c.current)), { footer: ESC_FOOTER });
8985
9089
  if (idx < 0) {
8986
9090
  console.log(dim(" (cancelled)\n"));
8987
9091
  return;
8988
9092
  }
8989
- if (idx === MORE) {
8990
- if ((await agentModelOnServer({ back: true })) === BACK) continue;
9093
+ if (idx === MODELS) {
9094
+ // Straight to that server's full list; when it can't be listed, its recovery menu instead.
9095
+ if ((await agentModelOnServer({ back: true, start: "models" })) === BACK) continue;
9096
+ return;
9097
+ }
9098
+ if (idx === KEYS) {
9099
+ const rowId = String(source.row?.id ?? "").trim() || String(source.row?.kind ?? "").trim();
9100
+ if (!rowId) return enterAgentProviderKey(source.row, curHost);
9101
+ if ((await manageServerKeys({ role: "agent", backendId: rowId, host: curHost, model: current, back: true })) === BACK) continue;
8991
9102
  return;
8992
9103
  }
8993
9104
  if (idx === ADD) {
@@ -9025,10 +9136,12 @@ async function useSavedAgentChoice(c) {
9025
9136
 
9026
9137
  /**
9027
9138
  * One server's view: its full model list, other providers, its keys — what /agent-model was before
9028
- * #246, now behind "other models, providers and API keys…" when something is already set up.
9029
- * `back`: opened from that list, so its first menu offers "← back".
9139
+ * #246. With models already set up, /agent-model's list opens it as "pick another model on <host>…"
9140
+ * (#248): `start: "models"` skips the what-to-change menu and goes to the model list.
9141
+ * `back`: opened from that list, so its first screen offers "← back" and returns BACK.
9030
9142
  */
9031
- async function agentModelOnServer({ back = false } = {}) {
9143
+ async function agentModelOnServer({ back = false, start = "menu" } = {}) {
9144
+ const again = () => agentModelOnServer({ back, start });
9032
9145
  const am = await import("./agent-model-command.mjs");
9033
9146
  const settings = await withPendingStatus("reading your agent model", fetchWorkerSettings);
9034
9147
  if (!settings) {
@@ -9113,7 +9226,7 @@ async function agentModelOnServer({ back = false } = {}) {
9113
9226
  if (action === "key") await enterAgentProviderKey(source.row, listHost);
9114
9227
  else if (action === "provider") {
9115
9228
  // "← back" on "Which provider?" returns to this menu (#247).
9116
- if ((await addServer({ role: "agent", dialect: "openai", back: true })) === BACK) return agentModelOnServer({ back });
9229
+ if ((await addServer({ role: "agent", dialect: "openai", back: true })) === BACK) return again();
9117
9230
  } else console.log("");
9118
9231
  return;
9119
9232
  }
@@ -9121,18 +9234,21 @@ async function agentModelOnServer({ back = false } = {}) {
9121
9234
  // WHAT TO CHANGE, FIRST (#44). The ways to use an Ollama server or another provider used to sit
9122
9235
  // after every model on this one — 130 rows down on api.openai.com — which read as "OpenAI models
9123
9236
  // are all you can choose". A short menu of what to change comes before any list.
9124
- console.log(dim(` Agent model — the one that TALKS: ${current || "unset"} on ${listHost}`));
9125
- const menu = am.agentMenuOptions({ host: listHost, modelCount: models.length, needsKey });
9126
- const first = await pickFromList(menu.map((o) => o.label), 0, { back });
9127
- if (first === PICK_BACK) return BACK;
9128
- const firstAction = first >= 0 ? menu[first].action : "cancel";
9237
+ let firstAction = "models";
9238
+ if (start !== "models") {
9239
+ console.log(dim(` Agent model — the one that TALKS: ${current || "unset"} on ${listHost}`));
9240
+ const menu = am.agentMenuOptions({ host: listHost, modelCount: models.length, needsKey });
9241
+ const first = await pickFromList(menu.map((o) => o.label), 0, { back });
9242
+ if (first === PICK_BACK) return BACK;
9243
+ firstAction = first >= 0 ? menu[first].action : "cancel";
9244
+ }
9129
9245
  if (firstAction === "cancel") {
9130
9246
  console.log(dim(" (cancelled)\n"));
9131
9247
  return;
9132
9248
  }
9133
9249
  // "← back" in any of these returns to this menu (#242) — read again, as on a first open.
9134
9250
  if (firstAction === "provider") {
9135
- if ((await chooseAgentProvider({ back: true })) === BACK) return agentModelOnServer({ back });
9251
+ if ((await chooseAgentProvider({ back: true })) === BACK) return again();
9136
9252
  return;
9137
9253
  }
9138
9254
  if (firstAction === "key") {
@@ -9140,7 +9256,7 @@ async function agentModelOnServer({ back = false } = {}) {
9140
9256
  const rowId = String(source.row?.id ?? "").trim() || String(source.row?.kind ?? "").trim();
9141
9257
  if (!rowId) return enterAgentProviderKey(source.row, listHost);
9142
9258
  const r = await manageServerKeys({ role: "agent", backendId: rowId, host: listHost, model: am.currentAgentModel(settings), back: true });
9143
- if (r === BACK) return agentModelOnServer({ back });
9259
+ if (r === BACK) return again();
9144
9260
  return;
9145
9261
  }
9146
9262
 
@@ -9173,7 +9289,8 @@ async function agentModelOnServer({ back = false } = {}) {
9173
9289
  // No other entries here any more (#44): the ways to change the provider are on the menu above,
9174
9290
  // where they can be seen, rather than after every model.
9175
9291
  const idx = await pickFromList(labels, Math.max(0, rows.findIndex((r) => r.current)), { back: true });
9176
- if (idx === PICK_BACK) return agentModelOnServer({ back });
9292
+ // Opened as "pick another model on <host>…", back is /agent-model's list; else this menu.
9293
+ if (idx === PICK_BACK) return start === "models" ? BACK : again();
9177
9294
  if (idx < 0) {
9178
9295
  console.log(dim(" (cancelled)\n"));
9179
9296
  return;
@@ -10356,7 +10473,7 @@ async function promptNewCodingKey(backendId = "", provider = "") {
10356
10473
  return id;
10357
10474
  }
10358
10475
 
10359
- async function replaceCodingKey() {
10476
+ async function replaceCodingKey({ back = false } = {}) {
10360
10477
  const ks = await withPendingStatus("checking the coding model", () =>
10361
10478
  coderKeyState(sessionCodingModel)
10362
10479
  );
@@ -10391,8 +10508,7 @@ async function replaceCodingKey() {
10391
10508
  const keys = allKeys ? ps.keysForBackend(allKeys, ks.backendId) : null;
10392
10509
  if (keys && keys.length >= 1) {
10393
10510
  // ONE SERVER'S KEYS (task #245): the same picker /agent-model and /embed-model open.
10394
- await manageServerKeys({ role: "coding", backendId: ks.backendId, host: ks.provider || ks.backendId || "this server", model: ks.model });
10395
- return;
10511
+ return manageServerKeys({ role: "coding", backendId: ks.backendId, host: ks.provider || ks.backendId || "this server", model: ks.model, back });
10396
10512
  }
10397
10513
 
10398
10514
  // NO KEYS YET, BUT AN API THAT UNDERSTANDS NAMES — so ask for one.
@@ -11133,6 +11249,8 @@ let listChecked = new Set();
11133
11249
  // arrive after the choosing is over.
11134
11250
  let listMax = 0;
11135
11251
  let listNote = "";
11252
+ /** #249: a dim line under the rows (pickFromList's `footer`), drawn and erased with the block. */
11253
+ let listFooter = "";
11136
11254
  // When true, ctrl+d in a single-select picker finishes with a DELETE request for the
11137
11255
  // highlighted row instead of choosing it (task #222). Off unless a caller asks for it, so no
11138
11256
  // existing picker gains a destructive key by accident.
@@ -11162,7 +11280,7 @@ function drawList(redraw) {
11162
11280
  // provider's /v1/models was taller than the terminal: the screen scrolled, the highlighted row
11163
11281
  // went off the top, and the ESC[nA walk below then counted rows that had moved — "cannot scroll
11164
11282
  // up or down, stuck here". Two rows of margin keep the block clear of the line above it.
11165
- const maxBlock = Math.max(5, viewportHeight() - 2) - (listNote ? 1 : 0) - (listHeader ? listHeaderRows : 0);
11283
+ const maxBlock = Math.max(5, viewportHeight() - 2) - (listNote ? 1 : 0) - (listFooter ? 1 : 0) - (listHeader ? listHeaderRows : 0);
11166
11284
  const win = listWindow(listItems.length, listSel, listTop, maxBlock);
11167
11285
  listTop = win.top;
11168
11286
  const row = (i) => {
@@ -11190,6 +11308,7 @@ function drawList(redraw) {
11190
11308
  // The cap's message rides UNDER the list, in the same block, so it is erased and redrawn with
11191
11309
  // it — a note printed into the scrolling output would stay on screen after the list is gone.
11192
11310
  if (listNote) rows.push(clipVisible(GUTTER + yellow(listNote), width - 1));
11311
+ if (listFooter) rows.push(clipVisible(GUTTER + dim(listFooter), width - 1));
11193
11312
  // On a redraw the caret sits one row BELOW the block (each draw ends with "\n").
11194
11313
  const up = redraw ? physicalRows(listRowsDrawn, width) : 0;
11195
11314
  // #321: what the screen held under the picker when it opened, and how far it reaches — so the
@@ -11234,6 +11353,7 @@ function finishList(index) {
11234
11353
  listChecked = new Set();
11235
11354
  listMax = 0;
11236
11355
  listNote = "";
11356
+ listFooter = "";
11237
11357
  const allowDelete = listAllowDelete;
11238
11358
  const deleting = listDeleteRequested;
11239
11359
  listAllowDelete = false;
@@ -11374,6 +11494,7 @@ function pickFromList(items, initialSel = 0, opts = {}) {
11374
11494
  return;
11375
11495
  }
11376
11496
  listMulti = false; // explicit: a cancelled multi-select must not leak its mode into this one
11497
+ listFooter = typeof opts.footer === "string" ? opts.footer : "";
11377
11498
  listAllowDelete = opts.allowDelete === true;
11378
11499
  listDeleteRequested = false;
11379
11500
  listKeys = opts.keys && typeof opts.keys === "object" ? opts.keys : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.876",
3
+ "version": "1.0.878",
4
4
  "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
5
  "type": "module",
6
6
  "license": "MIT",