@tiens.nguyen/gu-cli 1.0.896 → 1.0.898

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/gu-repl.mjs CHANGED
@@ -126,7 +126,6 @@ import {
126
126
  nothingToAnswerWith,
127
127
  NO_MODEL_LINES,
128
128
  resizeRepaintTarget,
129
- workspaceSyncQuestion,
130
129
  workspaceAccessQuestion,
131
130
  workspaceSyncLabel,
132
131
  activeCoderName,
@@ -1052,7 +1051,7 @@ const COMMANDS = [
1052
1051
  // The two synonyms were dispatched but declared nowhere (found by slash-command-wiring.test.mjs):
1053
1052
  // they worked, Tab-completed to nothing, and appeared in no help. Aliases is what the registry
1054
1053
  // already has for exactly this — /quit, /rag-local.
1055
- { name: "/host-mode", desc: "undo client mode — run agent turns on THIS machine again" },
1054
+ // /host-mode is gone (owner, 11-07): modes are chosen at setup; `gu clear` sets up again.
1056
1055
  // /undo is HIDDEN_COMMANDS now — still works when typed.
1057
1056
  { name: "/server", desc: "pick a deployment server (host/user) for this session" },
1058
1057
  { name: "/proxy", usage: "/proxy [show|set|on|off|clear|test]", desc: "route MODEL calls through an HTTP proxy (local calls always go direct)" },
@@ -1436,7 +1435,7 @@ rl._ttyWrite = (str, key) => {
1436
1435
  }
1437
1436
  if (verdict.action === "consume") {
1438
1437
  mouseClearTimer();
1439
- applyMouseReport(verdict.report);
1438
+ if (verdict.report) applyMouseReport(verdict.report); // null: focus in/out, a win32 record
1440
1439
  return;
1441
1440
  }
1442
1441
  if (verdict.action === "replay") {
@@ -2720,6 +2719,11 @@ let cliNoticeShown = false;
2720
2719
  function announceCliUpdate() {
2721
2720
  if (cliNoticeShown || !availableUpdates.cli) return;
2722
2721
  if (following) return; // a turn owns the transcript — try again at the next prompt
2722
+ // …and so does an open menu or question (GH #397): printed into a picker's in-place block, the
2723
+ // notice pushed it down and the picker could no longer erase itself — the rows of a menu left
2724
+ // closed stayed on screen. Retried at the next prompt, like a turn.
2725
+ // A slash command still running hides the input row (inputVisible): its output is not done.
2726
+ if (listPickerActive || askWaiting || !inputVisible) return;
2723
2727
  let lines = [];
2724
2728
  try {
2725
2729
  lines = repeatUpdateNotice({ last: readUpdateAttempt(), cli: availableUpdates.cli, running: REPL_VERSION,
@@ -5244,7 +5248,7 @@ const ask = async (q, { prefill = "", hint = [], atomic = false, noCancel = fals
5244
5248
  // Masked prompt for a secret (an API key). Mutes readline's echo/refresh (secretInput)
5245
5249
  // so the typed value never hits the terminal or scrollback (task #127). Returns "" on a
5246
5250
  // bare Enter (skip).
5247
- const askSecret = async (q) => {
5251
+ const askSecret = async (q, { hint = [] } = {}) => {
5248
5252
  // Same rule as ask(): input has ended, so stop. Returning "" here would hand an EMPTY
5249
5253
  // secret to a caller that is about to store or send it.
5250
5254
  if (_stdinClosed) return await ask("");
@@ -5255,6 +5259,9 @@ const askSecret = async (q) => {
5255
5259
  // FOR. With the bar pinned the row itself becomes the prompt, so nothing is left to infer.
5256
5260
  secretPrompt = String(q ?? "").replace(/\s+$/, "") + " ";
5257
5261
  secretInput = true;
5262
+ // A hint UNDER the input, gone once answered (GH #397) — printed above, "type or paste the
5263
+ // key…" stayed in the history after the key was in, as if it were still being asked.
5264
+ askHint = barPinned() ? hint : [];
5258
5265
  // Same reason as ask() above: inputLineFor returns "" when inputVisible is false, so the one
5259
5266
  // bullet per character — the only proof a keystroke registered while the echo is muted —
5260
5267
  // would not be drawn either. askSecret is reached from /model, which is exactly a command
@@ -5264,7 +5271,10 @@ const askSecret = async (q) => {
5264
5271
  // Unpinned (piped output, or a window too short to reserve rows) there IS no frozen row, so
5265
5272
  // the label has to be printed the old way or the prompt is silent.
5266
5273
  if (barPinned()) paintBar();
5267
- else process.stdout.write(q);
5274
+ else {
5275
+ for (const l of hint) console.log(GUTTER + dim(l));
5276
+ process.stdout.write(q);
5277
+ }
5268
5278
  let raw = "";
5269
5279
  try {
5270
5280
  askWaiting = true;
@@ -5277,6 +5287,7 @@ const askSecret = async (q) => {
5277
5287
  askWaiting = false;
5278
5288
  secretInput = false;
5279
5289
  secretPrompt = "";
5290
+ askHint = [];
5280
5291
  inputVisible = _secretWasVisible;
5281
5292
  // READLINE KEEPS EVERY SUBMITTED LINE (task #191). Muting the echo stops the key reaching
5282
5293
  // the screen; it does nothing about rl.history, which is where the line goes on Enter — so
@@ -5343,10 +5354,10 @@ async function ensureWorkspace() {
5343
5354
  // Registered before cloud sync existed → ask once and persist. Otherwise honor the
5344
5355
  // stored choice. (undefined = old record that never opted in → ask.)
5345
5356
  if (hit.allowSync === undefined) {
5346
- const q = workspaceSyncQuestion({ clientExpert: isClientExpert() });
5347
- hit.allowSync = q.ask
5348
- ? await askYesNo("Allow syncing this workspace's chat history to the cloud?", true)
5349
- : q.value;
5357
+ // No question (owner, 11-07): the mode chosen at setup decides where history goes —
5358
+ // the cloud on a hosted account, the local database on Client Expert. A folder that
5359
+ // answered No before keeps that answer; only a folder never asked is filled in.
5360
+ hit.allowSync = true;
5350
5361
  await writeFile(WS_FILE, JSON.stringify({ workspaces: list }, null, 2) + "\n");
5351
5362
  }
5352
5363
  // The workspace facts now live in the startup banner (task #131) — just remember them.
@@ -5380,16 +5391,10 @@ async function ensureWorkspace() {
5380
5391
  } catch (e) {
5381
5392
  console.log(GUTTER + yellow(`Could not save the browser permission (${e?.message || e}) — gu will ask when it first needs it.`));
5382
5393
  }
5383
- // On Client Expert there is no cloud to sync TO — see workspaceSyncQuestion.
5384
- const syncQ = workspaceSyncQuestion({ clientExpert: isClientExpert() });
5385
- const allowSync = syncQ.ask
5386
- ? await askYesNo(
5387
- "Allow syncing this workspace's chat history to the cloud?\n" +
5388
- "(your conversation is saved to your account so you can see it on the web app)",
5389
- true /* default Yes — RAG is NOT synced (it stays on your S3); only chat history. */
5390
- )
5391
- : syncQ.value;
5392
- if (!syncQ.ask && syncQ.note) console.log(wrapGutter(syncQ.note, dim));
5394
+ // NO SYNC QUESTION (owner, 11-07): Cloud or Client Expert is chosen once, at setup, and it
5395
+ // decides where chat history lives — your account's cloud database, or the local one. To
5396
+ // change it, `gu clear` and set up again. RAG is never synced (it stays where it was built).
5397
+ const allowSync = true;
5393
5398
  const next = list.filter((w) => w.path !== cwd);
5394
5399
  next.push({
5395
5400
  name: basename(cwd),
@@ -6196,7 +6201,11 @@ async function runModelDoctor() {
6196
6201
  return;
6197
6202
  }
6198
6203
  console.log(dim(" Asking each model to answer — the real request, not just a ping."));
6199
- const roles = dc.rolesFromSettings(settings);
6204
+ // The SAME models, URLs and keys a turn gets (GH #397): this folder's /model pick included.
6205
+ const turnPayload = await fetchAgentPayload([{ role: "user", content: "ping" }], sessionCodingModel).catch(() => null);
6206
+ const p = turnPayload?.payload ?? null;
6207
+ const codingKey = p?.codingBackendId ? (await fetchKeyForBackend(p.codingBackendId))?.apiKey ?? "" : "";
6208
+ const roles = dc.rolesFromPayload(p, settings, codingKey) ?? dc.rolesFromSettings(settings);
6200
6209
  const results = [];
6201
6210
  for (const r of roles) {
6202
6211
  // One at a time, and SAID one at a time: these share a GPU, so a check can take a while and
@@ -6213,13 +6222,8 @@ async function runModelDoctor() {
6213
6222
  console.log(GUTTER + (res.status === "ok" ? dim(l) : res.status === "unset" ? dim(l) : yellow(l)));
6214
6223
  }
6215
6224
  }
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
- );
6225
+ const verdict0 = dc.doctorSummary(results);
6226
+ console.log("\n" + GUTTER + (verdict0.tone === "ok" ? accent : verdict0.tone === "bad" ? red : yellow)(verdict0.text));
6223
6227
  // THE GU SERVICE TOO (#413): every model can answer and a coding turn still fail, because its
6224
6228
  // instructions come from the gu service — reported on a Windows VM where only "hi" worked.
6225
6229
  const sc = await import("./service-check.mjs");
@@ -6292,10 +6296,18 @@ async function doctorFetch(url, init) {
6292
6296
  * Returns [] when there is nothing to offer, or null when the user cancelled — which the caller
6293
6297
  * must treat as "abandon", not as "offer none", or Escape would silently save something.
6294
6298
  */
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}):`));
6299
+ async function pickExtraModels(rest0, preChecked = []) {
6300
+ // The SAME non-coding filter as the main pick (GH #397): the extras are coders too, and this
6301
+ // list offered image, audio and video models the picker just above had hidden.
6298
6302
  const already = new Set(preChecked);
6303
+ const { codingModelCandidates } = await import("./coding-provider-setup.mjs");
6304
+ const keep = new Set(codingModelCandidates(rest0).models);
6305
+ const rest = rest0
6306
+ .filter((m) => keep.has(m) || already.has(m))
6307
+ .sort((a, b) => a.localeCompare(b, "en", { numeric: true })); // as the pick above (GH #397)
6308
+ if (rest.length === 0) return [];
6309
+ console.log(dim(` Any other models to switch to in /model? optional · up to ${MAX_ALT_CODERS}`));
6310
+ console.log(dim(` space ticks · Enter saves (nothing ticked skips this) · Esc cancels`));
6299
6311
  const ticks = await pickManyFromList(
6300
6312
  rest,
6301
6313
  rest.map((m, i) => (already.has(m) ? i : -1)).filter((i) => i >= 0),
@@ -6323,7 +6335,12 @@ async function pickExtraModels(rest, preChecked = []) {
6323
6335
  * Returns the model id, or null when the user cancelled.
6324
6336
  */
6325
6337
  async function chooseCodingModelFrom(ps, all, question, current, { back = false } = {}) {
6326
- const { models: coders, filtered } = ps.codingModelCandidates(all);
6338
+ const { models: coders0, filtered } = ps.codingModelCandidates(all);
6339
+ // SORTED, like /agent-model's list (GH #397): the provider's own order is arbitrary, and a
6340
+ // family (qwen3.8-*, deepseek-v4-*) scattered over the list is hard to scan.
6341
+ const byName = (a, b) => a.localeCompare(b, "en", { numeric: true });
6342
+ const coders = [...coders0].sort(byName);
6343
+ all = [...all].sort(byName);
6327
6344
  let list = coders;
6328
6345
  let showingAll = filtered === 0;
6329
6346
  for (;;) {
@@ -6333,11 +6350,11 @@ async function chooseCodingModelFrom(ps, all, question, current, { back = false
6333
6350
  // Opens on the CURRENT model where there is one — a long list that opens at the top tells the
6334
6351
  // reader nothing about which one they are already using.
6335
6352
  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 });
6353
+ // A HEADER, not a printed line (GH #397): erased with the list, so a finished pick leaves
6354
+ // "→ model" behind instead of a question nobody is being asked any more.
6355
+ const head = [dim(` ${question} (${PICK_HINT})`)];
6356
+ if (!showingAll && filtered) head.push(dim(` ${filtered} non-coding models hidden — "show all" is the last row`));
6357
+ const idx = await pickFromList(rows, at >= 0 ? at : 0, { back, header: () => head, headerRows: head.length });
6341
6358
  if (idx === PICK_BACK) return BACK;
6342
6359
  if (idx < 0) return null;
6343
6360
  if (idx === list.length) {
@@ -6349,6 +6366,7 @@ async function chooseCodingModelFrom(ps, all, question, current, { back = false
6349
6366
  showingAll = true;
6350
6367
  continue;
6351
6368
  }
6369
+ console.log(dim(` → ${list[idx]}`));
6352
6370
  return list[idx];
6353
6371
  }
6354
6372
  }
@@ -6366,20 +6384,40 @@ async function chooseCodingModelFrom(ps, all, question, current, { back = false
6366
6384
  * stays available for the same reason it does there — a listed model may be unusable and an
6367
6385
  * unlisted one may be exactly right.
6368
6386
  */
6369
- async function chooseFromModelList(all, question, current = 0, { back = false } = {}) {
6370
- const rows = [...all, "type a model name…"];
6387
+ async function chooseFromModelList(all, question, current = 0, { back = false, notes = null } = {}) {
6388
+ // `notes` (GH #397): a dim note per row ("an audio/speech model"), as /agent-model's own list has.
6389
+ const rows = [...all.map((m, i) => (notes?.[i] ? `${m}${dim(` · ${notes[i]}`)}` : m)), "type a model name…"];
6371
6390
  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 });
6391
+ const idx = await pickFromList(rows, at >= 0 ? at : 0, {
6392
+ back,
6393
+ header: () => [dim(` ${question} (${PICK_HINT})`)],
6394
+ headerRows: 1,
6395
+ });
6374
6396
  if (idx === PICK_BACK) return BACK;
6375
6397
  if (idx < 0) return null;
6376
6398
  if (idx === all.length) {
6377
6399
  const typed = (await ask(" model name: ")).trim();
6378
6400
  return typed || null;
6379
6401
  }
6402
+ console.log(dim(` → ${all[idx]}`));
6380
6403
  return all[idx];
6381
6404
  }
6382
6405
 
6406
+ /**
6407
+ * The agent's pick from a fresh provider (GH #397): the SAME order and labels as /agent-model's
6408
+ * "pick another model on <host>…" — chat models alphabetically, then audio/image/video models
6409
+ * sunk to the bottom and named. The add flow showed them mixed in and unlabelled.
6410
+ */
6411
+ async function pickAgentModelFrom(models, question, current, opts) {
6412
+ const am = await import("./agent-model-command.mjs");
6413
+ const sorted = am.agentModelsFromProvider(models);
6414
+ const ids = sorted.map((m) => m.model);
6415
+ return chooseFromModelList(ids, question, Math.max(0, ids.indexOf(current)), {
6416
+ ...opts,
6417
+ notes: sorted.map((m) => m.note),
6418
+ });
6419
+ }
6420
+
6383
6421
  /**
6384
6422
  * /model's provider manager. `back` (#242): opened from /model's list, so its first menu offers
6385
6423
  * "← back" and returns BACK. Inside it, each sub-menu's "← back" returns HERE — re-read, because a
@@ -6405,8 +6443,13 @@ async function manageCodingProviders({ back = false } = {}) {
6405
6443
  );
6406
6444
  const ADD = labels.length;
6407
6445
  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 });
6446
+ // Headers, not printed lines (GH #397): going one level deeper must not leave the level above
6447
+ // stacked on screen.
6448
+ const idx = await pickFromList(labels, selAt, {
6449
+ back,
6450
+ header: () => [dim(` Providers — pick one to change it, or add another (${PICK_HINT}):`)],
6451
+ headerRows: 1,
6452
+ });
6410
6453
  if (idx === PICK_BACK) return BACK;
6411
6454
  if (idx < 0) {
6412
6455
  console.log(dim(" (cancelled)\n"));
@@ -6420,11 +6463,10 @@ async function manageCodingProviders({ back = false } = {}) {
6420
6463
 
6421
6464
  const p = providers[idx];
6422
6465
  for (;;) {
6423
- console.log("\n" + GUTTER + dim(`${p.host} · ${p.model || "no model set"}`));
6424
6466
  const action = await pickFromList(
6425
6467
  [dim("keep it"), "change the model…", "change the URL…", "remove this provider…"],
6426
6468
  1,
6427
- { back: true }
6469
+ { back: true, header: () => [GUTTER + dim(`${p.host} · ${p.model || "no model set"}`)], headerRows: 1 }
6428
6470
  );
6429
6471
  if (action === PICK_BACK) break; // → the provider list
6430
6472
  if (action <= 0) {
@@ -6548,7 +6590,7 @@ async function changeProviderModel(ps, rows, p) {
6548
6590
  await syncActiveCoderWhere();
6549
6591
  console.log(GUTTER + dim("this folder's pick named the old model — cleared it."));
6550
6592
  }
6551
- console.log(accent(` ✓ ${p.host} → ${model}`) + dim(" · pick it in /model\n"));
6593
+ console.log(accent(` ✓ ${p.host} now uses ${model}`) + dim(" · /model switches between its models\n"));
6552
6594
  }
6553
6595
 
6554
6596
  /** Point a provider at a different URL, keeping its id so saved picks still resolve. */
@@ -6639,6 +6681,18 @@ async function removeCodingProvider(ps, rows, p) {
6639
6681
  );
6640
6682
  console.log(GUTTER + dim(" they will be deleted too; a key with no provider can never be used again.\n"));
6641
6683
  }
6684
+ // THE AGENT MAY TALK THROUGH THIS PROVIDER TOO (GH #397). Removing the row and its key left
6685
+ // agentModelUrl pointing at it: the banner still named the model, every question failed on a
6686
+ // missing key, and /agent-model probed the URL as an Ollama (…/api/tags → 404). So the agent
6687
+ // model goes with it — said before the question, like the keys.
6688
+ const settings = await fetchWorkerSettings().catch(() => null);
6689
+ const sameUrl = (u) => u && ps.ensureV1Root(String(u)).toLowerCase() === ps.ensureV1Root(String(p.url ?? "")).toLowerCase();
6690
+ const agentUses = sameUrl(settings?.agentModelUrl);
6691
+ if (agentUses) {
6692
+ const name = String(settings.agentModelName || settings.agentModel || "").trim();
6693
+ console.log(GUTTER + orange(` the agent model uses it too${name ? ` — ${name}` : ""}`));
6694
+ console.log(GUTTER + dim(" it will be unset; pick another with /agent-model.\n"));
6695
+ }
6642
6696
  const yes = await askYesNo(
6643
6697
  `Remove ${p.host} (${p.model || "no model set"})? This cannot be undone.`,
6644
6698
  false
@@ -6650,6 +6704,7 @@ async function removeCodingProvider(ps, rows, p) {
6650
6704
  const saved = await withPendingStatus("removing", () =>
6651
6705
  md.saveWorkerSettings(apiBase, workerKey, {
6652
6706
  agentCodingBackends: ps.removeProvider(rows, p.id),
6707
+ ...(agentUses ? { agentModelUrl: "", agentModelName: "", agentModel: "" } : {}),
6653
6708
  })
6654
6709
  );
6655
6710
  if (!saved) {
@@ -6666,6 +6721,7 @@ async function removeCodingProvider(ps, rows, p) {
6666
6721
  sessionCodingKeyId = "";
6667
6722
  await saveSessionCodingKeyId(resolve(process.cwd()));
6668
6723
  }
6724
+ if (k.id === sessionAgentKeyId) await saveFolderKeyPick("agent", "");
6669
6725
  }
6670
6726
  // This folder may have been pointed at the removed provider.
6671
6727
  if (sessionCodingUrl === p.url || sessionCodingModel.startsWith(`${p.id}::`)) {
@@ -6679,8 +6735,21 @@ async function removeCodingProvider(ps, rows, p) {
6679
6735
  console.log(
6680
6736
  accent(` ✓ removed ${p.host}`) +
6681
6737
  (stranded.length ? dim(` · ${deleted} key${deleted === 1 ? "" : "s"} deleted`) : "") +
6738
+ (agentUses ? dim(" · agent model unset") : "") +
6682
6739
  "\n"
6683
6740
  );
6741
+ // The bar named the removed coder (the account default is held in memory): read it back.
6742
+ if (sameUrl(defaultCodingUrl)) {
6743
+ defaultCodingModel = "";
6744
+ defaultCodingUrl = "";
6745
+ }
6746
+ const after = (await fetchAgentPayload([{ role: "user", content: "." }]).catch(() => null))?.payload;
6747
+ if (after) {
6748
+ seedDefaultCoder(after);
6749
+ agentMissing = after.agentMissing === true;
6750
+ noModelAtAll = nothingToAnswerWith(after);
6751
+ }
6752
+ if (barPinned()) paintBar();
6684
6753
  }
6685
6754
 
6686
6755
  /**
@@ -6709,6 +6778,7 @@ async function removeCodingProvider(ps, rows, p) {
6709
6778
  * newKey ask for a key even if the server has one (the stored one was refused)
6710
6779
  */
6711
6780
  async function addServer({ role = "coding", dialect = "openai", url: presetUrl = "", model: presetModel = "", plan: presetPlan = null, newKey = false, back = false } = {}) {
6781
+ let claudeSdkLater = false; // offered after the save, see step 1
6712
6782
  const as = await import("./add-server.mjs");
6713
6783
  const ps = await import("./coding-provider-setup.mjs");
6714
6784
  const sk = await import("./server-keys.mjs");
@@ -6736,7 +6806,8 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6736
6806
  const providerPicker = !presetUrl && dialect === "openai";
6737
6807
  const ec = role === "embed" ? await import("./embed-model-command.mjs") : null;
6738
6808
  let target, host, found, need, key, keyName, typedKey, models, model;
6739
- for (;;) {
6809
+ let lastPick = 0; // GH #397: back from the key re-opens the list ON the provider just chosen
6810
+ providerLoop: for (;;) {
6740
6811
  // --- 1. the server --------------------------------------------------------------------
6741
6812
  target = null;
6742
6813
  // The dialect the URL prompt reads the address in: the caller's, or Ollama's when that row was picked.
@@ -6754,22 +6825,33 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6754
6825
  const presets = ps.providerPresets(role);
6755
6826
  // #244: an Ollama on this machine or the network is a row here, like the hosted ones — the
6756
6827
  // menus no longer have a separate "use an Ollama server…" entrance. It asks for the address.
6757
- const OLLAMA_HERE = presets.length;
6828
+ // OLLAMA FIRST (GH #397): first-run setup lists "Ollama server — this machine or your own"
6829
+ // first and these pickers listed it seventh, so the same choice moved between two screens.
6830
+ // gu is local-first; the hosted providers follow in their usual order.
6831
+ const OLLAMA_HERE = 0;
6758
6832
  const rowsShown = [
6759
- ...presets.map((p) => `${p.name}${dim(` · ${p.url}`)}`),
6760
6833
  `Ollama — this machine or your network…${dim(" · e.g. 127.0.0.1:11434")}`,
6834
+ // The HOST, not the full URL (GH #397): "…/compatible-mode/v1" ran past 100 columns and was
6835
+ // cut mid-word; the path is plumbing, and the URL is echoed in full once one is picked.
6836
+ ...presets.map((p) => `${p.name}${dim(` · ${hostOf(p.url)}`)}`),
6761
6837
  "another URL…",
6762
6838
  ];
6763
6839
  // 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 });
6840
+ // The question is the picker's HEADER, erased with it (GH #397): printed above, it stayed in
6841
+ // the history every time "← back" re-opened this list — "Which provider?" stacked up.
6842
+ const pi = await pickFromList(rowsShown, lastPick, { back, header: () => [dim(` Which provider? (${PICK_HINT})`)], headerRows: 1 });
6766
6843
  if (pi === PICK_BACK) return BACK;
6767
6844
  if (pi < 0) {
6768
6845
  console.log(dim(" (nothing saved)\n"));
6769
6846
  return;
6770
6847
  }
6771
- if (pi < presets.length) target = as.serverTarget(presets[pi].dialect ?? "openai", presets[pi].url);
6848
+ lastPick = pi;
6849
+ const preset = pi > OLLAMA_HERE && pi <= presets.length ? presets[pi - 1] : null;
6850
+ if (preset) target = as.serverTarget(preset.dialect ?? "openai", preset.url);
6772
6851
  else if (pi === OLLAMA_HERE) askDialect = "ollama";
6852
+ // SAY WHAT WAS PICKED (GH #397): the list is erased on Enter, and what followed ("… needs an
6853
+ // API key", "key name:") never named the provider — the choice was nowhere on screen.
6854
+ console.log(GUTTER + dim(`→ ${preset ? preset.name : pi === OLLAMA_HERE ? "Ollama" : "another URL"}`));
6773
6855
  }
6774
6856
  if (!target && !presetUrl) {
6775
6857
  const dflt = as.defaultServerUrl(role, askDialect, settings, existing);
@@ -6806,10 +6888,10 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6806
6888
  console.log("");
6807
6889
  return;
6808
6890
  }
6809
- // Claude as the CODER: prompt caching needs Anthropic's SDK (#262) — offer it once, here.
6810
- if (target.claude && role === "coding") {
6811
- await md.ensureClaudeSdk({ log: (l) => console.log(GUTTER + dim(l.trim())), confirm: (q) => askYesNo(` ${q}`, true) });
6812
- }
6891
+ // Claude as the CODER: prompt caching needs Anthropic's SDK (#262). Offered AFTER the key and the
6892
+ // model are saved (GH #397): asked first, it interrupted the flow before the user had even been
6893
+ // asked for a key, and dumped pip's raw output over the screen.
6894
+ claudeSdkLater = Boolean(target.claude && role === "coding");
6813
6895
 
6814
6896
  // --- 2. the server's key --------------------------------------------------------------
6815
6897
  // AN EXISTING SERVER IS REUSED, never stored twice (two rows for one URL would be two picker
@@ -6835,8 +6917,19 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6835
6917
  if (key) console.log(GUTTER + dim(`using the key "${inUse.name}" already saved for ${host}.`));
6836
6918
  }
6837
6919
  }
6838
- const askKey = async () => {
6839
- const t = await askServerKey({ host, optional: need === "optional" });
6920
+ // ESC AT THE KEY GOES BACK ONE STEP (GH #397): with a provider picker behind it, Esc at
6921
+ // "key name:" / "API key:" returns to "Which provider?" — it used to unwind the whole command,
6922
+ // so choosing the wrong provider meant starting /agent-model or /model again. Nothing is saved
6923
+ // before step 5, so going back leaves nothing behind. Without a picker it still cancels.
6924
+ const BACK_TO_PROVIDERS = Symbol("back");
6925
+ const askKey = async ({ again = false } = {}) => {
6926
+ let t;
6927
+ try {
6928
+ t = await askServerKey({ host, optional: need === "optional", escBack: providerPicker, ...(again && typedKey ? { name: keyName } : {}) });
6929
+ } catch (e) {
6930
+ if (wasPromptCancelled(e) && providerPicker) return BACK_TO_PROVIDERS;
6931
+ throw e;
6932
+ }
6840
6933
  key = t.secret;
6841
6934
  keyName = t.name;
6842
6935
  typedKey = Boolean(t.secret);
@@ -6844,7 +6937,9 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6844
6937
  };
6845
6938
  if (!key && (need !== "none" || newKey)) {
6846
6939
  if (need === "required") console.log(GUTTER + dim(`${host} needs an API key.`));
6847
- if (!(await askKey())) return;
6940
+ const got = await askKey();
6941
+ if (got === BACK_TO_PROVIDERS) continue providerLoop;
6942
+ if (!got) return;
6848
6943
  }
6849
6944
 
6850
6945
  // --- 3. its models, listed with that key ----------------------------------------------
@@ -6861,10 +6956,18 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6861
6956
  }
6862
6957
  });
6863
6958
  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;
6959
+ // A REFUSED KEY IS ASKED FOR AGAIN, HERE (GH #397). The stored one always was; a TYPED one
6960
+ // ended the command with "check the key and try again" — which meant /model, the provider and
6961
+ // the key name all over again for one mistyped character. Two more tries, then stop.
6962
+ for (let retry = 0; retry < 2 && (probe.status === 401 || probe.status === 403) && key; retry++) {
6963
+ // Two short lines, not one long one: at 80 columns the single sentence wrapped mid-word.
6964
+ console.log(yellow(typedKey
6965
+ ? ` ${host} rejected that key (HTTP ${probe.status}).`
6966
+ : ` ${host} refused the stored key (HTTP ${probe.status}).`));
6967
+ console.log(GUTTER + dim(providerPicker ? "paste the right one — Esc goes back to the providers." : "paste the right one — Esc cancels."));
6968
+ const got = await askKey({ again: true });
6969
+ if (got === BACK_TO_PROVIDERS) continue providerLoop;
6970
+ if (!got || !key) return;
6868
6971
  probe = await list();
6869
6972
  }
6870
6973
  if (probe.status || probe.err) {
@@ -6896,7 +6999,9 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6896
6999
  const canBack = { back: providerPicker || back };
6897
7000
  model = R.filterCoders
6898
7001
  ? await chooseCodingModelFrom(ps, models, R.question, 0, canBack)
6899
- : await chooseFromModelList(models, R.question, Math.max(0, models.indexOf(current)), canBack);
7002
+ : role === "agent"
7003
+ ? await pickAgentModelFrom(models, R.question, current, canBack)
7004
+ : await chooseFromModelList(models, R.question, Math.max(0, models.indexOf(current)), canBack);
6900
7005
  if (model === BACK) {
6901
7006
  if (providerPicker) continue; // → Which provider?
6902
7007
  return BACK;
@@ -6941,6 +7046,7 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6941
7046
  }
6942
7047
 
6943
7048
  // --- 5. save: the server, its key, then the role --------------------------------------
7049
+ let alsoInModel = false;
6944
7050
  let rowId = found?.id ?? "";
6945
7051
  let rows = existing;
6946
7052
  // A NEW ROW ONLY WHEN SOMETHING NEEDS ONE: the coder is picked through its row, and a key is
@@ -6967,7 +7073,8 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6967
7073
  console.log(GUTTER + dim("if this machine's local API is old, restart it with `gu api restart`.\n"));
6968
7074
  return;
6969
7075
  }
6970
- if (!found && role !== "coding" && as.serverRole(role).rowModel) console.log(GUTTER + dim(`${host} is also offered in /model now.`));
7076
+ // Said AFTER the ✓ (GH #397) — printed here it came before the result it is a footnote to.
7077
+ if (!found && role !== "coding" && as.serverRole(role).rowModel) alsoInModel = true;
6971
7078
  }
6972
7079
  if (typedKey) {
6973
7080
  const id = await withPendingStatus("saving the key", () => addCodingKey(keyName, key, rowId));
@@ -6980,14 +7087,15 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6980
7087
  // the stored one was refused: it is the server's key from now on, for every role and folder.
6981
7088
  if (found) await setServerDefaultKey(rowId, id);
6982
7089
  }
6983
- const keyLine = key ? dim(` API key ${keyName || "saved"} ····${key.slice(-4)} · ${host}'s key`) : "";
7090
+ const keyLine = key ? dim(` API key ${keyName || "saved"} ····${key.slice(-4)}`) : "";
6984
7091
 
6985
7092
  if (role === "agent") {
6986
7093
  // A MACHINE SETTING, not a folder pick: the agent is what gu talks to in every folder. Saved
6987
7094
  // 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"));
7095
+ // ONE BLOCK under the ✓ (GH #397): the key, then the footnotes — not three loose paragraphs.
7096
+ const extra = [keyLine, alsoInModel ? dim(` ${host} is also offered as a coding model in /model.`) : ""];
7097
+ if (await saveAgentModel(target.roleUrl("agent"), model, host, extra.filter(Boolean))) {
7098
+ /* said */
6991
7099
  } else if (rows !== existing || typedKey) {
6992
7100
  // The SERVER landed — say so, or the user re-enters a key that is already stored.
6993
7101
  console.log(yellow(` ${host} was saved, but the agent model was not pointed at it.`));
@@ -7008,7 +7116,16 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
7008
7116
  }
7009
7117
  return;
7010
7118
  }
7011
- await useCoderInThisFolder({ ps, rows, beforeChoices, url: target.url, host, locality: target.locality, model, backendId: rowId, key, keyName });
7119
+ await useCoderInThisFolder({ ps, rows, beforeChoices, url: target.url, host, locality: target.locality, model, backendId: rowId, key, keyName, extras });
7120
+ if (claudeSdkLater) {
7121
+ // pip's own lines ("Requirement already satisfied…", "Collecting…") are noise here: keep gu's.
7122
+ const pipNoise = /^(Requirement already satisfied|Collecting|Downloading|Using cached|Installing collected|Successfully installed|Obtaining|Preparing|Building|Created wheel|Stored in directory|WARNING|\[notice\]|\s*[━╸─]+)/i;
7123
+ await md.ensureClaudeSdk({
7124
+ log: (l) => { const t = String(l ?? "").trim(); if (t && !pipNoise.test(t)) console.log(GUTTER + dim(t)); },
7125
+ confirm: (q) => askYesNo(` ${q}`, true),
7126
+ });
7127
+ console.log("");
7128
+ }
7012
7129
  }
7013
7130
 
7014
7131
  /**
@@ -7021,7 +7138,7 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
7021
7138
  * · DID SAVING EVICT ANOTHER PROVIDER? An API older than row ids keys a backend by its KIND,
7022
7139
  * so a second openai row replaced the first with a 200. Said BEFORE the success line.
7023
7140
  */
7024
- async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locality, model, backendId, key, keyName }) {
7141
+ async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locality, model, backendId, key, keyName, extras = [] }) {
7025
7142
  const cwd = resolve(process.cwd());
7026
7143
  const after = await withPendingStatus("checking it is offered", () =>
7027
7144
  fetchAgentPayload([{ role: "user", content: "ping" }]).catch(() => null)
@@ -7070,15 +7187,23 @@ async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locali
7070
7187
  // that was just entered, which reads as the save having failed.
7071
7188
  codingKeyMissingKnown = false;
7072
7189
  console.log(accent(` ✓ coding model → ${model}`) + dim(` · ${host}`));
7073
- if (key) console.log(dim(` API key ${keyName || "saved"} ····${key.slice(-4)} · ${host}'s key\n`));
7190
+ if (key) console.log(dim(` API key ${keyName || "saved"} ····${key.slice(-4)}`));
7191
+ // The extras were ticked a screen ago and never mentioned again (GH #397) — say where they went.
7192
+ if (extras.length) console.log(dim(` also in /model: ${extras.join(", ")}`));
7193
+ console.log("");
7074
7194
  // A HOSTED SERVER NEEDS ITS WINDOW SET: with nothing set gu assumes 24,000 prompt tokens and
7075
7195
  // compacts there — on a large hosted model that throws history away every turn.
7076
7196
  if (ps.shouldMentionContextWindow(locality)) {
7077
- console.log(GUTTER + dim("tip: /context-window — hosted servers do not report their size,"));
7078
- console.log(GUTTER + dim(" so gu assumes 24,000 prompt tokens and compacts there.\n"));
7197
+ console.log(GUTTER + dim("tip: set this model's context size with /context-window —"));
7198
+ console.log(GUTTER + dim(" hosted providers don't report it, so gu assumes 24k tokens.\n"));
7079
7199
  }
7080
7200
  const t = await fetchOutputTokenTotals(cwd);
7081
7201
  { const g = pickGlobalTotal(t); if (g !== null) globalOutputTokens = g; }
7202
+ // The bar still said "coding model not set · /model" under the success line of the FIRST model
7203
+ // added on a fresh install, until the next turn repainted it (GH #397).
7204
+ noModelAtAll = after?.payload ? nothingToAnswerWith(after.payload) : false;
7205
+ if (after?.payload) agentMissing = after.payload.agentMissing === true;
7206
+ if (barPinned()) paintBar();
7082
7207
  }
7083
7208
 
7084
7209
 
@@ -7181,11 +7306,13 @@ async function chooseModel() {
7181
7306
  // choices while still occupying its URL, so sending this branch straight to the ADD flow
7182
7307
  // would meet "already configured here" and dead-end. The manager fixes such a row, and falls
7183
7308
  // through to add when there is genuinely nothing there.
7184
- console.log(dim(" No coding model configured."));
7309
+ // As the picker's header (GH #397), so "← back" re-opening this menu does not stack it.
7185
7310
  const go = await pickFromList(
7186
- [dim("leave it"), "add or change a provider — Claude API, OpenAI, Kimi…"],
7187
- 1
7311
+ [dim("leave it"), "add a provider — Claude API, OpenAI, Kimi, Ollama…"],
7312
+ 1,
7313
+ { header: () => [dim(" No coding model configured.")], headerRows: 1 }
7188
7314
  );
7315
+ if (go !== 1) console.log(dim(" No coding model configured."));
7189
7316
  if (go === 1 && (await manageCodingProviders({ back: true })) === BACK) return chooseModel();
7190
7317
  if (go !== 1) console.log("");
7191
7318
  return;
@@ -7194,16 +7321,10 @@ async function chooseModel() {
7194
7321
  // provider manager is where another one is added (an Ollama box is "another URL…").
7195
7322
  // #412 removed "change the Ollama server this machine uses…" from all three menus: it re-asked
7196
7323
  // the agent, embedder and coder servers in one go, which confused people picking a model.
7197
- if (choices.length === 1) {
7198
- console.log(dim(` Only one coding model is available: ${choices[0].model}.`));
7199
- const go = await pickFromList(
7200
- [dim("keep it"), "add or change a provider — Claude API, OpenAI, Kimi…"],
7201
- 0
7202
- );
7203
- if (go === 1 && (await manageCodingProviders({ back: true })) === BACK) return chooseModel();
7204
- if (go !== 1) console.log("");
7205
- return;
7206
- }
7324
+ // GH #397: ONE model used to get its own screen ("Only one coding model is available: X ·
7325
+ // keep it / add or change a provider"). Set up from /agent-model first, that was the agent's
7326
+ // model — and the other 12 coders on the same provider were three menus away. The full menu
7327
+ // below already has "pick another model on <host>…", keys and add, so one model uses it too.
7207
7328
  const active = sessionCodingModel || def;
7208
7329
  // Arrow-key picker (↑/↓ + Enter) — pre-highlight the active model, tag the default, and
7209
7330
  // name the backend when more than one is in play (the same model name can appear twice).
@@ -7212,6 +7333,9 @@ async function chooseModel() {
7212
7333
  // OpenAI-compatible providers are both "OpenAI-compatible", so the kind names neither.
7213
7334
  const { labels, tags, showTag } = codingChoiceLabels(choices, def);
7214
7335
  const activeIdx = choices.findIndex((c) => c.id === active);
7336
+ // GH #397: "(default)" marked the account default while this folder used another model, and
7337
+ // nothing said which one runs here. The one in use is marked.
7338
+ if (activeIdx >= 0 && choices[activeIdx].id !== def) labels[activeIdx] += accent(" (in use here)");
7215
7339
  // THE CURRENT CODER'S KEYS, ON THIS LIST (#248) — the manager ctrl+k opens, for a provider that
7216
7340
  // takes a key (OpenAI-compatible, ollama.com). Before the add row, so no model row moves.
7217
7341
  const activeChoice = choices[activeIdx >= 0 ? activeIdx : choices.findIndex((c) => c.id === def)];
@@ -7239,8 +7363,11 @@ async function chooseModel() {
7239
7363
  // enough in name to have already been mixed up. The fix is to say which one THIS is — not to
7240
7364
  // print the other two here: the agent model and the embedder are independent settings with
7241
7365
  // 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 });
7366
+ const idx = await pickFromList(labels, activeIdx >= 0 ? activeIdx : 0, {
7367
+ footer: ESC_FOOTER,
7368
+ header: () => [dim(` Choose the CODING model — the one that writes code (${PICK_ARROWS}):`)],
7369
+ headerRows: 1,
7370
+ });
7244
7371
  if (idx < 0) {
7245
7372
  console.log(dim(" (cancelled)\n"));
7246
7373
  return;
@@ -9164,7 +9291,14 @@ async function chooseEmbedModel() {
9164
9291
  if (keyRequired) labels.push(actionRow(`API keys for ${host || currentUrl} — switch, add or remove…`));
9165
9292
  const ADD = labels.length;
9166
9293
  labels.push(actionRow("add a provider — OpenAI, Ollama…"));
9167
- const head = ` Embedding model (now ${current} on ${host || currentUrl})`;
9294
+ // NOTHING SET IS SAID AS NOTHING SET (GH #397). "now nomic-embed-text:latest on 127.0.0.1:11434"
9295
+ // was the DEFAULT, printed as if it were in use — on a Mac with no Ollama, where /doctor said
9296
+ // "embedder (no model) — no server configured" a moment later. The default still drives the
9297
+ // cursor and the "pick another model on …" row; the header only claims what is set.
9298
+ const embedConfigured = isSet || Boolean(String(settings.ragEmbedUrl ?? "").trim());
9299
+ const head = embedConfigured
9300
+ ? ` Embedding model (now ${current} on ${host || currentUrl})`
9301
+ : " Embedding model — none set up yet, so RAG is off. Pick one";
9168
9302
  const hint = `(${PICK_ARROWS}):`;
9169
9303
  const cols = Math.max(20, process.stdout.columns || 80);
9170
9304
  console.log(dim(head.length + 2 + hint.length < cols ? `${head} ${hint}` : `${head}\n ${hint}`));
@@ -9218,7 +9352,7 @@ async function chooseEmbedModel() {
9218
9352
  }
9219
9353
 
9220
9354
  /** 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) {
9355
+ async function saveAgentModel(agentUrl, model, host, extra = []) {
9222
9356
  const md = await import("./model-doctor.mjs");
9223
9357
  const ok = await withPendingStatus("pointing the agent model at it", () =>
9224
9358
  md.saveWorkerSettings(apiBase, workerKey, { agentModelUrl: agentUrl, agentModelName: model, agentModel: model })
@@ -9229,6 +9363,7 @@ async function saveAgentModel(agentUrl, model, host) {
9229
9363
  return false;
9230
9364
  }
9231
9365
  console.log(accent(` ✓ agent model → ${model}`) + dim(` · ${host}`));
9366
+ for (const l of extra) console.log(l);
9232
9367
  console.log(dim(" takes effect on your next question.\n"));
9233
9368
  return true;
9234
9369
  }
@@ -9237,7 +9372,9 @@ async function saveAgentModel(agentUrl, model, host) {
9237
9372
  * Ask for a key's NAME — and refuse the key itself as a name: names are printed wherever keys are
9238
9373
  * listed, so a pasted secret there is a secret on screen (reported: "API key sk-E8Iv… ····3E66").
9239
9374
  */
9240
- async function askKeyName(prompt = " key name: ") {
9375
+ // "(optional)" because nothing said so (GH #397): a bare "key name:" with no default reads as a
9376
+ // required field, while Enter has always meant "no name".
9377
+ async function askKeyName(prompt = " key name (optional): ") {
9241
9378
  const am = await import("./agent-model-command.mjs");
9242
9379
  for (let attempt = 0; attempt < 2; attempt++) {
9243
9380
  const name = (await ask(prompt)).trim();
@@ -9258,15 +9395,18 @@ async function askKeyName(prompt = " key name: ") {
9258
9395
  * "no key" (a local vLLM answers anonymously). `host` names the server in the prompt and decides
9259
9396
  * the shape check's locality: a local server accepts any string, so "abc" is not questioned there.
9260
9397
  */
9261
- async function askServerKey({ host = "", optional = false } = {}) {
9398
+ async function askServerKey({ host = "", optional = false, escBack = false, name: knownName = null } = {}) {
9262
9399
  const ps = await import("./coding-provider-setup.mjs");
9263
9400
  // Esc is said here (#249): the prompt that follows is bare text, with nothing on it saying a
9264
9401
  // way out exists — Esc cancels the whole add, nothing saved. (The line explaining what a key
9265
9402
  // name is for was removed on request.)
9266
- console.log("\n" + GUTTER + dim("Esc to cancel."));
9267
- const name = (await askKeyName()).trim();
9268
- console.log(GUTTER + dim(SECRET_KEY_HINT));
9269
- const secret = (await askSecret(` API key${host ? ` for ${host}` : ""}${name ? ` (${name})` : ""}: `)).trim();
9403
+ // GH #397: where a provider list is behind this prompt, Esc returns to it — and says so.
9404
+ // A RETRY KEEPS THE NAME (GH #397): only the secret was wrong, so only the secret is asked.
9405
+ if (knownName === null) console.log("\n" + GUTTER + dim(escBack ? "Esc goes back to the providers." : "Esc to cancel."));
9406
+ const name = knownName === null ? (await askKeyName()).trim() : String(knownName);
9407
+ const secret = (
9408
+ await askSecret(` API key${host ? ` for ${host}` : ""}${name ? ` (${name})` : ""}: `, { hint: [SECRET_KEY_HINT] })
9409
+ ).trim();
9270
9410
  if (!secret) {
9271
9411
  console.log(GUTTER + dim(optional ? "(no key — the server will be called anonymously)" : "(cancelled — nothing was saved)\n"));
9272
9412
  return { name, secret: "" };
@@ -9354,19 +9494,38 @@ async function chooseAgentModel() {
9354
9494
  const source = am.agentListSource(settings);
9355
9495
  const needsKey = am.agentSourceNeedsKey(source);
9356
9496
  for (;;) {
9357
- const labels = saved.map((c) => c.label + (c.current ? accent(" (current)") : ""));
9497
+ // The first row is the default and the way back to ONE model: the coding model talks too (GH #397).
9498
+ const labels = [
9499
+ `same as the coding model${dim(" — follows /model")}` + (current ? "" : accent(" (current)")),
9500
+ ...saved.map((c) => c.label + (c.current ? accent(" (current)") : "")),
9501
+ ];
9358
9502
  const MODELS = curHost ? labels.length : -1;
9359
9503
  if (curHost) labels.push(actionRow(`pick another model on ${curHost}…`));
9504
+ // NO SEPARATE AGENT MODEL YET: offer the coder's own server, so a talking model from the provider
9505
+ // already set up is one pick away — its saved key is reused (GH #397).
9506
+ const coderUrl = (await import("./coding-provider-setup.mjs")).ensureV1Root(String(sessionCodingUrl || defaultCodingUrl || "")).toLowerCase();
9507
+ const coderRow = curHost ? null : (saved.find((c) => c.url.toLowerCase() === coderUrl) ?? null);
9508
+ const CODER_MODELS = coderRow ? labels.length : -1;
9509
+ if (coderRow) labels.push(actionRow(`pick another model on ${coderRow.host}…`));
9360
9510
  const KEYS = curHost && needsKey ? labels.length : -1;
9361
9511
  if (KEYS >= 0) labels.push(actionRow(`API keys for ${curHost} — switch, add or remove…`));
9362
9512
  const ADD = labels.length;
9363
9513
  labels.push(actionRow("add a provider — Claude API, OpenAI, Kimi, Ollama…"));
9364
9514
  // The hint drops to its own row when the header would wrap (80 columns over SSH).
9365
- const head = ` Agent model — the one that TALKS (now ${current || "unset"}${curHost ? ` on ${curHost}` : ""})`;
9515
+ // GH #397: "(now unset)" hid that the coding model answers in the meantime; and printed
9516
+ // before the list, this title stacked each time "← back" re-opened the menu.
9517
+ const now = current
9518
+ ? `now ${current}${curHost ? ` on ${curHost}` : ""}`
9519
+ : "same as the coding model";
9520
+ const head = ` Agent model — the one that TALKS (${now})`;
9366
9521
  const hint = `(${PICK_ARROWS}):`;
9367
9522
  const cols = Math.max(20, process.stdout.columns || 80);
9368
- console.log(dim(head.length + 2 + hint.length < cols ? `${head} ${hint}` : `${head}\n ${hint}`));
9369
- const idx = await pickFromList(labels, Math.max(0, saved.findIndex((c) => c.current)), { footer: ESC_FOOTER });
9523
+ const headLines = head.length + 2 + hint.length < cols ? [`${head} ${hint}`] : [head, ` ${hint}`];
9524
+ const idx = await pickFromList(labels, saved.findIndex((c) => c.current) + 1, {
9525
+ footer: ESC_FOOTER,
9526
+ header: () => headLines.map((l) => dim(l)),
9527
+ headerRows: headLines.length,
9528
+ });
9370
9529
  if (idx < 0) {
9371
9530
  console.log(dim(" (cancelled)\n"));
9372
9531
  return;
@@ -9376,6 +9535,11 @@ async function chooseAgentModel() {
9376
9535
  if ((await agentModelOnServer({ back: true, start: "models" })) === BACK) continue;
9377
9536
  return;
9378
9537
  }
9538
+ if (idx === CODER_MODELS) {
9539
+ const dialect = coderRow.kind === "ollama" ? "ollama" : "openai";
9540
+ if ((await addServer({ role: "agent", dialect, url: coderRow.url, back: true })) === BACK) continue;
9541
+ return;
9542
+ }
9379
9543
  if (idx === KEYS) {
9380
9544
  const rowId = String(source.row?.id ?? "").trim() || String(source.row?.kind ?? "").trim();
9381
9545
  if (!rowId) return enterAgentProviderKey(source.row, curHost);
@@ -9386,7 +9550,12 @@ async function chooseAgentModel() {
9386
9550
  if ((await addServer({ role: "agent", dialect: "openai", back: true })) === BACK) continue;
9387
9551
  return;
9388
9552
  }
9389
- await useSavedAgentChoice(saved[idx]);
9553
+ if (idx === 0) {
9554
+ if (!current) console.log(dim(" already the same as the coding model — nothing changed.\n"));
9555
+ else await useCoderForAgent();
9556
+ return;
9557
+ }
9558
+ await useSavedAgentChoice(saved[idx - 1]);
9390
9559
  return;
9391
9560
  }
9392
9561
  }
@@ -9396,6 +9565,31 @@ async function chooseAgentModel() {
9396
9565
  * API finds the key by the URL (resolveKeyForRole), so nothing is re-entered — unless the server
9397
9566
  * needs a key and has none, which is asked here rather than failing on the next question.
9398
9567
  */
9568
+ /**
9569
+ * "Same as the coding model" (GH #397): clear the separate agent model, so the coding model talks
9570
+ * too — and keeps following each folder's /model pick. The way BACK to one model: before this the
9571
+ * only route was removing the whole provider (and its key).
9572
+ */
9573
+ async function useCoderForAgent() {
9574
+ const md = await import("./model-doctor.mjs");
9575
+ const saved = await withPendingStatus("saving", () =>
9576
+ md.saveWorkerSettings(apiBase, workerKey, { agentModelUrl: "", agentModelName: "", agentModel: "" })
9577
+ );
9578
+ if (!saved) {
9579
+ console.log(red(" couldn't save that — nothing changed.\n"));
9580
+ return;
9581
+ }
9582
+ if (sessionAgentKeyId) await saveFolderKeyPick("agent", "");
9583
+ console.log(accent(" ✓ agent model → same as the coding model") + dim(" · /model switches both"));
9584
+ console.log(GUTTER + dim("takes effect on your next question.\n"));
9585
+ const after = (await fetchAgentPayload([{ role: "user", content: "." }]).catch(() => null))?.payload;
9586
+ if (after) {
9587
+ agentMissing = after.agentMissing === true;
9588
+ noModelAtAll = nothingToAnswerWith(after);
9589
+ }
9590
+ if (barPinned()) paintBar();
9591
+ }
9592
+
9399
9593
  async function useSavedAgentChoice(c) {
9400
9594
  if (c.current) {
9401
9595
  console.log(dim(` already using ${c.model} on ${c.host} — nothing changed.\n`));
@@ -9499,7 +9693,7 @@ async function agentModelOnServer({ back = false, start = "menu" } = {}) {
9499
9693
  const options = am.agentRecoveryOptions({ keyProblem, host: listHost });
9500
9694
  const go = await pickFromList(
9501
9695
  options.map((o, i) => (i === 0 ? dim(o.label) : o.label)),
9502
- keyProblem ? 1 : 0,
9696
+ am.agentRecoveryDefault(options, { keyProblem, configured: Boolean(url || current) }),
9503
9697
  { back }
9504
9698
  );
9505
9699
  if (go === PICK_BACK) return BACK;
@@ -10829,8 +11023,7 @@ async function replaceCodingKey({ back = false } = {}) {
10829
11023
  (ks.stored ? dim("(replacing ") + accent(maskKey(ks.stored)) + dim(")") : dim("(none stored)")) +
10830
11024
  dim(" · Enter alone cancels, \"clear\" removes it\n")
10831
11025
  );
10832
- console.log(GUTTER + dim(SECRET_KEY_HINT));
10833
- const typed = (await askSecret(` ${ks.model} API key: `)).trim();
11026
+ const typed = (await askSecret(` ${ks.model} API key: `, { hint: [SECRET_KEY_HINT] })).trim();
10834
11027
  if (!typed) {
10835
11028
  console.log(GUTTER + dim("(cancelled — the stored key is unchanged)\n"));
10836
11029
  paintBar();
@@ -10884,7 +11077,9 @@ async function manageServerKeys({ role = "coding", backendId, host, model = "",
10884
11077
  });
10885
11078
  const ADD = rows.length;
10886
11079
  rows.push(dim("add a new key…"));
10887
- console.log("\n" + GUTTER + dim(`Keys for ${host} — ${folderLabel()}'s ${roleLabel}${model ? ` (${model})` : ""}\n`));
11080
+ // The agent model is a MACHINE setting (GH #397): "proj's agent model" named a folder it is not tied to.
11081
+ const whose = role === "agent" ? `the ${roleLabel}` : `${folderLabel()}'s ${roleLabel}`;
11082
+ console.log("\n" + GUTTER + dim(`Keys for ${host} — ${whose}${model ? ` (${model})` : ""}\n`));
10888
11083
  console.log(GUTTER + dim("Enter chooses · ctrl+d deletes the highlighted key\n"));
10889
11084
  const picked = await pickFromList(rows, Math.max(0, keys.findIndex((k) => k.id === inUse?.id)), { allowDelete: true, back });
10890
11085
  const idx = picked?.index ?? -1;
@@ -11812,6 +12007,28 @@ function pickFromList(items, initialSel = 0, opts = {}) {
11812
12007
  listTimeoutIndex = -1;
11813
12008
  listHeader = typeof opts.header === "function" ? opts.header : null;
11814
12009
  listHeaderRows = listHeader ? Math.max(0, opts.headerRows | 0) : 0;
12010
+ // A STATIC HEADER WIDER THAN THE TERMINAL IS WRAPPED AT WORDS (GH #397), not clipped: on 80
12011
+ // columns "Which model on token-plan.maas.qwencloudapi.com? (↑/↓ then Enter · Esc to c" lost
12012
+ // its way out. Done once, here, so the block keeps the constant height its redraw walks
12013
+ // depend on. A live panel (refreshMs) is already sized by its caller and is left alone.
12014
+ if (listHeader && !(opts.refreshMs > 0)) {
12015
+ const w = Math.max(20, process.stdout.columns || 80) - 1;
12016
+ let given = [];
12017
+ try { given = (listHeader(listHeaderRows) || []).slice(0, listHeaderRows); } catch { /* blank */ }
12018
+ if (given.some((l) => visibleLen(l) > w)) {
12019
+ const wrapped = given.flatMap((l) => {
12020
+ if (visibleLen(l) <= w) return [l];
12021
+ const plain = stripAnsiEscapes(l);
12022
+ const lead = /^\s*/.exec(plain)[0];
12023
+ // The key hint " (↑/↓ then Enter · Esc …)" moves to its own line whole, before any word wrap.
12024
+ const at = plain.indexOf(" (", lead.length);
12025
+ const parts = at > 0 ? [plain.slice(lead.length, at), plain.slice(at + 2)] : [plain.slice(lead.length)];
12026
+ return parts.flatMap((p) => wrapIndented(p, { width: w, indent: lead, hang: "" })).map((r) => dim(r));
12027
+ });
12028
+ listHeader = () => wrapped;
12029
+ listHeaderRows = wrapped.length;
12030
+ }
12031
+ }
11815
12032
  if (listHeader && opts.refreshMs > 0) {
11816
12033
  // A live panel (#376): redrawn in place until the picker closes.
11817
12034
  const live = setInterval(() => {
@@ -12290,6 +12507,7 @@ async function localScaffoldState() {
12290
12507
  /** Print the startup scaffold line — deferred while a turn owns the transcript. */
12291
12508
  function announceScaffold() {
12292
12509
  if (!scaffoldNotice || following) return;
12510
+ if (listPickerActive || askWaiting || !inputVisible) return; // as announceCliUpdate (GH #397)
12293
12511
  const { kind, text } = scaffoldNotice;
12294
12512
  scaffoldNotice = null;
12295
12513
  if (kind === "link") {
@@ -14201,7 +14419,12 @@ async function main() {
14201
14419
  // local mongodb is unreachable. Cloud mode failing to reach the hosted API means the
14202
14420
  // computer has lost its internet connection. Two different messages, one per mode.
14203
14421
  dbUnreachable = true;
14204
- if (isLocalApiBase(apiBase)) {
14422
+ if (isLocalApiBase(apiBase) && (isNetworkError(err) || isTimeout(err))) {
14423
+ // THE API ITSELF IS NOT ANSWERING (GH #397) — MongoDB was not even asked. Blaming the
14424
+ // database here sent the reader to check mongod while it was fine; the message right after
14425
+ // this one names the API and starts it.
14426
+ console.error(dim("(the local API isn't answering — your saved model/key choice is unavailable)"));
14427
+ } else if (isLocalApiBase(apiBase)) {
14205
14428
  console.error(dim("(cannot access local mongodb — your saved model/key choice is unavailable)"));
14206
14429
  } else {
14207
14430
  console.error(dim("(your computer has lost its internet connection — your saved model/key choice is unavailable)"));
@@ -14337,7 +14560,11 @@ async function main() {
14337
14560
  await import("./agent-model-label.mjs");
14338
14561
  // Skip the `ps` sweep entirely when settings already name the model — the overwhelmingly
14339
14562
  // common case, and startup is not the place to spend a subprocess proving it.
14340
- const agentLabel = namesAModel(p.agentModelId)
14563
+ // NO AGENT MODEL SET: the payload's agentModelId is only the account-default coder, which may
14564
+ // not be this folder's pick — naming it here said "kimi" over a folder running qwen (GH #397).
14565
+ const agentLabel = p.agentMissing === true
14566
+ ? { name: "", known: false, reason: "not-set" }
14567
+ : namesAModel(p.agentModelId)
14341
14568
  ? { name: String(p.agentModelId).trim(), known: true, reason: "settings" }
14342
14569
  : agentModelLabel({
14343
14570
  configuredId: p.agentModelId,
@@ -14598,7 +14825,7 @@ async function main() {
14598
14825
  dim(" Skipped — this machine won't SERVE models; the agent still runs here and\n") +
14599
14826
  dim(" calls your model machine over the network. Point the Agent model URL at\n") +
14600
14827
  dim(" that machine (not 127.0.0.1) — `gu status` shows what it resolves to.\n") +
14601
- dim(" `gu doctor` sets models up here · `/host-mode` undoes this.\n")
14828
+ dim(" `gu doctor` sets models up here · `gu clear` starts setup over.\n")
14602
14829
  );
14603
14830
  void st;
14604
14831
  }
@@ -14772,6 +14999,19 @@ async function main() {
14772
14999
  );
14773
15000
  } else {
14774
15001
  const started = await tryStartLocalApi();
15002
+ // CARRY ON INSTEAD OF "run gu again" (GH #397). tryStartLocalApi waited for the API to
15003
+ // answer, so the only thing left is the startup this process already abandoned — and the
15004
+ // simplest correct way to redo it is to BE a fresh gu: execve keeps the pid, the terminal
15005
+ // and the arguments. Where execve is missing (Node < 22.15, Windows) the old sentence stays.
15006
+ // ONCE: an API that answers the health check and then dies would otherwise loop forever.
15007
+ if (started && !process.env.GU_API_REOPENED && typeof process.execve === "function" && process.platform !== "win32" && process.stdin.isTTY) {
15008
+ console.error(dim(" Started it — opening gu…\n"));
15009
+ try {
15010
+ process.execve(process.execPath, [process.execPath, ...process.execArgv, ...process.argv.slice(1)], { ...process.env, GU_API_REOPENED: "1" });
15011
+ } catch {
15012
+ /* not allowed here — fall through to the sentence below */
15013
+ }
15014
+ }
14775
15015
  console.error(
14776
15016
  started
14777
15017
  ? dim(" Started it — run `gu` again.\n")
@@ -14794,6 +15034,7 @@ async function main() {
14794
15034
  }
14795
15035
  process.exit(1);
14796
15036
  }
15037
+ delete process.env.GU_API_REOPENED; // GH #397: started — the next start may re-open again
14797
15038
  console.log(wrapGutter("Ask about this repo. Type / to see commands (Tab completes). Ctrl-C aborts a running turn.", dim) + "\n");
14798
15039
  // #221: gu runs, but its own npm folder is not on PATH (npm skipped the postinstall that adds it).
14799
15040
  try {
@@ -15326,18 +15567,6 @@ async function main() {
15326
15567
  );
15327
15568
  continue;
15328
15569
  }
15329
- if (line === "/host-mode") {
15330
- const ctl2 = await import("./daemon-control.mjs");
15331
- await ctl2.setClientMode(false);
15332
- await saveSessionSkip(cwd, "skipModelSetupPrompt", false);
15333
- await saveSessionSkip(cwd, "skipDaemonPrompt", false);
15334
- console.log(
15335
- accent("host mode") +
15336
- dim(" — this machine will run turns again. Start the worker with `gu start`, ") +
15337
- dim("and `gu doctor` to set up models here.\n")
15338
- );
15339
- continue;
15340
- }
15341
15570
  if (line === "/rag" || line === "/rag-cloud" || line === "/rag-local") {
15342
15571
  // ONE QUESTION, THREE ANSWERS (task #180). This was two commands that toggled one flag,
15343
15572
  // so the command you typed did not determine the state you got — and the reply named a