@tiens.nguyen/gu-cli 1.0.896 → 1.0.897

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.
@@ -119,6 +119,11 @@ export function agentModelRows(models, current) {
119
119
  * model is always named, because that is the one fact still true when the network is not.
120
120
  */
121
121
  export function describeTagsFailure({ url, current, error, proxied = false } = {}) {
122
+ // NOTHING CONFIGURED IS NOT A FAILURE (GH #397). There was no list to fetch, so "couldn't fetch
123
+ // the model list from (no agent model URL configured)", its echo and a /proxy hint were three
124
+ // ways of saying one thing — and the proxy cannot help with a URL that does not exist. Same
125
+ // sentence /model uses for the same state.
126
+ if (!url && !current) return ["No agent model configured."];
122
127
  const lines = [
123
128
  `couldn't fetch the model list from ${url || "(no agent model URL configured)"}`,
124
129
  ];
@@ -355,6 +360,16 @@ export function agentRecoveryOptions({ keyProblem = false, host = "" } = {}) {
355
360
  ];
356
361
  }
357
362
 
363
+ /**
364
+ * Which recovery row the cursor starts on (GH #397). A key problem → the key row. NOTHING SET UP →
365
+ * "add a provider": the only way forward there, and what /model already highlights for its own
366
+ * "No coding model configured." Otherwise "leave it", so Enter on an unreachable box changes nothing.
367
+ */
368
+ export function agentRecoveryDefault(options, { keyProblem = false, configured = true } = {}) {
369
+ const want = keyProblem ? "key" : configured ? "leave" : "provider";
370
+ return Math.max(0, options.findIndex((o) => o.action === want));
371
+ }
372
+
358
373
  // ---------------------------------------------------------------------------------------------
359
374
  // #44 — WHAT TO CHANGE comes first.
360
375
  // ---------------------------------------------------------------------------------------------
@@ -729,7 +729,7 @@ const NOT_A_CHAT_MODEL = [
729
729
  /moderation/i,
730
730
  /\btts\b|-tts|text-to-speech/i,
731
731
  /whisper|transcribe|audio|realtime|voice/i,
732
- /image|vision-preview|dall-?e|sora/i,
732
+ /image|vision-preview|dall-?e|sora|video/i,
733
733
  /search(-preview|-api)?$|-search-/i,
734
734
  /rerank|guard|classifier/i,
735
735
  // OpenAI's original completion endpoints — they take a prompt, not messages.
@@ -18,7 +18,8 @@ import {
18
18
  spawnSurviving,
19
19
  } from "./platform-tools.mjs";
20
20
  import { isLocalApiBase } from "./terminal-layout.mjs";
21
- const PROC_LIST = processListCommand();
21
+ // Only this user's processes (GH #396): another account's gu daemon is not "ours is running".
22
+ const PROC_LIST = processListCommand(process.platform, { ownUser: true });
22
23
 
23
24
  const DIR = join(homedir(), ".gonext");
24
25
  export const PID_FILE = join(DIR, "worker.pid");
package/doctor-check.mjs CHANGED
@@ -63,6 +63,47 @@ export function rolesFromSettings(s) {
63
63
  ];
64
64
  }
65
65
 
66
+ /**
67
+ * THE MODELS A TURN ACTUALLY RUNS ON (GH #397), from the agent-payload the turn itself fetches.
68
+ *
69
+ * rolesFromSettings reads the legacy single-provider fields. /model now stores the coder as a
70
+ * provider row (agentCodingBackends), so on a machine set up with today's /model the coder read
71
+ * as "not configured" while turns ran on it fine — and the chat model was probed WITHOUT its key,
72
+ * so a working Qwen/OpenAI setup showed "HTTP 401 No API-key provided". Asking the same endpoint
73
+ * a turn asks means /doctor checks what a turn uses, key included.
74
+ *
75
+ * `codingKey` is fetched separately (the payload never carries the coder's key). The embedder is
76
+ * not in the payload, so it still comes from settings. Returns null when there is no payload, so
77
+ * the caller can fall back to rolesFromSettings.
78
+ */
79
+ export function rolesFromPayload(payload, settings, codingKey = "") {
80
+ if (!payload || typeof payload !== "object") return null;
81
+ const str = (v) => String(v ?? "").trim();
82
+ // "local" is the API's "no key needed" marker, not a credential.
83
+ const realKey = (k) => (str(k) && str(k) !== "local" ? str(k) : "");
84
+ const [, , embed] = rolesFromSettings(settings);
85
+ const agentSet = payload.agentMissing !== true;
86
+ return [
87
+ {
88
+ role: "chat",
89
+ label: "chat model",
90
+ why: "answers and routes every turn",
91
+ url: agentSet ? str(payload.agentBaseURL) : "",
92
+ model: agentSet ? str(payload.agentModelId) : "",
93
+ apiKey: agentSet ? realKey(payload.agentApiKey) : "",
94
+ },
95
+ {
96
+ role: "code",
97
+ label: "coding model",
98
+ why: "writes the code",
99
+ url: str(payload.codingBaseURL),
100
+ model: str(payload.codingModelId),
101
+ apiKey: realKey(codingKey),
102
+ },
103
+ embed,
104
+ ];
105
+ }
106
+
66
107
  /**
67
108
  * WHY a model that will not answer is not answering, when the host is an Ollama.
68
109
  *
@@ -166,7 +207,8 @@ export async function checkRole(
166
207
  try {
167
208
  res = await fetchImpl(endpoint, {
168
209
  method: "POST",
169
- headers: { "Content-Type": "application/json" },
210
+ // The key goes in the header only — never into the result, which is printed.
211
+ headers: { "Content-Type": "application/json", ...(r.apiKey ? { Authorization: `Bearer ${r.apiKey}` } : {}) },
170
212
  body: JSON.stringify(body),
171
213
  signal: AbortSignal.timeout(timeoutMs),
172
214
  });
@@ -245,3 +287,23 @@ export function doctorLines(results) {
245
287
  }
246
288
  return out;
247
289
  }
290
+
291
+ /**
292
+ * THE VERDICT LINE (GH #397). "✓ every configured model answered." was printed when NOTHING was
293
+ * configured — vacuously true, and green on a machine that cannot answer a single question. The
294
+ * check mark is only earned by a model that answered; with none set up the verdict says so and
295
+ * names where to start. Returns { tone: "ok" | "warn" | "bad", text }.
296
+ */
297
+ export function doctorSummary(results) {
298
+ const list = Array.isArray(results) ? results : [];
299
+ const bad = list.filter((r) => r.status !== "ok" && r.status !== "unset");
300
+ const ok = list.filter((r) => r.status === "ok");
301
+ const unset = list.filter((r) => r.status === "unset");
302
+ if (bad.length) return { tone: "bad", text: `✗ ${bad.length} of ${list.length} did not answer.` };
303
+ if (!ok.length) return { tone: "warn", text: "· no model is set up yet, so nothing was checked — start with /model." };
304
+ if (unset.length) {
305
+ const names = unset.map((r) => r.label).join(", ");
306
+ return { tone: "warn", text: `✓ ${ok.length} answered · not set up: ${names}.` };
307
+ }
308
+ return { tone: "ok", text: "✓ every configured model answered." };
309
+ }
@@ -1707,7 +1707,9 @@ def _coding_backend_flags(backend):
1707
1707
  # — it applies whenever a folder is registered, i.e. every coding session — and 20 was
1708
1708
  # far too low for real work: a NestJS feature routinely spends that on reading and
1709
1709
  # installing before it has written a line, so the run stopped to ask mid-task
1710
- # (user, 2026-08-10: "lets increase it to 100 instead of 20").
1710
+ # (user, 2026-08-10: "lets increase it to 100 instead of 20"). Then 200 for the same reason
1711
+ # (GH #395, user, 2026-11-06: "increase step per turn to 200 instead of 100, only if reach 200
1712
+ # steps it will ask for permission to continue"): the __MAXSTEP__ question is only asked at 200.
1711
1713
  #
1712
1714
  # Raising it is cheap because the budget is a STOP, not a reservation: a turn that needs
1713
1715
  # 12 steps still takes 12. What it changes is how often a working run is interrupted to
@@ -1716,7 +1718,7 @@ def _coding_backend_flags(backend):
1716
1718
  #
1717
1719
  # The no-workspace defaults stay small on purpose: without a registered folder there are
1718
1720
  # no file tools, so a long run there is a model looping rather than making progress.
1719
- "budget": (20, 100) if backend == "openai" else (10, 100) if backend == "ollama" else (5, 40),
1721
+ "budget": (20, 200) if backend == "openai" else (10, 200) if backend == "ollama" else (5, 40),
1720
1722
  }
1721
1723
 
1722
1724
 
package/gu-repl.mjs CHANGED
@@ -1436,7 +1436,7 @@ rl._ttyWrite = (str, key) => {
1436
1436
  }
1437
1437
  if (verdict.action === "consume") {
1438
1438
  mouseClearTimer();
1439
- applyMouseReport(verdict.report);
1439
+ if (verdict.report) applyMouseReport(verdict.report); // null: focus in/out, a win32 record
1440
1440
  return;
1441
1441
  }
1442
1442
  if (verdict.action === "replay") {
@@ -6196,7 +6196,11 @@ async function runModelDoctor() {
6196
6196
  return;
6197
6197
  }
6198
6198
  console.log(dim(" Asking each model to answer — the real request, not just a ping."));
6199
- const roles = dc.rolesFromSettings(settings);
6199
+ // The SAME models, URLs and keys a turn gets (GH #397): this folder's /model pick included.
6200
+ const turnPayload = await fetchAgentPayload([{ role: "user", content: "ping" }], sessionCodingModel).catch(() => null);
6201
+ const p = turnPayload?.payload ?? null;
6202
+ const codingKey = p?.codingBackendId ? (await fetchKeyForBackend(p.codingBackendId))?.apiKey ?? "" : "";
6203
+ const roles = dc.rolesFromPayload(p, settings, codingKey) ?? dc.rolesFromSettings(settings);
6200
6204
  const results = [];
6201
6205
  for (const r of roles) {
6202
6206
  // One at a time, and SAID one at a time: these share a GPU, so a check can take a while and
@@ -6213,13 +6217,8 @@ async function runModelDoctor() {
6213
6217
  console.log(GUTTER + (res.status === "ok" ? dim(l) : res.status === "unset" ? dim(l) : yellow(l)));
6214
6218
  }
6215
6219
  }
6216
- const bad = results.filter((r) => r.status !== "ok" && r.status !== "unset");
6217
- console.log(
6218
- "\n" + GUTTER +
6219
- (bad.length === 0
6220
- ? accent("✓ every configured model answered.")
6221
- : red(`✗ ${bad.length} of ${results.length} did not answer.`))
6222
- );
6220
+ const verdict0 = dc.doctorSummary(results);
6221
+ console.log("\n" + GUTTER + (verdict0.tone === "ok" ? accent : verdict0.tone === "bad" ? red : yellow)(verdict0.text));
6223
6222
  // THE GU SERVICE TOO (#413): every model can answer and a coding turn still fail, because its
6224
6223
  // instructions come from the gu service — reported on a Windows VM where only "hi" worked.
6225
6224
  const sc = await import("./service-check.mjs");
@@ -6292,10 +6291,16 @@ async function doctorFetch(url, init) {
6292
6291
  * Returns [] when there is nothing to offer, or null when the user cancelled — which the caller
6293
6292
  * must treat as "abandon", not as "offer none", or Escape would silently save something.
6294
6293
  */
6295
- async function pickExtraModels(rest, preChecked = []) {
6296
- if (rest.length === 0) return [];
6297
- console.log(dim(` Also offer these in /model? (up to ${MAX_ALT_CODERS} · ${PICK_MANY_HINT}):`));
6294
+ async function pickExtraModels(rest0, preChecked = []) {
6295
+ // The SAME non-coding filter as the main pick (GH #397): the extras are coders too, and this
6296
+ // list offered image, audio and video models the picker just above had hidden.
6298
6297
  const already = new Set(preChecked);
6298
+ const { codingModelCandidates } = await import("./coding-provider-setup.mjs");
6299
+ const keep = new Set(codingModelCandidates(rest0).models);
6300
+ const rest = rest0.filter((m) => keep.has(m) || already.has(m));
6301
+ if (rest.length === 0) return [];
6302
+ console.log(dim(` Also offer these in /model? (up to ${MAX_ALT_CODERS})`));
6303
+ console.log(dim(` ${PICK_MANY_HINT}`));
6299
6304
  const ticks = await pickManyFromList(
6300
6305
  rest,
6301
6306
  rest.map((m, i) => (already.has(m) ? i : -1)).filter((i) => i >= 0),
@@ -6333,11 +6338,11 @@ async function chooseCodingModelFrom(ps, all, question, current, { back = false
6333
6338
  // Opens on the CURRENT model where there is one — a long list that opens at the top tells the
6334
6339
  // reader nothing about which one they are already using.
6335
6340
  const at = typeof current === "string" ? list.indexOf(current) : Number(current) || 0;
6336
- console.log(
6337
- dim(` ${question} (${PICK_HINT})`) +
6338
- (showingAll || !filtered ? "" : dim(` · ${filtered} non-coding models hidden`))
6339
- );
6340
- const idx = await pickFromList(rows, at >= 0 ? at : 0, { back });
6341
+ // A HEADER, not a printed line (GH #397): erased with the list, so a finished pick leaves
6342
+ // "→ model" behind instead of a question nobody is being asked any more.
6343
+ const head = [dim(` ${question} (${PICK_HINT})`)];
6344
+ if (!showingAll && filtered) head.push(dim(` ${filtered} non-coding models hidden — "show all" is the last row`));
6345
+ const idx = await pickFromList(rows, at >= 0 ? at : 0, { back, header: () => head, headerRows: head.length });
6341
6346
  if (idx === PICK_BACK) return BACK;
6342
6347
  if (idx < 0) return null;
6343
6348
  if (idx === list.length) {
@@ -6349,6 +6354,7 @@ async function chooseCodingModelFrom(ps, all, question, current, { back = false
6349
6354
  showingAll = true;
6350
6355
  continue;
6351
6356
  }
6357
+ console.log(dim(` → ${list[idx]}`));
6352
6358
  return list[idx];
6353
6359
  }
6354
6360
  }
@@ -6369,14 +6375,18 @@ async function chooseCodingModelFrom(ps, all, question, current, { back = false
6369
6375
  async function chooseFromModelList(all, question, current = 0, { back = false } = {}) {
6370
6376
  const rows = [...all, "type a model name…"];
6371
6377
  const at = typeof current === "string" ? all.indexOf(current) : Number(current) || 0;
6372
- console.log(dim(` ${question} (${PICK_HINT})`));
6373
- const idx = await pickFromList(rows, at >= 0 ? at : 0, { back });
6378
+ const idx = await pickFromList(rows, at >= 0 ? at : 0, {
6379
+ back,
6380
+ header: () => [dim(` ${question} (${PICK_HINT})`)],
6381
+ headerRows: 1,
6382
+ });
6374
6383
  if (idx === PICK_BACK) return BACK;
6375
6384
  if (idx < 0) return null;
6376
6385
  if (idx === all.length) {
6377
6386
  const typed = (await ask(" model name: ")).trim();
6378
6387
  return typed || null;
6379
6388
  }
6389
+ console.log(dim(` → ${all[idx]}`));
6380
6390
  return all[idx];
6381
6391
  }
6382
6392
 
@@ -6405,8 +6415,13 @@ async function manageCodingProviders({ back = false } = {}) {
6405
6415
  );
6406
6416
  const ADD = labels.length;
6407
6417
  labels.push(actionRow("add another provider — Claude API, OpenAI, Kimi…"));
6408
- console.log(dim(` Providers — pick one to change it, or add another (${PICK_HINT}):`));
6409
- const idx = await pickFromList(labels, selAt, { back });
6418
+ // Headers, not printed lines (GH #397): going one level deeper must not leave the level above
6419
+ // stacked on screen.
6420
+ const idx = await pickFromList(labels, selAt, {
6421
+ back,
6422
+ header: () => [dim(` Providers — pick one to change it, or add another (${PICK_HINT}):`)],
6423
+ headerRows: 1,
6424
+ });
6410
6425
  if (idx === PICK_BACK) return BACK;
6411
6426
  if (idx < 0) {
6412
6427
  console.log(dim(" (cancelled)\n"));
@@ -6420,11 +6435,10 @@ async function manageCodingProviders({ back = false } = {}) {
6420
6435
 
6421
6436
  const p = providers[idx];
6422
6437
  for (;;) {
6423
- console.log("\n" + GUTTER + dim(`${p.host} · ${p.model || "no model set"}`));
6424
6438
  const action = await pickFromList(
6425
6439
  [dim("keep it"), "change the model…", "change the URL…", "remove this provider…"],
6426
6440
  1,
6427
- { back: true }
6441
+ { back: true, header: () => [GUTTER + dim(`${p.host} · ${p.model || "no model set"}`)], headerRows: 1 }
6428
6442
  );
6429
6443
  if (action === PICK_BACK) break; // → the provider list
6430
6444
  if (action <= 0) {
@@ -6548,7 +6562,7 @@ async function changeProviderModel(ps, rows, p) {
6548
6562
  await syncActiveCoderWhere();
6549
6563
  console.log(GUTTER + dim("this folder's pick named the old model — cleared it."));
6550
6564
  }
6551
- console.log(accent(` ✓ ${p.host} → ${model}`) + dim(" · pick it in /model\n"));
6565
+ console.log(accent(` ✓ ${p.host} now uses ${model}`) + dim(" · /model switches between its models\n"));
6552
6566
  }
6553
6567
 
6554
6568
  /** Point a provider at a different URL, keeping its id so saved picks still resolve. */
@@ -6639,6 +6653,18 @@ async function removeCodingProvider(ps, rows, p) {
6639
6653
  );
6640
6654
  console.log(GUTTER + dim(" they will be deleted too; a key with no provider can never be used again.\n"));
6641
6655
  }
6656
+ // THE AGENT MAY TALK THROUGH THIS PROVIDER TOO (GH #397). Removing the row and its key left
6657
+ // agentModelUrl pointing at it: the banner still named the model, every question failed on a
6658
+ // missing key, and /agent-model probed the URL as an Ollama (…/api/tags → 404). So the agent
6659
+ // model goes with it — said before the question, like the keys.
6660
+ const settings = await fetchWorkerSettings().catch(() => null);
6661
+ const sameUrl = (u) => u && ps.ensureV1Root(String(u)).toLowerCase() === ps.ensureV1Root(String(p.url ?? "")).toLowerCase();
6662
+ const agentUses = sameUrl(settings?.agentModelUrl);
6663
+ if (agentUses) {
6664
+ const name = String(settings.agentModelName || settings.agentModel || "").trim();
6665
+ console.log(GUTTER + orange(` the agent model uses it too${name ? ` — ${name}` : ""}`));
6666
+ console.log(GUTTER + dim(" it will be unset; pick another with /agent-model.\n"));
6667
+ }
6642
6668
  const yes = await askYesNo(
6643
6669
  `Remove ${p.host} (${p.model || "no model set"})? This cannot be undone.`,
6644
6670
  false
@@ -6650,6 +6676,7 @@ async function removeCodingProvider(ps, rows, p) {
6650
6676
  const saved = await withPendingStatus("removing", () =>
6651
6677
  md.saveWorkerSettings(apiBase, workerKey, {
6652
6678
  agentCodingBackends: ps.removeProvider(rows, p.id),
6679
+ ...(agentUses ? { agentModelUrl: "", agentModelName: "", agentModel: "" } : {}),
6653
6680
  })
6654
6681
  );
6655
6682
  if (!saved) {
@@ -6666,6 +6693,7 @@ async function removeCodingProvider(ps, rows, p) {
6666
6693
  sessionCodingKeyId = "";
6667
6694
  await saveSessionCodingKeyId(resolve(process.cwd()));
6668
6695
  }
6696
+ if (k.id === sessionAgentKeyId) await saveFolderKeyPick("agent", "");
6669
6697
  }
6670
6698
  // This folder may have been pointed at the removed provider.
6671
6699
  if (sessionCodingUrl === p.url || sessionCodingModel.startsWith(`${p.id}::`)) {
@@ -6679,8 +6707,21 @@ async function removeCodingProvider(ps, rows, p) {
6679
6707
  console.log(
6680
6708
  accent(` ✓ removed ${p.host}`) +
6681
6709
  (stranded.length ? dim(` · ${deleted} key${deleted === 1 ? "" : "s"} deleted`) : "") +
6710
+ (agentUses ? dim(" · agent model unset") : "") +
6682
6711
  "\n"
6683
6712
  );
6713
+ // The bar named the removed coder (the account default is held in memory): read it back.
6714
+ if (sameUrl(defaultCodingUrl)) {
6715
+ defaultCodingModel = "";
6716
+ defaultCodingUrl = "";
6717
+ }
6718
+ const after = (await fetchAgentPayload([{ role: "user", content: "." }]).catch(() => null))?.payload;
6719
+ if (after) {
6720
+ seedDefaultCoder(after);
6721
+ agentMissing = after.agentMissing === true;
6722
+ noModelAtAll = nothingToAnswerWith(after);
6723
+ }
6724
+ if (barPinned()) paintBar();
6684
6725
  }
6685
6726
 
6686
6727
  /**
@@ -6736,7 +6777,8 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6736
6777
  const providerPicker = !presetUrl && dialect === "openai";
6737
6778
  const ec = role === "embed" ? await import("./embed-model-command.mjs") : null;
6738
6779
  let target, host, found, need, key, keyName, typedKey, models, model;
6739
- for (;;) {
6780
+ let lastPick = 0; // GH #397: back from the key re-opens the list ON the provider just chosen
6781
+ providerLoop: for (;;) {
6740
6782
  // --- 1. the server --------------------------------------------------------------------
6741
6783
  target = null;
6742
6784
  // The dialect the URL prompt reads the address in: the caller's, or Ollama's when that row was picked.
@@ -6754,22 +6796,31 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6754
6796
  const presets = ps.providerPresets(role);
6755
6797
  // #244: an Ollama on this machine or the network is a row here, like the hosted ones — the
6756
6798
  // menus no longer have a separate "use an Ollama server…" entrance. It asks for the address.
6757
- const OLLAMA_HERE = presets.length;
6799
+ // OLLAMA FIRST (GH #397): first-run setup lists "Ollama server — this machine or your own"
6800
+ // first and these pickers listed it seventh, so the same choice moved between two screens.
6801
+ // gu is local-first; the hosted providers follow in their usual order.
6802
+ const OLLAMA_HERE = 0;
6758
6803
  const rowsShown = [
6759
- ...presets.map((p) => `${p.name}${dim(` · ${p.url}`)}`),
6760
6804
  `Ollama — this machine or your network…${dim(" · e.g. 127.0.0.1:11434")}`,
6805
+ ...presets.map((p) => `${p.name}${dim(` · ${p.url}`)}`),
6761
6806
  "another URL…",
6762
6807
  ];
6763
6808
  // A preset names its own dialect (Ollama cloud is "ollama"); the rest are OpenAI-compatible.
6764
- console.log(dim(` Which provider? (${PICK_HINT})`));
6765
- const pi = await pickFromList(rowsShown, 0, { back });
6809
+ // The question is the picker's HEADER, erased with it (GH #397): printed above, it stayed in
6810
+ // the history every time "← back" re-opened this list — "Which provider?" stacked up.
6811
+ const pi = await pickFromList(rowsShown, lastPick, { back, header: () => [dim(` Which provider? (${PICK_HINT})`)], headerRows: 1 });
6766
6812
  if (pi === PICK_BACK) return BACK;
6767
6813
  if (pi < 0) {
6768
6814
  console.log(dim(" (nothing saved)\n"));
6769
6815
  return;
6770
6816
  }
6771
- if (pi < presets.length) target = as.serverTarget(presets[pi].dialect ?? "openai", presets[pi].url);
6817
+ lastPick = pi;
6818
+ const preset = pi > OLLAMA_HERE && pi <= presets.length ? presets[pi - 1] : null;
6819
+ if (preset) target = as.serverTarget(preset.dialect ?? "openai", preset.url);
6772
6820
  else if (pi === OLLAMA_HERE) askDialect = "ollama";
6821
+ // SAY WHAT WAS PICKED (GH #397): the list is erased on Enter, and what followed ("… needs an
6822
+ // API key", "key name:") never named the provider — the choice was nowhere on screen.
6823
+ console.log(GUTTER + dim(`→ ${preset ? preset.name : pi === OLLAMA_HERE ? "Ollama" : "another URL"}`));
6773
6824
  }
6774
6825
  if (!target && !presetUrl) {
6775
6826
  const dflt = as.defaultServerUrl(role, askDialect, settings, existing);
@@ -6835,8 +6886,19 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6835
6886
  if (key) console.log(GUTTER + dim(`using the key "${inUse.name}" already saved for ${host}.`));
6836
6887
  }
6837
6888
  }
6838
- const askKey = async () => {
6839
- const t = await askServerKey({ host, optional: need === "optional" });
6889
+ // ESC AT THE KEY GOES BACK ONE STEP (GH #397): with a provider picker behind it, Esc at
6890
+ // "key name:" / "API key:" returns to "Which provider?" — it used to unwind the whole command,
6891
+ // so choosing the wrong provider meant starting /agent-model or /model again. Nothing is saved
6892
+ // before step 5, so going back leaves nothing behind. Without a picker it still cancels.
6893
+ const BACK_TO_PROVIDERS = Symbol("back");
6894
+ const askKey = async ({ again = false } = {}) => {
6895
+ let t;
6896
+ try {
6897
+ t = await askServerKey({ host, optional: need === "optional", escBack: providerPicker, ...(again && typedKey ? { name: keyName } : {}) });
6898
+ } catch (e) {
6899
+ if (wasPromptCancelled(e) && providerPicker) return BACK_TO_PROVIDERS;
6900
+ throw e;
6901
+ }
6840
6902
  key = t.secret;
6841
6903
  keyName = t.name;
6842
6904
  typedKey = Boolean(t.secret);
@@ -6844,7 +6906,9 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6844
6906
  };
6845
6907
  if (!key && (need !== "none" || newKey)) {
6846
6908
  if (need === "required") console.log(GUTTER + dim(`${host} needs an API key.`));
6847
- if (!(await askKey())) return;
6909
+ const got = await askKey();
6910
+ if (got === BACK_TO_PROVIDERS) continue providerLoop;
6911
+ if (!got) return;
6848
6912
  }
6849
6913
 
6850
6914
  // --- 3. its models, listed with that key ----------------------------------------------
@@ -6861,10 +6925,18 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6861
6925
  }
6862
6926
  });
6863
6927
  let probe = await list();
6864
- // THE STORED KEY WAS REFUSED — ask for the right one once, rather than saving nothing.
6865
- if ((probe.status === 401 || probe.status === 403) && !typedKey && key) {
6866
- console.log(yellow(` ${host} refused the stored key (HTTP ${probe.status}) — enter the right one.`));
6867
- if (!(await askKey()) || !key) return;
6928
+ // A REFUSED KEY IS ASKED FOR AGAIN, HERE (GH #397). The stored one always was; a TYPED one
6929
+ // ended the command with "check the key and try again" — which meant /model, the provider and
6930
+ // the key name all over again for one mistyped character. Two more tries, then stop.
6931
+ for (let retry = 0; retry < 2 && (probe.status === 401 || probe.status === 403) && key; retry++) {
6932
+ // Two short lines, not one long one: at 80 columns the single sentence wrapped mid-word.
6933
+ console.log(yellow(typedKey
6934
+ ? ` ${host} rejected that key (HTTP ${probe.status}).`
6935
+ : ` ${host} refused the stored key (HTTP ${probe.status}).`));
6936
+ console.log(GUTTER + dim(providerPicker ? "paste the right one — Esc goes back to the providers." : "paste the right one — Esc cancels."));
6937
+ const got = await askKey({ again: true });
6938
+ if (got === BACK_TO_PROVIDERS) continue providerLoop;
6939
+ if (!got || !key) return;
6868
6940
  probe = await list();
6869
6941
  }
6870
6942
  if (probe.status || probe.err) {
@@ -6941,6 +7013,7 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6941
7013
  }
6942
7014
 
6943
7015
  // --- 5. save: the server, its key, then the role --------------------------------------
7016
+ let alsoInModel = false;
6944
7017
  let rowId = found?.id ?? "";
6945
7018
  let rows = existing;
6946
7019
  // A NEW ROW ONLY WHEN SOMETHING NEEDS ONE: the coder is picked through its row, and a key is
@@ -6967,7 +7040,8 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6967
7040
  console.log(GUTTER + dim("if this machine's local API is old, restart it with `gu api restart`.\n"));
6968
7041
  return;
6969
7042
  }
6970
- if (!found && role !== "coding" && as.serverRole(role).rowModel) console.log(GUTTER + dim(`${host} is also offered in /model now.`));
7043
+ // Said AFTER the ✓ (GH #397) — printed here it came before the result it is a footnote to.
7044
+ if (!found && role !== "coding" && as.serverRole(role).rowModel) alsoInModel = true;
6971
7045
  }
6972
7046
  if (typedKey) {
6973
7047
  const id = await withPendingStatus("saving the key", () => addCodingKey(keyName, key, rowId));
@@ -6980,14 +7054,15 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6980
7054
  // the stored one was refused: it is the server's key from now on, for every role and folder.
6981
7055
  if (found) await setServerDefaultKey(rowId, id);
6982
7056
  }
6983
- const keyLine = key ? dim(` API key ${keyName || "saved"} ····${key.slice(-4)} · ${host}'s key`) : "";
7057
+ const keyLine = key ? dim(` API key ${keyName || "saved"} ····${key.slice(-4)}`) : "";
6984
7058
 
6985
7059
  if (role === "agent") {
6986
7060
  // A MACHINE SETTING, not a folder pick: the agent is what gu talks to in every folder. Saved
6987
7061
  // as URL + NAME, the pair app.ts matches back to the server row to find the key.
6988
- if (await saveAgentModel(target.roleUrl("agent"), model, host)) {
6989
- if (keyLine) console.log(keyLine + "\n");
6990
- console.log(GUTTER + dim("the banner shows the new model next time gu starts.\n"));
7062
+ // ONE BLOCK under the ✓ (GH #397): the key, then the footnotes — not three loose paragraphs.
7063
+ const extra = [keyLine, alsoInModel ? dim(` ${host} is also offered as a coding model in /model.`) : ""];
7064
+ if (await saveAgentModel(target.roleUrl("agent"), model, host, extra.filter(Boolean))) {
7065
+ /* said */
6991
7066
  } else if (rows !== existing || typedKey) {
6992
7067
  // The SERVER landed — say so, or the user re-enters a key that is already stored.
6993
7068
  console.log(yellow(` ${host} was saved, but the agent model was not pointed at it.`));
@@ -7181,11 +7256,13 @@ async function chooseModel() {
7181
7256
  // choices while still occupying its URL, so sending this branch straight to the ADD flow
7182
7257
  // would meet "already configured here" and dead-end. The manager fixes such a row, and falls
7183
7258
  // through to add when there is genuinely nothing there.
7184
- console.log(dim(" No coding model configured."));
7259
+ // As the picker's header (GH #397), so "← back" re-opening this menu does not stack it.
7185
7260
  const go = await pickFromList(
7186
7261
  [dim("leave it"), "add or change a provider — Claude API, OpenAI, Kimi…"],
7187
- 1
7262
+ 1,
7263
+ { header: () => [dim(" No coding model configured.")], headerRows: 1 }
7188
7264
  );
7265
+ if (go !== 1) console.log(dim(" No coding model configured."));
7189
7266
  if (go === 1 && (await manageCodingProviders({ back: true })) === BACK) return chooseModel();
7190
7267
  if (go !== 1) console.log("");
7191
7268
  return;
@@ -7239,8 +7316,11 @@ async function chooseModel() {
7239
7316
  // enough in name to have already been mixed up. The fix is to say which one THIS is — not to
7240
7317
  // print the other two here: the agent model and the embedder are independent settings with
7241
7318
  // their own commands, and listing them together implies a relationship that does not exist.
7242
- console.log(dim(` Choose the CODING model — the one that writes code (${PICK_ARROWS}):`));
7243
- const idx = await pickFromList(labels, activeIdx >= 0 ? activeIdx : 0, { footer: ESC_FOOTER });
7319
+ const idx = await pickFromList(labels, activeIdx >= 0 ? activeIdx : 0, {
7320
+ footer: ESC_FOOTER,
7321
+ header: () => [dim(` Choose the CODING model — the one that writes code (${PICK_ARROWS}):`)],
7322
+ headerRows: 1,
7323
+ });
7244
7324
  if (idx < 0) {
7245
7325
  console.log(dim(" (cancelled)\n"));
7246
7326
  return;
@@ -9164,7 +9244,14 @@ async function chooseEmbedModel() {
9164
9244
  if (keyRequired) labels.push(actionRow(`API keys for ${host || currentUrl} — switch, add or remove…`));
9165
9245
  const ADD = labels.length;
9166
9246
  labels.push(actionRow("add a provider — OpenAI, Ollama…"));
9167
- const head = ` Embedding model (now ${current} on ${host || currentUrl})`;
9247
+ // NOTHING SET IS SAID AS NOTHING SET (GH #397). "now nomic-embed-text:latest on 127.0.0.1:11434"
9248
+ // was the DEFAULT, printed as if it were in use — on a Mac with no Ollama, where /doctor said
9249
+ // "embedder (no model) — no server configured" a moment later. The default still drives the
9250
+ // cursor and the "pick another model on …" row; the header only claims what is set.
9251
+ const embedConfigured = isSet || Boolean(String(settings.ragEmbedUrl ?? "").trim());
9252
+ const head = embedConfigured
9253
+ ? ` Embedding model (now ${current} on ${host || currentUrl})`
9254
+ : " Embedding model — none set up yet, so RAG is off. Pick one";
9168
9255
  const hint = `(${PICK_ARROWS}):`;
9169
9256
  const cols = Math.max(20, process.stdout.columns || 80);
9170
9257
  console.log(dim(head.length + 2 + hint.length < cols ? `${head} ${hint}` : `${head}\n ${hint}`));
@@ -9218,7 +9305,7 @@ async function chooseEmbedModel() {
9218
9305
  }
9219
9306
 
9220
9307
  /** Save the agent model on this machine: the URL the API sends turns to, and the model name. */
9221
- async function saveAgentModel(agentUrl, model, host) {
9308
+ async function saveAgentModel(agentUrl, model, host, extra = []) {
9222
9309
  const md = await import("./model-doctor.mjs");
9223
9310
  const ok = await withPendingStatus("pointing the agent model at it", () =>
9224
9311
  md.saveWorkerSettings(apiBase, workerKey, { agentModelUrl: agentUrl, agentModelName: model, agentModel: model })
@@ -9229,6 +9316,7 @@ async function saveAgentModel(agentUrl, model, host) {
9229
9316
  return false;
9230
9317
  }
9231
9318
  console.log(accent(` ✓ agent model → ${model}`) + dim(` · ${host}`));
9319
+ for (const l of extra) console.log(l);
9232
9320
  console.log(dim(" takes effect on your next question.\n"));
9233
9321
  return true;
9234
9322
  }
@@ -9237,7 +9325,9 @@ async function saveAgentModel(agentUrl, model, host) {
9237
9325
  * Ask for a key's NAME — and refuse the key itself as a name: names are printed wherever keys are
9238
9326
  * listed, so a pasted secret there is a secret on screen (reported: "API key sk-E8Iv… ····3E66").
9239
9327
  */
9240
- async function askKeyName(prompt = " key name: ") {
9328
+ // "(optional)" because nothing said so (GH #397): a bare "key name:" with no default reads as a
9329
+ // required field, while Enter has always meant "no name".
9330
+ async function askKeyName(prompt = " key name (optional): ") {
9241
9331
  const am = await import("./agent-model-command.mjs");
9242
9332
  for (let attempt = 0; attempt < 2; attempt++) {
9243
9333
  const name = (await ask(prompt)).trim();
@@ -9258,13 +9348,15 @@ async function askKeyName(prompt = " key name: ") {
9258
9348
  * "no key" (a local vLLM answers anonymously). `host` names the server in the prompt and decides
9259
9349
  * the shape check's locality: a local server accepts any string, so "abc" is not questioned there.
9260
9350
  */
9261
- async function askServerKey({ host = "", optional = false } = {}) {
9351
+ async function askServerKey({ host = "", optional = false, escBack = false, name: knownName = null } = {}) {
9262
9352
  const ps = await import("./coding-provider-setup.mjs");
9263
9353
  // Esc is said here (#249): the prompt that follows is bare text, with nothing on it saying a
9264
9354
  // way out exists — Esc cancels the whole add, nothing saved. (The line explaining what a key
9265
9355
  // name is for was removed on request.)
9266
- console.log("\n" + GUTTER + dim("Esc to cancel."));
9267
- const name = (await askKeyName()).trim();
9356
+ // GH #397: where a provider list is behind this prompt, Esc returns to it — and says so.
9357
+ // A RETRY KEEPS THE NAME (GH #397): only the secret was wrong, so only the secret is asked.
9358
+ if (knownName === null) console.log("\n" + GUTTER + dim(escBack ? "Esc goes back to the providers." : "Esc to cancel."));
9359
+ const name = knownName === null ? (await askKeyName()).trim() : String(knownName);
9268
9360
  console.log(GUTTER + dim(SECRET_KEY_HINT));
9269
9361
  const secret = (await askSecret(` API key${host ? ` for ${host}` : ""}${name ? ` (${name})` : ""}: `)).trim();
9270
9362
  if (!secret) {
@@ -9499,7 +9591,7 @@ async function agentModelOnServer({ back = false, start = "menu" } = {}) {
9499
9591
  const options = am.agentRecoveryOptions({ keyProblem, host: listHost });
9500
9592
  const go = await pickFromList(
9501
9593
  options.map((o, i) => (i === 0 ? dim(o.label) : o.label)),
9502
- keyProblem ? 1 : 0,
9594
+ am.agentRecoveryDefault(options, { keyProblem, configured: Boolean(url || current) }),
9503
9595
  { back }
9504
9596
  );
9505
9597
  if (go === PICK_BACK) return BACK;
@@ -14201,7 +14293,12 @@ async function main() {
14201
14293
  // local mongodb is unreachable. Cloud mode failing to reach the hosted API means the
14202
14294
  // computer has lost its internet connection. Two different messages, one per mode.
14203
14295
  dbUnreachable = true;
14204
- if (isLocalApiBase(apiBase)) {
14296
+ if (isLocalApiBase(apiBase) && (isNetworkError(err) || isTimeout(err))) {
14297
+ // THE API ITSELF IS NOT ANSWERING (GH #397) — MongoDB was not even asked. Blaming the
14298
+ // database here sent the reader to check mongod while it was fine; the message right after
14299
+ // this one names the API and starts it.
14300
+ console.error(dim("(the local API isn't answering — your saved model/key choice is unavailable)"));
14301
+ } else if (isLocalApiBase(apiBase)) {
14205
14302
  console.error(dim("(cannot access local mongodb — your saved model/key choice is unavailable)"));
14206
14303
  } else {
14207
14304
  console.error(dim("(your computer has lost its internet connection — your saved model/key choice is unavailable)"));
@@ -14772,6 +14869,19 @@ async function main() {
14772
14869
  );
14773
14870
  } else {
14774
14871
  const started = await tryStartLocalApi();
14872
+ // CARRY ON INSTEAD OF "run gu again" (GH #397). tryStartLocalApi waited for the API to
14873
+ // answer, so the only thing left is the startup this process already abandoned — and the
14874
+ // simplest correct way to redo it is to BE a fresh gu: execve keeps the pid, the terminal
14875
+ // and the arguments. Where execve is missing (Node < 22.15, Windows) the old sentence stays.
14876
+ // ONCE: an API that answers the health check and then dies would otherwise loop forever.
14877
+ if (started && !process.env.GU_API_REOPENED && typeof process.execve === "function" && process.platform !== "win32" && process.stdin.isTTY) {
14878
+ console.error(dim(" Started it — opening gu…\n"));
14879
+ try {
14880
+ process.execve(process.execPath, [process.execPath, ...process.execArgv, ...process.argv.slice(1)], { ...process.env, GU_API_REOPENED: "1" });
14881
+ } catch {
14882
+ /* not allowed here — fall through to the sentence below */
14883
+ }
14884
+ }
14775
14885
  console.error(
14776
14886
  started
14777
14887
  ? dim(" Started it — run `gu` again.\n")
@@ -14794,6 +14904,7 @@ async function main() {
14794
14904
  }
14795
14905
  process.exit(1);
14796
14906
  }
14907
+ delete process.env.GU_API_REOPENED; // GH #397: started — the next start may re-open again
14797
14908
  console.log(wrapGutter("Ask about this repo. Type / to see commands (Tab completes). Ctrl-C aborts a running turn.", dim) + "\n");
14798
14909
  // #221: gu runs, but its own npm folder is not on PATH (npm skipped the postinstall that adds it).
14799
14910
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.896",
3
+ "version": "1.0.897",
4
4
  "description": "gu: the coding agent in your terminal. It reads your repo, makes the change and runs the tests, with your own models (Ollama or any OpenAI-compatible provider). Free installs run entirely on your machine. macOS, Linux and Windows.",
5
5
  "keywords": [
6
6
  "ai",
@@ -30,8 +30,15 @@ const run = (cmd, args, opts = {}) =>
30
30
 
31
31
  /* ------------------------------------------------------------------ process listing ----- */
32
32
 
33
- /** The command that lists every process with its full command line. */
34
- export function processListCommand(platform = process.platform) {
33
+ /**
34
+ * The command that lists every process with its full command line.
35
+ *
36
+ * `ownUser` (GH #396): only THIS user's processes, on macOS and Linux. Another account on the same machine has its
37
+ * own HOME, worker.env and daemon: counting its gu daemon as "ours is running" meant a second account's daemon
38
+ * was never started (found on a Mac shared by two accounts: "start it with `gu start`"), and it is not ours to stop.
39
+ * Windows keeps the full list: a daemon started by a scheduled task runs in a different session than the terminal.
40
+ */
41
+ export function processListCommand(platform = process.platform, { ownUser = false, uid = process.getuid?.() } = {}) {
35
42
  return platform === "win32"
36
43
  ? {
37
44
  // CIM over WMIC: wmic is deprecated and absent from newer Windows 11 images, and
@@ -43,7 +50,7 @@ export function processListCommand(platform = process.platform) {
43
50
  "Get-CimInstance Win32_Process | ForEach-Object { \"$($_.ProcessId) $($_.CommandLine)\" }",
44
51
  ],
45
52
  }
46
- : { cmd: "ps", args: ["-A", "-o", "pid=,command="] };
53
+ : { cmd: "ps", args: [...(ownUser && Number.isInteger(uid) ? ["-U", String(uid)] : ["-A"]), "-o", "pid=,command="] };
47
54
  }
48
55
 
49
56
  /**
@@ -557,26 +557,50 @@ export function createMouseKeyWindow({ maxRun = 24 } = {}) {
557
557
  * "^" is only held while the terminal is reporting the mouse (`plainLeads`), so typing them is
558
558
  * not slowed down otherwise.
559
559
  */
560
- const MOUSE_LEADS = ["\x1b[<", "^[[<", "[<"];
561
560
  const MOUSE_BODY_DONE = /^(\d+;\d+;\d+[Mm])$/;
562
561
  const MOUSE_BODY_PART = /^\d*(?:;\d*){0,2}$/;
562
+ // Windows "win32-input-mode" key records: ESC [ Vk ; Sc ; Uc ; Kd ; Cs ; Rc _ (six numbers).
563
+ const WIN32_BODY_DONE = /^\d+(?:;\d+){5}_$/;
564
+ const WIN32_BODY_PART = /^\d*(?:;\d*){0,5}$/;
565
+ /**
566
+ * What `text` (the held keys, ESC written as "\x1b") is so far. Leads: a real ESC, ESC spelled
567
+ * "^[", or no ESC at all. The last two only while the mouse is reported (`plainLeads`), and a
568
+ * bare "[" only for a MOUSE report: "[I" or "[1;2" are things people type.
569
+ * done + report "35;114;22M" a mouse report
570
+ * done + report null a terminal message with nothing to do: focus in/out ("I"/"O",
571
+ * sent when the window is minimised and restored — GH #365) or a
572
+ * win32 key record the console let through
573
+ */
563
574
  function mouseTextVerdict(text, plainLeads) {
564
- for (const lead of MOUSE_LEADS) {
565
- if (!plainLeads && lead !== "\x1b[<") continue;
575
+ for (const lead of ["\x1b[", "^[[", "["]) {
576
+ if (!plainLeads && lead !== "\x1b[") continue;
566
577
  if (lead.startsWith(text)) return { kind: "partial" };
567
578
  if (!text.startsWith(lead)) continue;
568
579
  const body = text.slice(lead.length);
569
- if (MOUSE_BODY_DONE.test(body)) return { kind: "done", report: body };
570
- if (body.length <= 20 && MOUSE_BODY_PART.test(body)) return { kind: "partial" };
580
+ if (body.startsWith("<")) {
581
+ const m = body.slice(1);
582
+ if (MOUSE_BODY_DONE.test(m)) return { kind: "done", report: m };
583
+ if (m.length <= 20 && MOUSE_BODY_PART.test(m)) return { kind: "partial" };
584
+ continue;
585
+ }
586
+ if (lead === "[") {
587
+ if (body === "") return { kind: "partial" };
588
+ continue;
589
+ }
590
+ if (body === "I" || body === "O" || WIN32_BODY_DONE.test(body)) return { kind: "done", report: null };
591
+ if (body.length <= 40 && WIN32_BODY_PART.test(body)) return { kind: "partial" };
571
592
  }
572
593
  return { kind: "bad" };
573
594
  }
574
595
  export function createMouseReportReader() {
575
596
  let held = null; // [str, key] pairs
597
+ // A CSI node could not name ("\x1b[I", or "\x1b[17;29" with the rest still to come) is held
598
+ // by its sequence; a named one (an arrow, F3, Ctrl+Up) is a key and never starts a report.
599
+ const unnamedCsi = (key) => key?.name === "undefined" && /^\x1b\[/.test(key?.sequence ?? "");
576
600
  const textOf = (str, key) =>
577
- key?.code === "[<" ? "\x1b[<" : key?.name === "escape" ? "\x1b" : typeof str === "string" ? str : "";
601
+ key?.code === "[<" ? "\x1b[<" : unnamedCsi(key) ? key.sequence : key?.name === "escape" ? "\x1b" : typeof str === "string" ? str : "";
578
602
  const startsOne = (str, key, plainLeads) =>
579
- key?.code === "[<" || key?.name === "escape" || (plainLeads && (str === "[" || str === "^"));
603
+ key?.code === "[<" || unnamedCsi(key) || key?.name === "escape" || (plainLeads && (str === "[" || str === "^"));
580
604
  return {
581
605
  holding: () => held !== null,
582
606
  /**
@@ -595,6 +619,8 @@ export function createMouseReportReader() {
595
619
  const v = mouseTextVerdict(textOf(str, key), plainLeads);
596
620
  if (v.kind === "partial") return { action: "hold" };
597
621
  held = null;
622
+ // Whole in one key ("\x1b[O"): node would drop it anyway, but it is ours to drop.
623
+ if (v.kind === "done") return { action: "consume", report: v.report };
598
624
  return { action: "pass" };
599
625
  }
600
626
  held.push([str, key]);
@@ -626,7 +652,9 @@ export function createMouseReportReader() {
626
652
 
627
653
  /** Last line of defence at submit: a report that got past every reader is not a question. */
628
654
  export function stripLeakedMouseReports(line) {
629
- return String(line).replace(/(?:\x1b|\^\[)?\[<\d+;\d+;\d+[Mm]/g, "");
655
+ return String(line)
656
+ .replace(/(?:\x1b|\^\[)?\[<\d+;\d+;\d+[Mm]/g, "")
657
+ .replace(/(?:\x1b|\^\[)\[(?:[IO]|\d+(?:;\d+){5}_)/g, "");
630
658
  }
631
659
 
632
660
  /**