@tiens.nguyen/gu-cli 1.0.895 → 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.
package/README.md CHANGED
@@ -188,7 +188,7 @@ gu fix-path | fix-powershell repair PATH / remove PowerShell's `gu` alias
188
188
  | --- | --- | --- |
189
189
  | **Free trial** | 30 days, from the first run | nothing: the token is created for you |
190
190
  | **Free plan** | 3 more months on that computer | a free account at [gucli.com](https://gucli.com) |
191
- | **Pro** | **$4 a month** or **$40 a year** (+ VAT where it applies) | hosted models (no model server to run), up to 5 computers, provider budgets |
191
+ | **Pro** | **$4 a month** or **$40 a year** (+ VAT where it applies) | hosted models (no model server to run), any number of computers, provider budgets |
192
192
 
193
193
  On Pro, the hosted models and the hosted API run on gu's servers, so the prompts and code you
194
194
  send through them go there. A free install keeps everything on your computer.
@@ -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
  // ---------------------------------------------------------------------------------------------
package/cli-token.mjs CHANGED
@@ -622,7 +622,8 @@ export function barNotice({ token, publicKeyB64, mode, now = new Date(), attempt
622
622
  // SHORT AND CALM (#292: "make it simpler … seems so serious"): the fact, then the one thing to
623
623
  // do. `urgent` is what the bar colours by — only these two need the user to act.
624
624
  if (ended) return { kind: "plan-ended", urgent: true, text: `Pro ended ${shortDate(p.paidUntil)} · renew at ${pricingUrl}` };
625
- if (Number.isInteger(p.activeDevices) && p.activeDevices > p.deviceLimit) {
625
+ // Pro has no computer limit (GH #371): an over-limit line is for the other plans only.
626
+ if (p.plan !== "pro" && Number.isInteger(p.activeDevices) && p.activeDevices > p.deviceLimit) {
626
627
  return {
627
628
  kind: "over-limit",
628
629
  urgent: true,
@@ -728,7 +729,10 @@ export function tokenSummary(payload, now = new Date()) {
728
729
  }
729
730
  if (Number.isInteger(payload.activeDevices)) {
730
731
  const n = payload.activeDevices;
731
- lines.push(`Used on ${computers(n)} in the last 30 days — the ${planName(payload.plan)} allows ${payload.deviceLimit}.`);
732
+ const paidPro = payload.plan === "pro" && payload.paidUntil && Date.parse(payload.paidUntil) > now.getTime();
733
+ lines.push(paidPro
734
+ ? `Used on ${computers(n)} in the last 30 days — Pro has no computer limit.`
735
+ : `Used on ${computers(n)} in the last 30 days — the ${planName(payload.plan)} allows ${payload.deviceLimit}.`);
732
736
  }
733
737
  lines.push(`Issued ${shortDate(payload.issuedAt)}.`);
734
738
  return lines;
@@ -827,7 +831,8 @@ export function enforcementDecision({ token, publicKeyB64, now = new Date(), sit
827
831
  );
828
832
  }
829
833
  }
830
- if (Number.isInteger(p.activeDevices) && p.activeDevices > p.deviceLimit) {
834
+ // PRO HAS NO COMPUTER LIMIT (GH #371) — not even with a token from a server that still sends a count.
835
+ if (!paid && Number.isInteger(p.activeDevices) && p.activeDevices > p.deviceLimit) {
831
836
  return {
832
837
  enforced: true,
833
838
  allowed: false,
@@ -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
 
@@ -6569,17 +6571,24 @@ def _rg_binary(env=None):
6569
6571
  """The ripgrep to use, or "" for none. GU_RG=0 turns it off; GU_RG=<path> picks one; then PATH,
6570
6572
  then gu's own copy in ~/.gonext/bin."""
6571
6573
  import os
6572
- import shutil
6573
6574
  env = os.environ if env is None else env
6574
6575
  want = (env.get("GU_RG") or "").strip()
6575
6576
  if want.lower() in ("0", "off", "false", "no"):
6576
6577
  return ""
6577
6578
  if want:
6578
6579
  return want if os.path.isfile(want) and os.access(want, os.X_OK) else ""
6579
- found = shutil.which("rg", path=env.get("PATH"))
6580
- if found:
6581
- return found
6582
- own = os.path.join(os.path.expanduser("~"), ".gonext", "bin", "rg.exe" if os.name == "nt" else "rg")
6580
+ # PATH by hand, not shutil.which: on Windows which() looks in the CURRENT folder first, and
6581
+ # gu runs inside the user's project — a repo that ships its own rg.exe would be executed.
6582
+ # Relative PATH entries ("", ".") are skipped for the same reason.
6583
+ exe = "rg.exe" if os.name == "nt" else "rg"
6584
+ for d in (env.get("PATH") or "").split(os.pathsep):
6585
+ d = d.strip().strip('"')
6586
+ if not d or not os.path.isabs(d):
6587
+ continue
6588
+ cand = os.path.join(d, exe)
6589
+ if os.path.isfile(cand) and os.access(cand, os.X_OK):
6590
+ return cand
6591
+ own = os.path.join(os.path.expanduser("~"), ".gonext", "bin", exe)
6583
6592
  return own if os.path.isfile(own) and os.access(own, os.X_OK) else ""
6584
6593
 
6585
6594
 
package/gu-repl.mjs CHANGED
@@ -46,6 +46,7 @@ import dotenv from "dotenv";
46
46
  import {
47
47
  clipVisible,
48
48
  createMouseKeyWindow,
49
+ createTurnTypingDetector,
49
50
  promptBreakdownRows,
50
51
  promptTotal,
51
52
  resumeTask,
@@ -1435,7 +1436,7 @@ rl._ttyWrite = (str, key) => {
1435
1436
  }
1436
1437
  if (verdict.action === "consume") {
1437
1438
  mouseClearTimer();
1438
- applyMouseReport(verdict.report);
1439
+ if (verdict.report) applyMouseReport(verdict.report); // null: focus in/out, a win32 record
1439
1440
  return;
1440
1441
  }
1441
1442
  if (verdict.action === "replay") {
@@ -3236,8 +3237,8 @@ function frozenZone() {
3236
3237
  // #258: typing during a turn is not sent — the row says so (an ask() label still comes first).
3237
3238
  prompt: secretInput && secretPrompt
3238
3239
  ? accent(secretPrompt)
3239
- : following && typedDuringTurn && !inlinePrompt
3240
- ? yellow(typedNotice(width))
3240
+ : turnRowNotice(width) && !inlinePrompt
3241
+ ? yellow(turnRowNotice(width))
3241
3242
  : inlinePrompt ? accent(inlinePrompt) : PROMPT,
3242
3243
  // WHILE A SECRET IS OPEN, THIS ROW IS THE PROMPT — "kimi-k3 API key: ••••" rather than
3243
3244
  // ">> ••••". Same row, same masking; what changes is that it now says what it wants. The
@@ -3246,8 +3247,8 @@ function frozenZone() {
3246
3247
  promptCols:
3247
3248
  secretInput && secretPrompt
3248
3249
  ? visibleLen(secretPrompt)
3249
- : following && typedDuringTurn && !inlinePrompt
3250
- ? visibleLen(typedNotice(width))
3250
+ : turnRowNotice(width) && !inlinePrompt
3251
+ ? visibleLen(turnRowNotice(width))
3251
3252
  : inlinePrompt
3252
3253
  ? visibleLen(inlinePrompt)
3253
3254
  : PROMPT_COLS,
@@ -4742,7 +4743,11 @@ if (process.stdin.isTTY) {
4742
4743
  // are part of a payload readline never sees. Without this, every pasted character also
4743
4744
  // re-rendered the slash overlay (the line still starts with "/" from before the paste) and
4744
4745
  // re-walked the paste store. Same for a mouse report's characters, which are not keys.
4745
- if (pasteBuffer !== null || inMouseKeyWindow()) return;
4746
+ if (pasteBuffer !== null) return;
4747
+ if (inMouseKeyWindow()) {
4748
+ turnTyping.reset(); // a held "[" was the start of that report (GH #382)
4749
+ return;
4750
+ }
4746
4751
  // Issue #120: compacting → every key is locked (Ctrl+C still arrives as SIGINT).
4747
4752
  if (compactLocked()) {
4748
4753
  rl.line = "";
@@ -4789,8 +4794,9 @@ if (process.stdin.isTTY) {
4789
4794
  if (key && key.name === "tab" && key.shift) switchEditMode();
4790
4795
  // A printable key (or Enter after one) means the user is typing a question that will not
4791
4796
  // be sent — say so in the input row now, not after they have typed the whole thing (#258).
4792
- const printable = typeof _ch === "string" && _ch.length > 0 && !/[\x00-\x1f\x7f]/.test(_ch) && !key?.ctrl && !key?.meta;
4793
- if (printable && !typedDuringTurn) {
4797
+ // Not every printable key is typing: a mouse report or cursor reply whose ESC came alone
4798
+ // arrives as "[", "^[[", "[12;5R" (Windows, GH #382) — the detector sits those out.
4799
+ if (turnTyping.feed(_ch, key) && !typedDuringTurn) {
4794
4800
  typedDuringTurn = true;
4795
4801
  if (barPinned()) paintBar();
4796
4802
  }
@@ -6190,7 +6196,11 @@ async function runModelDoctor() {
6190
6196
  return;
6191
6197
  }
6192
6198
  console.log(dim(" Asking each model to answer — the real request, not just a ping."));
6193
- 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);
6194
6204
  const results = [];
6195
6205
  for (const r of roles) {
6196
6206
  // One at a time, and SAID one at a time: these share a GPU, so a check can take a while and
@@ -6207,13 +6217,8 @@ async function runModelDoctor() {
6207
6217
  console.log(GUTTER + (res.status === "ok" ? dim(l) : res.status === "unset" ? dim(l) : yellow(l)));
6208
6218
  }
6209
6219
  }
6210
- const bad = results.filter((r) => r.status !== "ok" && r.status !== "unset");
6211
- console.log(
6212
- "\n" + GUTTER +
6213
- (bad.length === 0
6214
- ? accent("✓ every configured model answered.")
6215
- : red(`✗ ${bad.length} of ${results.length} did not answer.`))
6216
- );
6220
+ const verdict0 = dc.doctorSummary(results);
6221
+ console.log("\n" + GUTTER + (verdict0.tone === "ok" ? accent : verdict0.tone === "bad" ? red : yellow)(verdict0.text));
6217
6222
  // THE GU SERVICE TOO (#413): every model can answer and a coding turn still fail, because its
6218
6223
  // instructions come from the gu service — reported on a Windows VM where only "hi" worked.
6219
6224
  const sc = await import("./service-check.mjs");
@@ -6286,10 +6291,16 @@ async function doctorFetch(url, init) {
6286
6291
  * Returns [] when there is nothing to offer, or null when the user cancelled — which the caller
6287
6292
  * must treat as "abandon", not as "offer none", or Escape would silently save something.
6288
6293
  */
6289
- async function pickExtraModels(rest, preChecked = []) {
6290
- if (rest.length === 0) return [];
6291
- 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.
6292
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}`));
6293
6304
  const ticks = await pickManyFromList(
6294
6305
  rest,
6295
6306
  rest.map((m, i) => (already.has(m) ? i : -1)).filter((i) => i >= 0),
@@ -6327,11 +6338,11 @@ async function chooseCodingModelFrom(ps, all, question, current, { back = false
6327
6338
  // Opens on the CURRENT model where there is one — a long list that opens at the top tells the
6328
6339
  // reader nothing about which one they are already using.
6329
6340
  const at = typeof current === "string" ? list.indexOf(current) : Number(current) || 0;
6330
- console.log(
6331
- dim(` ${question} (${PICK_HINT})`) +
6332
- (showingAll || !filtered ? "" : dim(` · ${filtered} non-coding models hidden`))
6333
- );
6334
- 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 });
6335
6346
  if (idx === PICK_BACK) return BACK;
6336
6347
  if (idx < 0) return null;
6337
6348
  if (idx === list.length) {
@@ -6343,6 +6354,7 @@ async function chooseCodingModelFrom(ps, all, question, current, { back = false
6343
6354
  showingAll = true;
6344
6355
  continue;
6345
6356
  }
6357
+ console.log(dim(` → ${list[idx]}`));
6346
6358
  return list[idx];
6347
6359
  }
6348
6360
  }
@@ -6363,14 +6375,18 @@ async function chooseCodingModelFrom(ps, all, question, current, { back = false
6363
6375
  async function chooseFromModelList(all, question, current = 0, { back = false } = {}) {
6364
6376
  const rows = [...all, "type a model name…"];
6365
6377
  const at = typeof current === "string" ? all.indexOf(current) : Number(current) || 0;
6366
- console.log(dim(` ${question} (${PICK_HINT})`));
6367
- 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
+ });
6368
6383
  if (idx === PICK_BACK) return BACK;
6369
6384
  if (idx < 0) return null;
6370
6385
  if (idx === all.length) {
6371
6386
  const typed = (await ask(" model name: ")).trim();
6372
6387
  return typed || null;
6373
6388
  }
6389
+ console.log(dim(` → ${all[idx]}`));
6374
6390
  return all[idx];
6375
6391
  }
6376
6392
 
@@ -6399,8 +6415,13 @@ async function manageCodingProviders({ back = false } = {}) {
6399
6415
  );
6400
6416
  const ADD = labels.length;
6401
6417
  labels.push(actionRow("add another provider — Claude API, OpenAI, Kimi…"));
6402
- console.log(dim(` Providers — pick one to change it, or add another (${PICK_HINT}):`));
6403
- 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
+ });
6404
6425
  if (idx === PICK_BACK) return BACK;
6405
6426
  if (idx < 0) {
6406
6427
  console.log(dim(" (cancelled)\n"));
@@ -6414,11 +6435,10 @@ async function manageCodingProviders({ back = false } = {}) {
6414
6435
 
6415
6436
  const p = providers[idx];
6416
6437
  for (;;) {
6417
- console.log("\n" + GUTTER + dim(`${p.host} · ${p.model || "no model set"}`));
6418
6438
  const action = await pickFromList(
6419
6439
  [dim("keep it"), "change the model…", "change the URL…", "remove this provider…"],
6420
6440
  1,
6421
- { back: true }
6441
+ { back: true, header: () => [GUTTER + dim(`${p.host} · ${p.model || "no model set"}`)], headerRows: 1 }
6422
6442
  );
6423
6443
  if (action === PICK_BACK) break; // → the provider list
6424
6444
  if (action <= 0) {
@@ -6542,7 +6562,7 @@ async function changeProviderModel(ps, rows, p) {
6542
6562
  await syncActiveCoderWhere();
6543
6563
  console.log(GUTTER + dim("this folder's pick named the old model — cleared it."));
6544
6564
  }
6545
- 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"));
6546
6566
  }
6547
6567
 
6548
6568
  /** Point a provider at a different URL, keeping its id so saved picks still resolve. */
@@ -6633,6 +6653,18 @@ async function removeCodingProvider(ps, rows, p) {
6633
6653
  );
6634
6654
  console.log(GUTTER + dim(" they will be deleted too; a key with no provider can never be used again.\n"));
6635
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
+ }
6636
6668
  const yes = await askYesNo(
6637
6669
  `Remove ${p.host} (${p.model || "no model set"})? This cannot be undone.`,
6638
6670
  false
@@ -6644,6 +6676,7 @@ async function removeCodingProvider(ps, rows, p) {
6644
6676
  const saved = await withPendingStatus("removing", () =>
6645
6677
  md.saveWorkerSettings(apiBase, workerKey, {
6646
6678
  agentCodingBackends: ps.removeProvider(rows, p.id),
6679
+ ...(agentUses ? { agentModelUrl: "", agentModelName: "", agentModel: "" } : {}),
6647
6680
  })
6648
6681
  );
6649
6682
  if (!saved) {
@@ -6660,6 +6693,7 @@ async function removeCodingProvider(ps, rows, p) {
6660
6693
  sessionCodingKeyId = "";
6661
6694
  await saveSessionCodingKeyId(resolve(process.cwd()));
6662
6695
  }
6696
+ if (k.id === sessionAgentKeyId) await saveFolderKeyPick("agent", "");
6663
6697
  }
6664
6698
  // This folder may have been pointed at the removed provider.
6665
6699
  if (sessionCodingUrl === p.url || sessionCodingModel.startsWith(`${p.id}::`)) {
@@ -6673,8 +6707,21 @@ async function removeCodingProvider(ps, rows, p) {
6673
6707
  console.log(
6674
6708
  accent(` ✓ removed ${p.host}`) +
6675
6709
  (stranded.length ? dim(` · ${deleted} key${deleted === 1 ? "" : "s"} deleted`) : "") +
6710
+ (agentUses ? dim(" · agent model unset") : "") +
6676
6711
  "\n"
6677
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();
6678
6725
  }
6679
6726
 
6680
6727
  /**
@@ -6730,7 +6777,8 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6730
6777
  const providerPicker = !presetUrl && dialect === "openai";
6731
6778
  const ec = role === "embed" ? await import("./embed-model-command.mjs") : null;
6732
6779
  let target, host, found, need, key, keyName, typedKey, models, model;
6733
- for (;;) {
6780
+ let lastPick = 0; // GH #397: back from the key re-opens the list ON the provider just chosen
6781
+ providerLoop: for (;;) {
6734
6782
  // --- 1. the server --------------------------------------------------------------------
6735
6783
  target = null;
6736
6784
  // The dialect the URL prompt reads the address in: the caller's, or Ollama's when that row was picked.
@@ -6748,22 +6796,31 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6748
6796
  const presets = ps.providerPresets(role);
6749
6797
  // #244: an Ollama on this machine or the network is a row here, like the hosted ones — the
6750
6798
  // menus no longer have a separate "use an Ollama server…" entrance. It asks for the address.
6751
- 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;
6752
6803
  const rowsShown = [
6753
- ...presets.map((p) => `${p.name}${dim(` · ${p.url}`)}`),
6754
6804
  `Ollama — this machine or your network…${dim(" · e.g. 127.0.0.1:11434")}`,
6805
+ ...presets.map((p) => `${p.name}${dim(` · ${p.url}`)}`),
6755
6806
  "another URL…",
6756
6807
  ];
6757
6808
  // A preset names its own dialect (Ollama cloud is "ollama"); the rest are OpenAI-compatible.
6758
- console.log(dim(` Which provider? (${PICK_HINT})`));
6759
- 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 });
6760
6812
  if (pi === PICK_BACK) return BACK;
6761
6813
  if (pi < 0) {
6762
6814
  console.log(dim(" (nothing saved)\n"));
6763
6815
  return;
6764
6816
  }
6765
- 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);
6766
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"}`));
6767
6824
  }
6768
6825
  if (!target && !presetUrl) {
6769
6826
  const dflt = as.defaultServerUrl(role, askDialect, settings, existing);
@@ -6829,8 +6886,19 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6829
6886
  if (key) console.log(GUTTER + dim(`using the key "${inUse.name}" already saved for ${host}.`));
6830
6887
  }
6831
6888
  }
6832
- const askKey = async () => {
6833
- 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
+ }
6834
6902
  key = t.secret;
6835
6903
  keyName = t.name;
6836
6904
  typedKey = Boolean(t.secret);
@@ -6838,7 +6906,9 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6838
6906
  };
6839
6907
  if (!key && (need !== "none" || newKey)) {
6840
6908
  if (need === "required") console.log(GUTTER + dim(`${host} needs an API key.`));
6841
- if (!(await askKey())) return;
6909
+ const got = await askKey();
6910
+ if (got === BACK_TO_PROVIDERS) continue providerLoop;
6911
+ if (!got) return;
6842
6912
  }
6843
6913
 
6844
6914
  // --- 3. its models, listed with that key ----------------------------------------------
@@ -6855,10 +6925,18 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6855
6925
  }
6856
6926
  });
6857
6927
  let probe = await list();
6858
- // THE STORED KEY WAS REFUSED — ask for the right one once, rather than saving nothing.
6859
- if ((probe.status === 401 || probe.status === 403) && !typedKey && key) {
6860
- console.log(yellow(` ${host} refused the stored key (HTTP ${probe.status}) — enter the right one.`));
6861
- 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;
6862
6940
  probe = await list();
6863
6941
  }
6864
6942
  if (probe.status || probe.err) {
@@ -6935,6 +7013,7 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6935
7013
  }
6936
7014
 
6937
7015
  // --- 5. save: the server, its key, then the role --------------------------------------
7016
+ let alsoInModel = false;
6938
7017
  let rowId = found?.id ?? "";
6939
7018
  let rows = existing;
6940
7019
  // A NEW ROW ONLY WHEN SOMETHING NEEDS ONE: the coder is picked through its row, and a key is
@@ -6961,7 +7040,8 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6961
7040
  console.log(GUTTER + dim("if this machine's local API is old, restart it with `gu api restart`.\n"));
6962
7041
  return;
6963
7042
  }
6964
- 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;
6965
7045
  }
6966
7046
  if (typedKey) {
6967
7047
  const id = await withPendingStatus("saving the key", () => addCodingKey(keyName, key, rowId));
@@ -6974,14 +7054,15 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6974
7054
  // the stored one was refused: it is the server's key from now on, for every role and folder.
6975
7055
  if (found) await setServerDefaultKey(rowId, id);
6976
7056
  }
6977
- 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)}`) : "";
6978
7058
 
6979
7059
  if (role === "agent") {
6980
7060
  // A MACHINE SETTING, not a folder pick: the agent is what gu talks to in every folder. Saved
6981
7061
  // as URL + NAME, the pair app.ts matches back to the server row to find the key.
6982
- if (await saveAgentModel(target.roleUrl("agent"), model, host)) {
6983
- if (keyLine) console.log(keyLine + "\n");
6984
- 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 */
6985
7066
  } else if (rows !== existing || typedKey) {
6986
7067
  // The SERVER landed — say so, or the user re-enters a key that is already stored.
6987
7068
  console.log(yellow(` ${host} was saved, but the agent model was not pointed at it.`));
@@ -7175,11 +7256,13 @@ async function chooseModel() {
7175
7256
  // choices while still occupying its URL, so sending this branch straight to the ADD flow
7176
7257
  // would meet "already configured here" and dead-end. The manager fixes such a row, and falls
7177
7258
  // through to add when there is genuinely nothing there.
7178
- 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.
7179
7260
  const go = await pickFromList(
7180
7261
  [dim("leave it"), "add or change a provider — Claude API, OpenAI, Kimi…"],
7181
- 1
7262
+ 1,
7263
+ { header: () => [dim(" No coding model configured.")], headerRows: 1 }
7182
7264
  );
7265
+ if (go !== 1) console.log(dim(" No coding model configured."));
7183
7266
  if (go === 1 && (await manageCodingProviders({ back: true })) === BACK) return chooseModel();
7184
7267
  if (go !== 1) console.log("");
7185
7268
  return;
@@ -7233,8 +7316,11 @@ async function chooseModel() {
7233
7316
  // enough in name to have already been mixed up. The fix is to say which one THIS is — not to
7234
7317
  // print the other two here: the agent model and the embedder are independent settings with
7235
7318
  // their own commands, and listing them together implies a relationship that does not exist.
7236
- console.log(dim(` Choose the CODING model — the one that writes code (${PICK_ARROWS}):`));
7237
- 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
+ });
7238
7324
  if (idx < 0) {
7239
7325
  console.log(dim(" (cancelled)\n"));
7240
7326
  return;
@@ -8869,8 +8955,13 @@ async function refreshLogsSize() {
8869
8955
  // What is collected and how it is sent is support-bundle.mjs; this is the terminal half only.
8870
8956
  let supportBusy = false;
8871
8957
  async function runSupport() {
8958
+ // DURING A TURN (GH #382): it used to print one dim line into the turn's output — easy to
8959
+ // miss, then nothing happened, and a stuck turn is exactly when someone wants support. Now it
8960
+ // is QUEUED: the input row says so (with the way out, Ctrl-C) and it opens when the turn ends.
8872
8961
  if (following) {
8873
- console.log("\n" + GUTTER + dim("A turn is running - /support is available when it finishes (Ctrl-C stops it)."));
8962
+ supportAfterTurn = true;
8963
+ if (barPinned()) paintBar();
8964
+ else console.log("\n" + GUTTER + yellow(SUPPORT_AFTER_TURN_NOTICE.trim()));
8874
8965
  return;
8875
8966
  }
8876
8967
  if (supportBusy || listPickerActive || approvalActive || askWaiting) return;
@@ -9153,7 +9244,14 @@ async function chooseEmbedModel() {
9153
9244
  if (keyRequired) labels.push(actionRow(`API keys for ${host || currentUrl} — switch, add or remove…`));
9154
9245
  const ADD = labels.length;
9155
9246
  labels.push(actionRow("add a provider — OpenAI, Ollama…"));
9156
- 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";
9157
9255
  const hint = `(${PICK_ARROWS}):`;
9158
9256
  const cols = Math.max(20, process.stdout.columns || 80);
9159
9257
  console.log(dim(head.length + 2 + hint.length < cols ? `${head} ${hint}` : `${head}\n ${hint}`));
@@ -9207,7 +9305,7 @@ async function chooseEmbedModel() {
9207
9305
  }
9208
9306
 
9209
9307
  /** Save the agent model on this machine: the URL the API sends turns to, and the model name. */
9210
- async function saveAgentModel(agentUrl, model, host) {
9308
+ async function saveAgentModel(agentUrl, model, host, extra = []) {
9211
9309
  const md = await import("./model-doctor.mjs");
9212
9310
  const ok = await withPendingStatus("pointing the agent model at it", () =>
9213
9311
  md.saveWorkerSettings(apiBase, workerKey, { agentModelUrl: agentUrl, agentModelName: model, agentModel: model })
@@ -9218,6 +9316,7 @@ async function saveAgentModel(agentUrl, model, host) {
9218
9316
  return false;
9219
9317
  }
9220
9318
  console.log(accent(` ✓ agent model → ${model}`) + dim(` · ${host}`));
9319
+ for (const l of extra) console.log(l);
9221
9320
  console.log(dim(" takes effect on your next question.\n"));
9222
9321
  return true;
9223
9322
  }
@@ -9226,7 +9325,9 @@ async function saveAgentModel(agentUrl, model, host) {
9226
9325
  * Ask for a key's NAME — and refuse the key itself as a name: names are printed wherever keys are
9227
9326
  * listed, so a pasted secret there is a secret on screen (reported: "API key sk-E8Iv… ····3E66").
9228
9327
  */
9229
- 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): ") {
9230
9331
  const am = await import("./agent-model-command.mjs");
9231
9332
  for (let attempt = 0; attempt < 2; attempt++) {
9232
9333
  const name = (await ask(prompt)).trim();
@@ -9247,13 +9348,15 @@ async function askKeyName(prompt = " key name: ") {
9247
9348
  * "no key" (a local vLLM answers anonymously). `host` names the server in the prompt and decides
9248
9349
  * the shape check's locality: a local server accepts any string, so "abc" is not questioned there.
9249
9350
  */
9250
- async function askServerKey({ host = "", optional = false } = {}) {
9351
+ async function askServerKey({ host = "", optional = false, escBack = false, name: knownName = null } = {}) {
9251
9352
  const ps = await import("./coding-provider-setup.mjs");
9252
9353
  // Esc is said here (#249): the prompt that follows is bare text, with nothing on it saying a
9253
9354
  // way out exists — Esc cancels the whole add, nothing saved. (The line explaining what a key
9254
9355
  // name is for was removed on request.)
9255
- console.log("\n" + GUTTER + dim("Esc to cancel."));
9256
- 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);
9257
9360
  console.log(GUTTER + dim(SECRET_KEY_HINT));
9258
9361
  const secret = (await askSecret(` API key${host ? ` for ${host}` : ""}${name ? ` (${name})` : ""}: `)).trim();
9259
9362
  if (!secret) {
@@ -9488,7 +9591,7 @@ async function agentModelOnServer({ back = false, start = "menu" } = {}) {
9488
9591
  const options = am.agentRecoveryOptions({ keyProblem, host: listHost });
9489
9592
  const go = await pickFromList(
9490
9593
  options.map((o, i) => (i === 0 ? dim(o.label) : o.label)),
9491
- keyProblem ? 1 : 0,
9594
+ am.agentRecoveryDefault(options, { keyProblem, configured: Boolean(url || current) }),
9492
9595
  { back }
9493
9596
  );
9494
9597
  if (go === PICK_BACK) return BACK;
@@ -11148,11 +11251,28 @@ let following = false;
11148
11251
  // typed while the previous one was still queued simply vanished. Now the input row says so the
11149
11252
  // moment a key lands, and keeps saying it until the turn ends. Nothing is queued or sent.
11150
11253
  let typedDuringTurn = false;
11254
+ const turnTyping = createTurnTypingDetector(); // what counts as typing during a turn (GH #382)
11151
11255
  const TYPED_DURING_TURN_NOTICE = "(a turn is running — typing is not sent; type it again when it finishes) ";
11152
11256
  const TYPED_DURING_TURN_SHORT = "(turn running — not sent) ";
11257
+ // "? support" clicked during a turn (GH #382): it opens when the turn ends; Ctrl-C ends it now.
11258
+ let supportAfterTurn = false;
11259
+ const SUPPORT_AFTER_TURN_NOTICE = "(support opens when this turn ends — Ctrl-C stops the turn now) ";
11260
+ const SUPPORT_AFTER_TURN_SHORT = "(support after this turn — Ctrl-C) ";
11261
+ // After the first Ctrl-C the turn is stopping; a worker that never answers needs a second one.
11262
+ const SUPPORT_AFTER_STOP_NOTICE = "(support opens when this turn ends — Ctrl-C again to stop waiting) ";
11263
+ const SUPPORT_AFTER_STOP_SHORT = "(support after this turn — Ctrl-C again) ";
11153
11264
  /** The notice that fits: the input row must never wrap because of its label (#109/#132). */
11154
11265
  const typedNotice = (width) =>
11155
11266
  visibleLen(TYPED_DURING_TURN_NOTICE) + 12 <= width ? TYPED_DURING_TURN_NOTICE : TYPED_DURING_TURN_SHORT;
11267
+ /** What the input row says during a turn instead of ">>", or "" — queued support comes first. */
11268
+ const turnRowNotice = (width) =>
11269
+ !following
11270
+ ? ""
11271
+ : supportAfterTurn
11272
+ ? cancelRequested
11273
+ ? visibleLen(SUPPORT_AFTER_STOP_NOTICE) + 12 <= width ? SUPPORT_AFTER_STOP_NOTICE : SUPPORT_AFTER_STOP_SHORT
11274
+ : visibleLen(SUPPORT_AFTER_TURN_NOTICE) + 12 <= width ? SUPPORT_AFTER_TURN_NOTICE : SUPPORT_AFTER_TURN_SHORT
11275
+ : typedDuringTurn ? typedNotice(width) : "";
11156
11276
  // Is the turn we are following one this terminal STARTED, or another terminal's that we merely
11157
11277
  // attached to (task #211)? `attached` is local to runAgentTurn, and onInterrupt — which lives in
11158
11278
  // main() — had no way to ask. So Ctrl+C while watching somebody else's agent took the "cancel my
@@ -12546,6 +12666,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12546
12666
  following = true;
12547
12667
  followingAttached = attached;
12548
12668
  typedDuringTurn = false;
12669
+ turnTyping.reset();
12549
12670
  compactSeenPasses = 0; // issue #120: python counts passes per turn
12550
12671
  followAborted = false;
12551
12672
  currentJobId = jobId;
@@ -13901,6 +14022,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
13901
14022
  following = false;
13902
14023
  followingAttached = false;
13903
14024
  typedDuringTurn = false;
14025
+ turnTyping.reset();
13904
14026
  endCompactLock(); // issue #120: never leave the terminal locked after a turn
13905
14027
  currentJobId = null;
13906
14028
  // Drop anything typed (but not submitted) during the turn — echo was muted, so it's
@@ -14171,7 +14293,12 @@ async function main() {
14171
14293
  // local mongodb is unreachable. Cloud mode failing to reach the hosted API means the
14172
14294
  // computer has lost its internet connection. Two different messages, one per mode.
14173
14295
  dbUnreachable = true;
14174
- 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)) {
14175
14302
  console.error(dim("(cannot access local mongodb — your saved model/key choice is unavailable)"));
14176
14303
  } else {
14177
14304
  console.error(dim("(your computer has lost its internet connection — your saved model/key choice is unavailable)"));
@@ -14742,6 +14869,19 @@ async function main() {
14742
14869
  );
14743
14870
  } else {
14744
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
+ }
14745
14885
  console.error(
14746
14886
  started
14747
14887
  ? dim(" Started it — run `gu` again.\n")
@@ -14764,6 +14904,7 @@ async function main() {
14764
14904
  }
14765
14905
  process.exit(1);
14766
14906
  }
14907
+ delete process.env.GU_API_REOPENED; // GH #397: started — the next start may re-open again
14767
14908
  console.log(wrapGutter("Ask about this repo. Type / to see commands (Tab completes). Ctrl-C aborts a running turn.", dim) + "\n");
14768
14909
  // #221: gu runs, but its own npm folder is not on PATH (npm skipped the postinstall that adds it).
14769
14910
  try {
@@ -14875,6 +15016,7 @@ async function main() {
14875
15016
  // not just detach. The worker sees the "cancelled" status and kills the Python
14876
15017
  // agent; the poll loop then returns cleanly with the "(cancelled)" note.
14877
15018
  cancelRequested = true;
15019
+ if (supportAfterTurn && barPinned()) paintBar(); // its row now says "Ctrl-C again" (GH #382)
14878
15020
  const jobId = currentJobId;
14879
15021
  // #307: while the question is still held (nothing printed yet), nothing goes to the
14880
15022
  // transcript either — a quick change of mind leaves no trace.
@@ -14981,6 +15123,11 @@ async function main() {
14981
15123
  // the turn ends; a queued line carries no Enter keypress, so its re-prompt overwrites
14982
15124
  // in place (\r + refresh) rather than stacking ">> >> >> " on one row.
14983
15125
  let lineRaw;
15126
+ // "? support" was clicked during the turn that just ended (GH #382) — however it ended.
15127
+ if (supportAfterTurn) {
15128
+ supportAfterTurn = false;
15129
+ _lineQueue.unshift("/support");
15130
+ }
14984
15131
  mainPromptWaiting = true; // a finished background shell may start a recap turn now (#362)
14985
15132
  for (;;) {
14986
15133
  lineRaw = await nextLine();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.895",
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
  /**
@@ -434,6 +434,68 @@ export function promptBreakdownRows(b) {
434
434
  }));
435
435
  }
436
436
 
437
+ /**
438
+ * DID THE USER TYPE DURING THE TURN? (GH #382) — for the "(a turn is running — typing is not
439
+ * sent …)" notice. A printable key used to be enough, but the terminal also sends text nobody
440
+ * typed: a mouse report or a cursor-position reply already on its way when Enter started the
441
+ * turn. On Windows its ESC often comes alone (node gives it up after 500ms) and the rest arrives
442
+ * as ordinary characters — "[", "^[[", "[12;5R" — which showed the notice with no key pressed.
443
+ *
444
+ * A lone Esc, "[" or "^" starts a held run. It stays a reply while the characters fit a mouse
445
+ * report or cursor reply ("[", "<", "^", digits, ";") and ends silently on its "M", "m" or "R".
446
+ * Anything else means it was typing after all: `feed` → true. Enter after a held "[" too.
447
+ * The joined mouse shape never gets here (createMouseKeyWindow); the caller calls `reset()` for
448
+ * the keys that window takes, which includes the rest of a split report after its "[".
449
+ */
450
+ export function createTurnTypingDetector({ maxRun = 32 } = {}) {
451
+ let active = false;
452
+ let held = "";
453
+ const reset = () => {
454
+ active = false;
455
+ held = "";
456
+ };
457
+ return {
458
+ /** → true when this key shows the user typed. Feed every key of the turn, in order. */
459
+ feed(ch, key) {
460
+ if (key?.name === "escape") {
461
+ active = true;
462
+ held = "";
463
+ return false;
464
+ }
465
+ if (key && (key.name === "return" || key.name === "enter")) {
466
+ const typed = active && held !== "";
467
+ reset();
468
+ return typed;
469
+ }
470
+ const printable =
471
+ typeof ch === "string" && ch.length > 0 && !/[\x00-\x1f\x7f]/.test(ch) && !key?.ctrl && !key?.meta;
472
+ if (!printable) {
473
+ reset();
474
+ return false;
475
+ }
476
+ if (!active) {
477
+ if (ch === "[" || ch === "^") {
478
+ active = true;
479
+ held = ch;
480
+ return false;
481
+ }
482
+ return true;
483
+ }
484
+ if (/^[MmR]$/.test(ch) && held.includes("[")) {
485
+ reset();
486
+ return false;
487
+ }
488
+ if (/^[[<^\d;]$/.test(ch) && held.length < maxRun && (held !== "" || ch === "[" || ch === "^")) {
489
+ held += ch;
490
+ return false;
491
+ }
492
+ reset();
493
+ return true;
494
+ },
495
+ reset,
496
+ };
497
+ }
498
+
437
499
  /**
438
500
  * A MOUSE REPORT IS NOT TYPING — the window in which keypress events belong to the mouse.
439
501
  *
@@ -495,26 +557,50 @@ export function createMouseKeyWindow({ maxRun = 24 } = {}) {
495
557
  * "^" is only held while the terminal is reporting the mouse (`plainLeads`), so typing them is
496
558
  * not slowed down otherwise.
497
559
  */
498
- const MOUSE_LEADS = ["\x1b[<", "^[[<", "[<"];
499
560
  const MOUSE_BODY_DONE = /^(\d+;\d+;\d+[Mm])$/;
500
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
+ */
501
574
  function mouseTextVerdict(text, plainLeads) {
502
- for (const lead of MOUSE_LEADS) {
503
- if (!plainLeads && lead !== "\x1b[<") continue;
575
+ for (const lead of ["\x1b[", "^[[", "["]) {
576
+ if (!plainLeads && lead !== "\x1b[") continue;
504
577
  if (lead.startsWith(text)) return { kind: "partial" };
505
578
  if (!text.startsWith(lead)) continue;
506
579
  const body = text.slice(lead.length);
507
- if (MOUSE_BODY_DONE.test(body)) return { kind: "done", report: body };
508
- 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" };
509
592
  }
510
593
  return { kind: "bad" };
511
594
  }
512
595
  export function createMouseReportReader() {
513
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 ?? "");
514
600
  const textOf = (str, key) =>
515
- 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 : "";
516
602
  const startsOne = (str, key, plainLeads) =>
517
- key?.code === "[<" || key?.name === "escape" || (plainLeads && (str === "[" || str === "^"));
603
+ key?.code === "[<" || unnamedCsi(key) || key?.name === "escape" || (plainLeads && (str === "[" || str === "^"));
518
604
  return {
519
605
  holding: () => held !== null,
520
606
  /**
@@ -533,6 +619,8 @@ export function createMouseReportReader() {
533
619
  const v = mouseTextVerdict(textOf(str, key), plainLeads);
534
620
  if (v.kind === "partial") return { action: "hold" };
535
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 };
536
624
  return { action: "pass" };
537
625
  }
538
626
  held.push([str, key]);
@@ -564,7 +652,9 @@ export function createMouseReportReader() {
564
652
 
565
653
  /** Last line of defence at submit: a report that got past every reader is not a question. */
566
654
  export function stripLeakedMouseReports(line) {
567
- 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, "");
568
658
  }
569
659
 
570
660
  /**