@tiens.nguyen/gu-cli 1.0.873 → 1.0.875

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.
@@ -350,8 +350,8 @@ export function agentRecoveryOptions({ keyProblem = false, host = "" } = {}) {
350
350
  return [
351
351
  { action: "leave", label: "leave it" },
352
352
  ...(keyProblem ? [{ action: "key", label: `enter the API key for ${host || "this provider"}…` }] : []),
353
- { action: "ollama", label: "use an Ollama server for the agent…" },
354
- { action: "provider", label: "add a provider for the agent — Claude API, OpenAI, Kimi…" },
353
+ // #244: no separate Ollama row — Ollama is one of the providers on the add screen.
354
+ { action: "provider", label: "add a provider for the agent — Claude API, OpenAI, Kimi, Ollama…" },
355
355
  ];
356
356
  }
357
357
 
@@ -375,8 +375,9 @@ export function agentMenuOptions({ host = "", modelCount = 0, needsKey = false }
375
375
  const where = host || "this server";
376
376
  return [
377
377
  { action: "models", label: `pick another model on ${where}${modelCount ? ` (${modelCount})` : ""}` },
378
- { action: "ollama", label: "use an Ollama server for the agent…" },
379
- { action: "provider", label: "use another provider — Claude API, OpenAI, Kimi…" },
378
+ // #244: "use an Ollama server for the agent…" was a second way to the same add screen, left from
379
+ // when Ollama had its own setup (#41). Ollama, ollama.com included, is one provider among the rest.
380
+ { action: "provider", label: "use another provider — Claude API, OpenAI, Kimi, Ollama…" },
380
381
  // Task #245: the SAME key manager /model's ctrl+k and /embed-model open — switch, add, make
381
382
  // default, delete — for this server's keys only.
382
383
  ...(needsKey ? [{ action: "key", label: `API keys for ${where} — switch, add or remove…` }] : []),
@@ -388,12 +389,61 @@ export function agentMenuOptions({ host = "", modelCount = 0, needsKey = false }
388
389
  * rows, current one marked — then "add a new one". Switching to a provider you already set up must
389
390
  * not mean typing its URL again. Returns [{ action: "row" | "add", row?, label }].
390
391
  */
392
+ /**
393
+ * EVERY MODEL ALREADY SET UP ON THIS MACHINE, for the agent (#246) — what /model offers the coder.
394
+ *
395
+ * Reported: /model listed several providers and models, and /agent-model showed none of them —
396
+ * it asked to add a provider or enter a key. They are the same servers with the same keys: a key
397
+ * belongs to its server, and the API finds the agent's key by matching agentModelUrl to a saved row
398
+ * (resolveKeyForRole). So a pick here saves that row's URL and the model, and its key comes along.
399
+ *
400
+ * Rows: OpenAI-compatible and Ollama, enabled, with a URL; their default model, then the extra
401
+ * models offered in /model. Embedding models are left out — as the agent they answer nonsense.
402
+ * → [{ rowId, kind, url, host, model, label, current }]
403
+ */
404
+ export function savedAgentChoices(settings) {
405
+ const rows = Array.isArray(settings?.agentCodingBackends) ? settings.agentCodingBackends : [];
406
+ const currentUrl = ensureV1Root(String(settings?.agentModelUrl ?? "")).toLowerCase();
407
+ const currentModel = currentAgentModel(settings);
408
+ const out = [];
409
+ const seen = new Set();
410
+ const takenKinds = new Set();
411
+ for (const row of rows) {
412
+ if (!row || typeof row !== "object" || row.enabled === false) continue;
413
+ const kind = String(row.kind ?? "");
414
+ if (kind !== "openai" && kind !== "ollama") continue;
415
+ const url = ensureV1Root(String(row.url ?? "").trim());
416
+ if (!url) continue;
417
+ // The API's identity rule: an id-less row is known by its kind, the first of each only.
418
+ const rowId = String(row.id ?? "").trim() || (takenKinds.has(kind) ? "" : kind);
419
+ if (!rowId) continue;
420
+ takenKinds.add(kind);
421
+ let host = url;
422
+ try {
423
+ host = new URL(url).host;
424
+ } catch {
425
+ /* keep the url */
426
+ }
427
+ const models = [String(row.defaultModel ?? "").trim(), ...(Array.isArray(row.models) ? row.models.map((m) => String(m ?? "").trim()) : [])];
428
+ for (const model of models) {
429
+ if (!model || isLikelyEmbedder({ model })) continue;
430
+ const key = `${url.toLowerCase()}\n${model}`;
431
+ if (seen.has(key)) continue;
432
+ seen.add(key);
433
+ const current = url.toLowerCase() === currentUrl && model === currentModel;
434
+ out.push({ rowId, kind, url, host, model, label: `${host}/${model}`, current });
435
+ }
436
+ }
437
+ return out;
438
+ }
439
+
391
440
  export function agentProviderChoices(settings) {
392
441
  const rows = Array.isArray(settings?.agentCodingBackends) ? settings.agentCodingBackends : [];
393
442
  const current = ensureV1Root(String(settings?.agentModelUrl ?? "")).toLowerCase();
394
443
  const out = [];
395
444
  for (const row of rows) {
396
- if (!row || row.kind !== "openai" || !row.url) continue;
445
+ // Ollama rows too (#244): ollama.com or a box saved earlier is a provider like any other.
446
+ if (!row || (row.kind !== "openai" && row.kind !== "ollama") || !row.url) continue;
397
447
  let host = String(row.url);
398
448
  try {
399
449
  host = new URL(String(row.url)).host;
@@ -403,7 +453,7 @@ export function agentProviderChoices(settings) {
403
453
  const isCurrent = ensureV1Root(String(row.url)).toLowerCase() === current;
404
454
  out.push({ action: "row", row, label: `${host}${isCurrent ? " (current)" : ""}` });
405
455
  }
406
- out.push({ action: "add", label: "add a new provider — Claude API, OpenAI, Kimi…" });
456
+ out.push({ action: "add", label: "add a new provider — Claude API, OpenAI, Kimi, Ollama…" });
407
457
  return out;
408
458
  }
409
459
 
@@ -106,8 +106,8 @@ export function embedMenuOptions({ current = DEFAULT_EMBED_MODEL, host = "", key
106
106
  const where = host || "this server";
107
107
  return [
108
108
  { action: "model", label: `switch the embedder model (currently ${current})` },
109
- { action: "ollama", label: "use an Ollama server for embedding…" },
110
- { action: "provider", label: "use another OpenAI-compatible provider…" },
109
+ // #244: one row for every server — Ollama (this machine, the network) is a choice on the add screen.
110
+ { action: "provider", label: "use another provider — OpenAI, Ollama…" },
111
111
  ...(keyRequired ? [{ action: "key", label: `enter a new API key for ${where}…` }] : []),
112
112
  ];
113
113
  }
package/gu-repl.mjs CHANGED
@@ -543,6 +543,9 @@ const color256 = (n, s) => `\x1b[38;5;${n}m${s}\x1b[0m`;
543
543
  // WAIT_COLORS_256 colour only when this run was started with `gu --color`. White goes through
544
544
  // paint(), so NO_COLOR / TERM=dumb still turn it off.
545
545
  const waitPaint = (n, s) => (REPL_COLOR ? color256(n, s) : white(s));
546
+ // The live thought line above it (#245) follows the same switch: white by default, the accent
547
+ // cyan only with `gu --color`.
548
+ const thoughtPaint = (s) => (REPL_COLOR ? accent(s) : white(s));
546
549
  const orange = (s) => color256(208, s); // peak-token label (task #84)
547
550
  // Compact token count: 950 → "950", 12300 → "12.3k", 1500000 → "1.5M" (task #83).
548
551
  const fmtTokens = (n) => {
@@ -636,7 +639,7 @@ if (cliVerdict.kind === "run") {
636
639
  " -v, --verbose show RAG index-file activity in the bottom bar (each shard written)\n" +
637
640
  " --log log this run in detail (~/.gonext/worker.log). Without it only starts,\n" +
638
641
  " stops and errors are logged. Ends when this gu closes.\n" +
639
- " --color colour the thinking spinner and its playful word (cycling colours).\n" +
642
+ " --color colour the live thought, the thinking spinner and its playful word.\n" +
640
643
  " Without it they are plain white.\n" +
641
644
  " --name <text> show <text> instead of the gu logo in the startup banner\n" +
642
645
  " (this run only; letters, digits, space - _ are drawn as art)\n" +
@@ -6642,6 +6645,8 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6642
6645
  for (;;) {
6643
6646
  // --- 1. the server --------------------------------------------------------------------
6644
6647
  target = null;
6648
+ // The dialect the URL prompt reads the address in: the caller's, or Ollama's when that row was picked.
6649
+ let askDialect = dialect;
6645
6650
  if (presetUrl) {
6646
6651
  target = as.serverTarget(dialect, presetUrl);
6647
6652
  if (!target.ok) {
@@ -6653,7 +6658,14 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6653
6658
  // the one add flow, so every role that adds a provider offers the same list (the embedder
6654
6659
  // without Claude, which serves no embeddings).
6655
6660
  const presets = ps.providerPresets(role);
6656
- const rowsShown = [...presets.map((p) => `${p.name}${dim(` · ${p.url}`)}`), "another URL…"];
6661
+ // #244: an Ollama on this machine or the network is a row here, like the hosted ones — the
6662
+ // menus no longer have a separate "use an Ollama server…" entrance. It asks for the address.
6663
+ const OLLAMA_HERE = presets.length;
6664
+ const rowsShown = [
6665
+ ...presets.map((p) => `${p.name}${dim(` · ${p.url}`)}`),
6666
+ `Ollama — this machine or your network…${dim(" · e.g. 127.0.0.1:11434")}`,
6667
+ "another URL…",
6668
+ ];
6657
6669
  // A preset names its own dialect (Ollama cloud is "ollama"); the rest are OpenAI-compatible.
6658
6670
  console.log(dim(` Which provider? (${PICK_HINT})`));
6659
6671
  const pi = await pickFromList(rowsShown, 0, { back });
@@ -6663,11 +6675,12 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6663
6675
  return;
6664
6676
  }
6665
6677
  if (pi < presets.length) target = as.serverTarget(presets[pi].dialect ?? "openai", presets[pi].url);
6678
+ else if (pi === OLLAMA_HERE) askDialect = "ollama";
6666
6679
  }
6667
6680
  if (!target && !presetUrl) {
6668
- const dflt = as.defaultServerUrl(role, dialect, settings, existing);
6669
- console.log(dim(dialect === "ollama"
6670
- ? " An Ollama server: this machine, a box on your network, or https://ollama.com (needs a key)."
6681
+ const dflt = as.defaultServerUrl(role, askDialect, settings, existing);
6682
+ console.log(dim(askDialect === "ollama"
6683
+ ? " An Ollama server: this machine, a box on your network, or ollama.com (with a key)."
6671
6684
  : " Any OpenAI-compatible endpoint: OpenRouter, Together, vLLM, LM Studio, …"));
6672
6685
  // The address goes ABOVE, in full, and Enter's meaning is said out loud (46d1524e).
6673
6686
  console.log(dim(dflt ? ` Enter uses ${dflt} · Esc cancels\n` : " Esc cancels.\n"));
@@ -6676,9 +6689,9 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6676
6689
  if (typed === null || as.isCancelWord(typed)) break;
6677
6690
  const raw = typed.trim() || dflt;
6678
6691
  if (!raw) break;
6679
- const t = as.serverTarget(dialect, raw);
6692
+ const t = as.serverTarget(askDialect, raw);
6680
6693
  if (!t.ok) {
6681
- if (dialect === "openai") for (const l of ps.describeUrlProblem(t.why, raw)) console.log(GUTTER + dim(l));
6694
+ if (askDialect === "openai") for (const l of ps.describeUrlProblem(t.why, raw)) console.log(GUTTER + dim(l));
6682
6695
  else console.log(GUTTER + dim(`"${raw}" is ${t.why} — try host:port, e.g. 127.0.0.1:11434`));
6683
6696
  continue;
6684
6697
  }
@@ -8805,8 +8818,8 @@ async function chooseEmbedModel() {
8805
8818
  await addServer({ role: "embed", dialect: "ollama", model: chosen, plan });
8806
8819
  return;
8807
8820
  }
8808
- if (action === "ollama" || action === "provider") {
8809
- if ((await addServer({ role: "embed", dialect: action === "ollama" ? "ollama" : "openai", back: true })) === BACK) return chooseEmbedModel();
8821
+ if (action === "provider") {
8822
+ if ((await addServer({ role: "embed", dialect: "openai", back: true })) === BACK) return chooseEmbedModel();
8810
8823
  return;
8811
8824
  }
8812
8825
 
@@ -8944,6 +8957,78 @@ async function chooseAgentProvider({ back = false } = {}) {
8944
8957
  * The decisions live in agent-model-command.mjs; this is the terminal half only.
8945
8958
  */
8946
8959
  async function chooseAgentModel() {
8960
+ const am = await import("./agent-model-command.mjs");
8961
+ const settings = await withPendingStatus("reading your agent model", fetchWorkerSettings);
8962
+ if (!settings) {
8963
+ console.log(red(" couldn't reach the API to read your current agent model.\n"));
8964
+ return;
8965
+ }
8966
+ // WHAT /model ALREADY HAS, FIRST (#246). The providers and models added for the coder are the
8967
+ // same servers with the same keys; listing them here means switching the agent to one of them is
8968
+ // one pick, not "add a provider" or "enter the API key" for something already set up.
8969
+ const saved = am.savedAgentChoices(settings);
8970
+ if (!saved.length) return agentModelOnServer();
8971
+ const current = am.currentAgentModel(settings);
8972
+ const curHost = settings.agentModelUrl ? hostOf(String(settings.agentModelUrl)) : "";
8973
+ for (;;) {
8974
+ 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…");
8977
+ const ADD = labels.length;
8978
+ labels.push("add a provider — Claude API, OpenAI, Kimi, Ollama…");
8979
+ // The hint drops to its own row when the header would wrap (80 columns over SSH).
8980
+ const head = ` Agent model — the one that TALKS (now ${current || "unset"}${curHost ? ` on ${curHost}` : ""})`;
8981
+ const hint = `(${PICK_HINT}):`;
8982
+ const cols = Math.max(20, process.stdout.columns || 80);
8983
+ 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)));
8985
+ if (idx < 0) {
8986
+ console.log(dim(" (cancelled)\n"));
8987
+ return;
8988
+ }
8989
+ if (idx === MORE) {
8990
+ if ((await agentModelOnServer({ back: true })) === BACK) continue;
8991
+ return;
8992
+ }
8993
+ if (idx === ADD) {
8994
+ if ((await addServer({ role: "agent", dialect: "openai", back: true })) === BACK) continue;
8995
+ return;
8996
+ }
8997
+ await useSavedAgentChoice(saved[idx]);
8998
+ return;
8999
+ }
9000
+ }
9001
+
9002
+ /**
9003
+ * Point the agent at a model already set up for /model (#246): that row's URL and the model. The
9004
+ * API finds the key by the URL (resolveKeyForRole), so nothing is re-entered — unless the server
9005
+ * needs a key and has none, which is asked here rather than failing on the next question.
9006
+ */
9007
+ async function useSavedAgentChoice(c) {
9008
+ if (c.current) {
9009
+ console.log(dim(` already using ${c.model} on ${c.host} — nothing changed.\n`));
9010
+ return;
9011
+ }
9012
+ const as = await import("./add-server.mjs");
9013
+ const target = as.serverTarget(c.kind === "ollama" ? "ollama" : "openai", c.url);
9014
+ const keys = (await withPendingStatus(`reading the keys for ${c.host}`, () => fetchServerKeys(c.rowId))) ?? [];
9015
+ if (target.ok && as.keyRequirement(target) === "required" && !keys.length) {
9016
+ console.log(GUTTER + dim(`${c.host} needs an API key and none is saved for it — enter one.`));
9017
+ await addServer({ role: "agent", dialect: target.dialect, url: c.url, newKey: true });
9018
+ return;
9019
+ }
9020
+ // A folder pick of ANOTHER server's key would be sent to this one: this folder follows the
9021
+ // server's own key from now on.
9022
+ if (sessionAgentKeyId && !keys.some((k) => k.id === sessionAgentKeyId)) await saveFolderKeyPick("agent", "");
9023
+ await saveAgentModel(c.url, c.model, c.host);
9024
+ }
9025
+
9026
+ /**
9027
+ * 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".
9030
+ */
9031
+ async function agentModelOnServer({ back = false } = {}) {
8947
9032
  const am = await import("./agent-model-command.mjs");
8948
9033
  const settings = await withPendingStatus("reading your agent model", fetchWorkerSettings);
8949
9034
  if (!settings) {
@@ -9020,11 +9105,12 @@ async function chooseAgentModel() {
9020
9105
  const options = am.agentRecoveryOptions({ keyProblem, host: listHost });
9021
9106
  const go = await pickFromList(
9022
9107
  options.map((o, i) => (i === 0 ? dim(o.label) : o.label)),
9023
- keyProblem ? 1 : 0
9108
+ keyProblem ? 1 : 0,
9109
+ { back }
9024
9110
  );
9111
+ if (go === PICK_BACK) return BACK;
9025
9112
  const action = go >= 0 ? options[go].action : "leave";
9026
9113
  if (action === "key") await enterAgentProviderKey(source.row, listHost);
9027
- else if (action === "ollama") await addServer({ role: "agent", dialect: "ollama" });
9028
9114
  else if (action === "provider") await addServer({ role: "agent", dialect: "openai" });
9029
9115
  else console.log("");
9030
9116
  return;
@@ -9035,19 +9121,16 @@ async function chooseAgentModel() {
9035
9121
  // are all you can choose". A short menu of what to change comes before any list.
9036
9122
  console.log(dim(` Agent model — the one that TALKS: ${current || "unset"} on ${listHost}`));
9037
9123
  const menu = am.agentMenuOptions({ host: listHost, modelCount: models.length, needsKey });
9038
- const first = await pickFromList(menu.map((o) => o.label), 0);
9124
+ const first = await pickFromList(menu.map((o) => o.label), 0, { back });
9125
+ if (first === PICK_BACK) return BACK;
9039
9126
  const firstAction = first >= 0 ? menu[first].action : "cancel";
9040
9127
  if (firstAction === "cancel") {
9041
9128
  console.log(dim(" (cancelled)\n"));
9042
9129
  return;
9043
9130
  }
9044
9131
  // "← back" in any of these returns to this menu (#242) — read again, as on a first open.
9045
- if (firstAction === "ollama") {
9046
- if ((await addServer({ role: "agent", dialect: "ollama", back: true })) === BACK) return chooseAgentModel();
9047
- return;
9048
- }
9049
9132
  if (firstAction === "provider") {
9050
- if ((await chooseAgentProvider({ back: true })) === BACK) return chooseAgentModel();
9133
+ if ((await chooseAgentProvider({ back: true })) === BACK) return agentModelOnServer({ back });
9051
9134
  return;
9052
9135
  }
9053
9136
  if (firstAction === "key") {
@@ -9055,7 +9138,7 @@ async function chooseAgentModel() {
9055
9138
  const rowId = String(source.row?.id ?? "").trim() || String(source.row?.kind ?? "").trim();
9056
9139
  if (!rowId) return enterAgentProviderKey(source.row, listHost);
9057
9140
  const r = await manageServerKeys({ role: "agent", backendId: rowId, host: listHost, model: am.currentAgentModel(settings), back: true });
9058
- if (r === BACK) return chooseAgentModel();
9141
+ if (r === BACK) return agentModelOnServer({ back });
9059
9142
  return;
9060
9143
  }
9061
9144
 
@@ -9088,7 +9171,7 @@ async function chooseAgentModel() {
9088
9171
  // No other entries here any more (#44): the ways to change the provider are on the menu above,
9089
9172
  // where they can be seen, rather than after every model.
9090
9173
  const idx = await pickFromList(labels, Math.max(0, rows.findIndex((r) => r.current)), { back: true });
9091
- if (idx === PICK_BACK) return chooseAgentModel();
9174
+ if (idx === PICK_BACK) return agentModelOnServer({ back });
9092
9175
  if (idx < 0) {
9093
9176
  console.log(dim(" (cancelled)\n"));
9094
9177
  return;
@@ -12493,7 +12576,8 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12493
12576
  // Only worth probing when the mouse is captured — thoughtRow exists for hover/click
12494
12577
  // hit-testing, and with `/hover` off nothing reads it (and nothing would consume the reply).
12495
12578
  if (hoverMouse && process.stdin.isTTY && now - lastDsrAt > 400) { lastDsrAt = now; ui("\x1b[6n"); }
12496
- // Line 1: cyan ● + cyan phase/seconds — the logo's colour, which is what this REPL uses for
12579
+ // Line 1 (#245: white by default; the cyan below only with `gu --color`, like the spinner row):
12580
+ // cyan ● + cyan phase/seconds — the logo's colour, which is what this REPL uses for
12497
12581
  // WHAT IS LIVE RIGHT NOW: the ">> " prompt is cyan while you are typing and goes dim once
12498
12582
  // submitted, and a selection is drawn on the same cyan. Green is left to mean SUCCEEDED (the
12499
12583
  // ✓ bullets), which is what it means everywhere else here — a running step is not a finished
@@ -12512,7 +12596,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12512
12596
  const clipRow = (r) => clipVisible(r, Math.max(20, process.stdout.columns || 80) - 1);
12513
12597
  const contentRows = [];
12514
12598
  for (let i = 0; i < LEAD_ROWS; i++) contentRows.push(""); // the separator — see LEAD_ROWS
12515
- contentRows.push(clipRow(`${markLine(accent(BULLET))}${hovering ? hoverThought(label) : accent(label)}`));
12599
+ contentRows.push(clipRow(`${markLine(thoughtPaint(BULLET))}${hovering ? hoverThought(label) : thoughtPaint(label)}`));
12516
12600
  for (const dr of detailRows) contentRows.push(clipRow(dim(dr)));
12517
12601
  for (const wl of expLines) contentRows.push(clipRow(GUTTER + dim(wl)));
12518
12602
  for (let i = 0; i < WORD_GAP; i++) contentRows.push(""); // see WORD_GAP
@@ -14182,7 +14266,7 @@ async function main() {
14182
14266
  red("gu: this machine has no models set up yet.") +
14183
14267
  dim(
14184
14268
  "\n Nothing can run until it knows which Ollama server to use." +
14185
- "\n Start `gu`, then `/agent-model` → use an Ollama server, `/embed-model` for RAG," +
14269
+ "\n Start `gu`, then `/agent-model` → use another provider → Ollama, `/embed-model` for RAG," +
14186
14270
  "\n and `/model` → add a provider → another URL… for the coding model." +
14187
14271
  `\n The agent server needs a ${REQUIRED_AGENT_PREFIX} model (e.g. ${REQUIRED_AGENT_MODEL})` +
14188
14272
  `\n and any ONE of these embedders: ${ACCEPTED_EMBED_MODELS.map((m) => m.replace(/:latest$/, "")).join(", ")};` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.873",
3
+ "version": "1.0.875",
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",