@tiens.nguyen/gu-cli 1.0.793 → 1.0.795

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.
@@ -992,6 +992,80 @@ def _web_search_impl(query, max_results=5, searxng_url="",
992
992
  # the text still reaches both UIs, the prefix tells them it is a failure, not a reply.
993
993
  TURN_FAILED = "__TURN_FAILED__::"
994
994
 
995
+ # ANOTHER MODEL STOOD IN (task #343). Reported live: the coding model had a problem, a different
996
+ # model answered, and nothing on screen said so — the turn "worked", only with a model nobody
997
+ # picked. Every place in this file where one model takes over from another now says so with ONE
998
+ # event, a step carrying this prefix and a JSON body, so the terminal can hold it in yellow on the
999
+ # bottom bar. A step, like __RAGSTATE__::, so it rides the job's chunk stream and still arrives
1000
+ # when the agent runs on a different machine than the terminal.
1001
+ #
1002
+ # What is NOT a fallback and must never be reported as one: a retry on the SAME model, and the
1003
+ # 429 / 502 exits below — those end the turn as failed, no other model answers.
1004
+ MODEL_FALLBACK = "__MODELFALLBACK__::"
1005
+
1006
+
1007
+ def _fallback_reason(err) -> str:
1008
+ """Why a model was given up on, in a few words that fit a status bar — never a stack of
1009
+ exception text (an nginx error is a whole HTML page). The status code when there is one,
1010
+ else the kind of failure, else the first line clipped."""
1011
+ if err is None:
1012
+ return ""
1013
+ status = _http_status_of(err)
1014
+ text = str(err)
1015
+ low = text.lower()
1016
+ if status in (401, 403):
1017
+ return f"{status} key refused"
1018
+ if status == 404:
1019
+ return "404 model not found"
1020
+ if status == 429 or "overloaded" in low or "rate limit" in low:
1021
+ return "429 overloaded"
1022
+ if status and status >= 500:
1023
+ return f"{status} server error"
1024
+ if "timed out" in low or "timeout" in low:
1025
+ return "timed out"
1026
+ if "empty response" in low:
1027
+ return "empty reply"
1028
+ if "context" in low and ("length" in low or "window" in low or "too long" in low or "exceed" in low):
1029
+ return "context too large"
1030
+ if "connection" in low or "connect" in low or "refused" in low or "unreachable" in low:
1031
+ return "could not connect"
1032
+ if status:
1033
+ return f"HTTP {status}"
1034
+ first = next((ln.strip() for ln in text.splitlines() if ln.strip()), "")
1035
+ first = re.sub(r"<[^>]+>", " ", first)
1036
+ first = re.sub(r"\s+", " ", first).strip()
1037
+ return first if len(first) <= 48 else first[:47].rstrip() + "…"
1038
+
1039
+
1040
+ def _fallback_event(role, from_model, to_model, reason, *, tools=True, answered=True) -> str:
1041
+ """The step text for one fallback. Pure.
1042
+
1043
+ role "coder" — the coding model was given up on; "chat" — the chat model was
1044
+ from/to the model that had the problem, and the one used instead
1045
+ reason a few words (see _fallback_reason)
1046
+ tools False when the stand-in answered as a bare chat reply — no files, no commands
1047
+ answered False when the stand-in failed too: "fell back" is only said when it answered
1048
+ """
1049
+ body = {
1050
+ "role": "chat" if role == "chat" else "coder",
1051
+ "from": str(from_model or "").strip(),
1052
+ "to": str(to_model or "").strip(),
1053
+ "reason": str(reason or "").strip(),
1054
+ "tools": bool(tools),
1055
+ "answered": bool(answered),
1056
+ }
1057
+ return MODEL_FALLBACK + json.dumps(body, ensure_ascii=False)
1058
+
1059
+
1060
+ def _report_fallback(role, from_model, to_model, reason, *, tools=True, answered=True) -> None:
1061
+ """Tell the terminal another model stood in. Logged too, so a run log shows it."""
1062
+ _log(
1063
+ f"model fallback: {role} {from_model!r} -> {to_model!r} ({reason or 'no reason'})"
1064
+ f"{'' if tools else ' — no tools'}{'' if answered else ' — it failed too'}"
1065
+ )
1066
+ _emit({"type": "step", "text": _fallback_event(
1067
+ role, from_model, to_model, reason, tools=tools, answered=answered)})
1068
+
995
1069
 
996
1070
  class _AgentConfigError(RuntimeError):
997
1071
  """Deterministic setup error (e.g. wrong model name/URL). Not retryable and not
@@ -1473,6 +1547,24 @@ def _resolve_coding_target(agent_base_url, agent_model_id, raw_coding_base, raw_
1473
1547
  return agent_base_url, agent_model_id
1474
1548
 
1475
1549
 
1550
+ def _coder_fell_back_to_chat(agent_base_url, raw_coding_base, resolved_base) -> bool:
1551
+ """Did _resolve_coding_target give the chat model a job a DIFFERENT coding server was chosen
1552
+ for? Pure. False when no coder is set, and false for a coder on the chat model's own server
1553
+ with no name — that is "use the chat model", chosen, not a fallback."""
1554
+ raw = (raw_coding_base or "").rstrip("/")
1555
+ if not raw or raw == (agent_base_url or "").rstrip("/"):
1556
+ return False
1557
+ return (resolved_base or "").rstrip("/") != raw
1558
+
1559
+
1560
+ def _coder_label(model_id, base_url) -> str:
1561
+ """What to call a coder on the bar: its name, or — when it never told us one — its host."""
1562
+ if (model_id or "").strip():
1563
+ return model_id.strip()
1564
+ host = re.sub(r"^[a-z]+://", "", (base_url or "").strip(), flags=re.I).split("/")[0]
1565
+ return host or "the coding model"
1566
+
1567
+
1476
1568
  def _coding_backend_flags(backend):
1477
1569
  """Everything the resolved backend decides, derived in ONE place (task #117 → #123).
1478
1570
 
@@ -2943,6 +3035,7 @@ def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str,
2943
3035
  targets.append((fallback_base_url, fallback_model_id, fallback_api_key or api_key))
2944
3036
 
2945
3037
  last_err = None
3038
+ first_err = None
2946
3039
  for i, (b, mid, key) in enumerate(targets):
2947
3040
  emitted_here = False
2948
3041
 
@@ -2981,12 +3074,22 @@ def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str,
2981
3074
  last_err = RuntimeError(f"{mid} returned an empty response")
2982
3075
  except Exception as e: # noqa: BLE001
2983
3076
  last_err = e
3077
+ if i == 0:
3078
+ first_err = last_err # why the model that was ASKED gave nothing
3079
+ else:
3080
+ # The stand-in did not answer either (#343) — take back "it answered".
3081
+ _report_fallback("chat", targets[0][1], mid, _fallback_reason(first_err),
3082
+ tools=False, answered=False)
2984
3083
  if emitted_here:
2985
3084
  # Part of THIS attempt's answer already streamed to the user — don't
2986
3085
  # silently splice a DIFFERENT model's output after a partial one.
2987
3086
  break
2988
3087
  if i + 1 < len(targets):
2989
3088
  nb, nmid, _ = targets[i + 1] # the model we're about to try next
3089
+ # SAID AT THE SWITCH (#343), before the stand-in streams: a step that arrived after
3090
+ # its answer would reopen the reasoning block under the reply. Corrected just above
3091
+ # if it fails too.
3092
+ _report_fallback("chat", mid, nmid, _fallback_reason(last_err), tools=False)
2990
3093
  _log(f"plain reply: {mid} @ {b} failed ({last_err}); "
2991
3094
  f"falling back to {nmid} @ {nb}")
2992
3095
  # Every target failed or returned nothing — raise rather than return a sentinel
@@ -7671,6 +7774,11 @@ def run_agent_chat(cfg):
7671
7774
  agent_base_url, agent_model_id, raw_coding_base, raw_coding_model,
7672
7775
  agent_api_key=agent_api_key, coding_api_key=coding_api_key,
7673
7776
  )
7777
+ # A coder WAS chosen and the chat model is doing its work (#343): its server would not say
7778
+ # which model it serves. Said when the agent path starts — a plain chat turn does no coding,
7779
+ # so there is nothing to have fallen back from.
7780
+ _coder_unresolved = _coder_fell_back_to_chat(
7781
+ agent_base_url, raw_coding_base, coding_base_url)
7674
7782
  # Task #108: an OpenAI-compatible coder REQUIRES an explicit model name (its
7675
7783
  # /v1/chat/completions needs a `model` id, and we won't silently reuse the MLX chat
7676
7784
  # model's name against a cloud endpoint). Fail with a clear, actionable message.
@@ -8490,6 +8598,9 @@ def run_agent_chat(cfg):
8490
8598
  # Agent path — from here all step events go into <think>.
8491
8599
  _log("router: agent (HTTP tool use needed)")
8492
8600
  _emit({"type": "step", "text": "Choosing a tool…"})
8601
+ if _coder_unresolved:
8602
+ _report_fallback("coder", _coder_label(raw_coding_model, raw_coding_base), agent_model_id,
8603
+ "its server named no model")
8493
8604
 
8494
8605
  # Prepend explicit tool instructions so small models pick the right tool, never
8495
8606
  # fabricate URLs/responses, and always terminate with final_answer().
@@ -11293,12 +11404,19 @@ def run_agent_chat(cfg):
11293
11404
  # No usable tool output (e.g. every step's code failed to parse).
11294
11405
  # Don't dead-end on a canned apology — answer from the conversation.
11295
11406
  _log("max-steps fallback → plain reply over conversation")
11407
+ # Every step was unusable and the chat model now answers from the conversation
11408
+ # alone (#343). Said BEFORE it streams — a step after the answer would reopen
11409
+ # the reasoning block under it — and corrected below if it fails too.
11410
+ _report_fallback("coder", coding_model_id, agent_model_id,
11411
+ "no usable step", tools=False)
11296
11412
  try:
11297
11413
  text = _plain_reply(
11298
11414
  messages, agent_base_url, agent_api_key, agent_model_id
11299
11415
  ).strip()
11300
11416
  except Exception as e: # noqa: BLE001
11301
11417
  _log(f"plain-reply fallback error: {e}")
11418
+ _report_fallback("coder", coding_model_id, agent_model_id,
11419
+ "no usable step", tools=False, answered=False)
11302
11420
  text = ""
11303
11421
  if not text:
11304
11422
  text = ("I couldn't complete that within the step budget. Please "
@@ -13427,10 +13545,16 @@ def run_agent_chat(cfg):
13427
13545
  # answer from the conversation rather than surfacing a raw error to the user —
13428
13546
  # but only when the fallback ACTUALLY has something useful to say.
13429
13547
  _log(f"agent error: {e} — degrading to plain reply")
13548
+ # The tool-capable loop failed and the chat model answers instead, with no tools (#343).
13549
+ # Said BEFORE it streams; corrected in the except if it fails too.
13550
+ _report_fallback("coder", coding_model_id, agent_model_id, _fallback_reason(e), tools=False)
13430
13551
  try:
13431
13552
  fallback = _plain_reply(messages, agent_base_url, agent_api_key, agent_model_id).strip()
13432
13553
  except Exception as e2: # noqa: BLE001
13433
13554
  _log(f"plain-reply degrade error: {e2}")
13555
+ # The stand-in failed too (#343): say THAT, never "fell back".
13556
+ _report_fallback("coder", coding_model_id, agent_model_id, _fallback_reason(e),
13557
+ tools=False, answered=False)
13434
13558
  # Nothing useful to say — re-raise the ORIGINAL (more relevant) agent
13435
13559
  # failure so this becomes a genuine job failure: a red error the REPL/web
13436
13560
  # already handle correctly, NOT persisted to conversation history. Emitting
package/gu-repl.mjs CHANGED
@@ -138,6 +138,11 @@ import { unapprovedProjectServers } from "./mcp-command.mjs";
138
138
  import { readHooks } from "./hooks-config.mjs";
139
139
  import { readSkillSource, sourceLabel, SKILL_SOURCE_KEY } from "./skill-source.mjs";
140
140
  import { codingBannerLabel, isOllamaCloudUrl, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
141
+ import { thoughtPrintSeq } from "./terminal-layout.mjs";
142
+ import {
143
+ MODELFALLBACK_PREFIX, fallbackAfterTurn, fallbackHistoryLine, fallbackNotice, keyMissingNotice,
144
+ parseModelFallback,
145
+ } from "./model-fallback.mjs";
141
146
  import { codingChoiceTags, coderDisplayName, coderWhere, whereOfActiveCoder } from "./coding-choice-tags.mjs";
142
147
  import { IMAGE_NEEDS_CLAUDE, coderTakesImagesFor, clipboardImageToFile, fitImageNotice, imageMarkersForDisplay, createImageStore, imagePasteKey, imagePathFromPaste, isClaudeCoderUrl, isImagePasteKey, storeImageFile } from "./image-paste.mjs";
143
148
  import {
@@ -1635,6 +1640,28 @@ const missingNoticeRows = () => [
1635
1640
  ...(embedMissing ? ["⚠ no embed model — RAG is off · /embed-model"] : []),
1636
1641
  ];
1637
1642
 
1643
+ // ANOTHER MODEL STOOD IN (task #343). The agent reports it as a __MODELFALLBACK__:: step; it is
1644
+ // held here and shown in YELLOW on the bar — from the moment it happens, through the rest of the
1645
+ // turn, and on while it stays true: until a turn is answered with no fallback (the model works
1646
+ // again) or another model is picked. `turnFallback` is the one reported during the CURRENT turn.
1647
+ let modelFallback = null;
1648
+ let turnFallback = null;
1649
+ function setModelFallback(fb) {
1650
+ modelFallback = fb;
1651
+ if (barPinned()) paintBar();
1652
+ }
1653
+ // Its rows: the reported fallback, and — the one fallback the agent cannot report, because it
1654
+ // never sees the coder the API dropped — a coding model with no API key (task #222).
1655
+ const fallbackNoticeRows = () => {
1656
+ const width = process.stdout.columns || 80;
1657
+ return [
1658
+ ...(modelFallback ? [fallbackNotice(modelFallback, { width, gutter: GUTTER })] : []),
1659
+ ...(codingKeyMissingKnown === true
1660
+ ? [keyMissingNotice({ coder: activeCodingModelId(), width, gutter: GUTTER })]
1661
+ : []),
1662
+ ];
1663
+ };
1664
+
1638
1665
  // #307: the calm note after a quick change of mind — dim, not the ⚠ orange of a warning, and in the
1639
1666
  // bar rather than the transcript (which the whole point is to leave untouched).
1640
1667
  let stopNotice = "";
@@ -2213,6 +2240,9 @@ const UPDATE_CHECK_MS =
2213
2240
  ? Number(process.env.GONEXT_UPDATE_CHECK_MS)
2214
2241
  : 60 * 60 * 1000;
2215
2242
  async function refreshAvailableUpdates() {
2243
+ // The terminal UI tests (tests/ui) run with no network and compare whole screens: an update
2244
+ // notice that depends on what npm holds today would make every golden screen a coin toss.
2245
+ if (process.env.GU_NO_UPDATE_CHECK === "1") return;
2216
2246
  const { updatesAvailable } = await import("./version-check.mjs");
2217
2247
  const cliLatest = await (async () => {
2218
2248
  try {
@@ -2597,7 +2627,9 @@ const footerRows = () =>
2597
2627
  ? dim(clipVisible(stopNotice, (process.stdout.columns || 80) - visibleLen(GUTTER) - 1))
2598
2628
  : "",
2599
2629
  // Task #14: the skipped-setup warning — persistent (not a timed notice), red, its own row.
2600
- missing: missingNoticeRows().map((r) => red(r)),
2630
+ // …then the fallback rows (#343), YELLOW: a different model is doing the work — a warning,
2631
+ // where the red rows above mean something is not set up at all.
2632
+ missing: [...missingNoticeRows().map((r) => red(r)), ...fallbackNoticeRows().map((r) => yellow(r))],
2601
2633
  });
2602
2634
  // An ask()'s hint lines (#305), drawn in yellow under its input while it waits. Plain text.
2603
2635
  let askHint = [];
@@ -3357,7 +3389,7 @@ const thoughtWidth = () =>
3357
3389
  * Progress recedes and the answer is the only full-bright thing on screen — a page of bright
3358
3390
  * reasoning per step would bury the deliverable it is supposed to lead up to.
3359
3391
  */
3360
- function printThoughtBlock(raw, before) {
3392
+ function printThoughtBlock(raw, before, { gap = false } = {}) {
3361
3393
  if (!sessionShowThought) return false;
3362
3394
  const prose = thoughtProse(raw);
3363
3395
  if (!prose) return false; // a bare tool label or pure code — not what anyone means by a thought
@@ -3404,8 +3436,14 @@ function printThoughtBlock(raw, before) {
3404
3436
  }
3405
3437
  // Scrolled out of the buffer — fall through and print a new block rather than lose it.
3406
3438
  }
3407
- process.stdout.write(rows.map((r) => GUTTER + dim(r)).join("\n") + "\n");
3408
- // Record where it landed. Measured AFTER the write so a trim during it cannot skew the
3439
+ // ONE EMPTY LINE ABOVE A NEW THOUGHT (#344) when the row above it is not already blank.
3440
+ // Reported with a screenshot: "The download is moving…" sat directly under "✓ Command finished
3441
+ // → python3 -c …" and read as that command's OUTPUT — and under a bright "✗ Command failed" row,
3442
+ // as its error text. A thought is printed when its step's reasoning ends, BEFORE that step's
3443
+ // tools report: it introduces what comes next, it does not belong to the row above. Only for a
3444
+ // NEW block — a continuation is merged into the previous one above and never gets here.
3445
+ process.stdout.write(thoughtPrintSeq(rows.map((r) => GUTTER + dim(r)), { gap }));
3446
+ // Record where it landed. The blank is above `from`, so the block is still exactly its rows. Measured AFTER the write so a trim during it cannot skew the
3409
3447
  // range: the last completed line is length-2 (the final entry is the still-open line).
3410
3448
  const endAbs = transcript.dropped() + transcript.length() - 2;
3411
3449
  thoughtBlocks.push({ from: endAbs - rows.length + 1, to: endAbs, prose, collapsed: true });
@@ -6721,6 +6759,7 @@ async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locali
6721
6759
  console.log(GUTTER + dim("restart it with `gu api restart`, then add that provider again.\n"));
6722
6760
  }
6723
6761
  sessionCodingModel = offered.id;
6762
+ setModelFallback(null); // #343
6724
6763
  sessionCodingLabel = model;
6725
6764
  sessionCodingUrl = url;
6726
6765
  // …and the host the bar puts in front of it (#263): left alone, the PREVIOUS coder's
@@ -6860,6 +6899,7 @@ async function chooseModel() {
6860
6899
  // Empty override means "use the account default" — so don't send an override when the
6861
6900
  // user picks the default (keeps the payload clean and lets a later default change win).
6862
6901
  sessionCodingModel = chosen.id === def ? "" : chosen.id;
6902
+ setModelFallback(null); // #343: the warning named the coder that was just replaced
6863
6903
  // Remember the friendly name for the token footer, which should never show a raw
6864
6904
  // "<kind>::<model>" wire id.
6865
6905
  sessionCodingLabel = chosen.id === def ? "" : chosen.model;
@@ -11060,7 +11100,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11060
11100
  if (heldThought === null) return;
11061
11101
  const raw = heldThought;
11062
11102
  heldThought = null;
11063
- if (printThoughtBlock(raw, onContent)) lastWasBlank = false;
11103
+ if (printThoughtBlock(raw, onContent, { gap: !lastWasBlank })) lastWasBlank = false;
11064
11104
  };
11065
11105
  let hoverRow = -1; // last mouse viewport row (from motion events) → highlight when it's on the thought line
11066
11106
  let thoughtRow = -1; // absolute viewport row of the thought line (line 1), learned via \x1b[6n
@@ -11485,10 +11525,13 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11485
11525
  // changed since is the VOLUME: a long turn now prints dozens of bullets interleaved with
11486
11526
  // collapsed thought prose, and with no blanks anywhere the two run together into a wall.
11487
11527
  //
11488
- // Above rather than below, for two reasons: a trailing blank would collide with the seam the
11489
- // caller owns before the bright answer (LINE_SPACING), and leading the group means the
11490
- // thought prose that FOLLOWS a bullet still sits tight against it — which is right, since
11491
- // that prose is the bullet's own reasoning, not the next step.
11528
+ // Above rather than below: a trailing blank would collide with the seam the caller owns
11529
+ // before the bright answer (LINE_SPACING).
11530
+ //
11531
+ // A THOUGHT GETS THE SAME BLANK ABOVE IT (#344, see printThoughtBlock). This comment used to say
11532
+ // the prose after a bullet "sits tight against it — which is right, since that prose is the
11533
+ // bullet's own reasoning". It is not: the thought is printed when the NEXT step's reasoning
11534
+ // ends, so glued under "Command finished → …" it read as that command's output.
11492
11535
  //
11493
11536
  // `lastWasBlank` makes it idempotent: nothing doubles a blank that is already there.
11494
11537
  const openBullet = () => {
@@ -11509,7 +11552,10 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11509
11552
  const at = thoughtBlocks.indexOf(stepGroup);
11510
11553
  if (at < 0) return false; // dropped from the registry (it is capped): no longer ours to rewrite
11511
11554
  for (let i = at + 1; i < thoughtBlocks.length; i++) {
11512
- if (thoughtBlocks[i].from !== end + 1) return false;
11555
+ // Directly under, or under the ONE blank a thought now opens with (#344) — the look-up
11556
+ // group must stay open across it, or every thought would start a new counted row.
11557
+ const step = thoughtBlocks[i].from - end;
11558
+ if (step !== 1 && step !== 2) return false;
11513
11559
  end = thoughtBlocks[i].to;
11514
11560
  }
11515
11561
  return end === transcriptTail();
@@ -11612,6 +11658,18 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11612
11658
  lastContentAt = Date.now();
11613
11659
  continue;
11614
11660
  }
11661
+ // Task #343: another model took over. Swallowed from the action list — it goes on the
11662
+ // BAR, now, in yellow — and the turn leaves one line about it once it has ended (see the
11663
+ // caller), so a fallback that lasted a single step can still be found afterwards.
11664
+ if (line.startsWith(MODELFALLBACK_PREFIX)) {
11665
+ const fb = parseModelFallback(line);
11666
+ if (fb) {
11667
+ turnFallback = fb;
11668
+ setModelFallback(fb);
11669
+ }
11670
+ lastContentAt = Date.now();
11671
+ continue;
11672
+ }
11615
11673
  // A fence opening starts a fresh step's raw stream → reset the harvested Thought
11616
11674
  // so the blinking line doesn't carry last step's clause into this one.
11617
11675
  if (isFenceLine(line)) {
@@ -11625,7 +11683,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11625
11683
  // says what it intends, then the actions it took appear under it.
11626
11684
  if (!REPL_VERBOSE && stepThought.trim() && !stepHasCode(stepThought)) {
11627
11685
  heldThought = stepThought; // #45: decided by the next line — see releaseHeldThought
11628
- } else if (printThoughtBlock(stepThought, onContent)) {
11686
+ } else if (printThoughtBlock(stepThought, onContent, { gap: !lastWasBlank })) {
11629
11687
  lastWasBlank = false;
11630
11688
  }
11631
11689
  stepThought = "";
@@ -13355,6 +13413,7 @@ async function main() {
13355
13413
  if (line === "/agent-model") {
13356
13414
  await chooseAgentModel();
13357
13415
  agentMissing = false; // Task #14: an agent model was just chosen → clear its warning
13416
+ setModelFallback(null); // #343: so is the stand-in a fallback warning named
13358
13417
  continue;
13359
13418
  }
13360
13419
  if (line === "/embed-model") {
@@ -13706,6 +13765,7 @@ async function main() {
13706
13765
  // #323: priced by the coder this turn STARTS on — a /model switch afterwards must not put
13707
13766
  // another model's price on it.
13708
13767
  const turnCoder = { url: activeCodingUrl(), model: activeCodingModelId() };
13768
+ turnFallback = null; // #343: nothing has fallen back in THIS turn yet
13709
13769
  if (reattach) console.log(GUTTER + dim("following the turn that was running when the connection went…\n"));
13710
13770
  const turn = await runAgentTurn(history, reattach ? { attachJobId: reattach.jobId, ownJob: true } : {});
13711
13771
  // NOT SENT (licence block, or the token prompt declined): runAgentTurn already said so.
@@ -13720,6 +13780,15 @@ async function main() {
13720
13780
  // Detached: the agent keeps running on the worker, so the question is neither answered
13721
13781
  // nor stopped — leave it untagged, and say nothing contradicting "the job keeps running".
13722
13782
  if (detached) continue;
13783
+ // #343: the bar after this turn — a fallback reported in it stays; an answered turn with
13784
+ // none means the model works again and the warning goes; a failed or stopped turn proves
13785
+ // nothing and leaves it.
13786
+ const fellBack = turnFallback;
13787
+ setModelFallback(fallbackAfterTurn({
13788
+ current: modelFallback,
13789
+ reportedThisTurn: fellBack,
13790
+ answered: Boolean(answer) && !answer.startsWith(TURN_FAILED_PREFIX),
13791
+ }));
13723
13792
  // Feed the bottom bar's token row (task #104). Assigned for EVERY turn — including one
13724
13793
  // that ended without an answer — so the row never shows a stale count from an older
13725
13794
  // turn. It replaces the print-once-per-turn footer that used to live here: the bar is
@@ -13749,6 +13818,7 @@ async function main() {
13749
13818
  failures: noteTaskFailure(taskLine, classifyTurnFailure(_failText)),
13750
13819
  authored: true,
13751
13820
  });
13821
+ if (fellBack) printFallbackLine(fellBack);
13752
13822
  } else if (answer) {
13753
13823
  noteTaskSuccess();
13754
13824
  history.push({ role: "assistant", content: answer });
@@ -13792,6 +13862,9 @@ async function main() {
13792
13862
  // builder, so a change to the block's shape can no longer leave this behind.
13793
13863
  registerCopyBlock(block.rows, plain, 1);
13794
13864
  }
13865
+ // #343: the turn's own record of the fallback, under its answer — the bar's row can be
13866
+ // gone by the next turn, and the scrollback is where "which model wrote this?" is asked.
13867
+ if (fellBack) printFallbackLine(fellBack);
13795
13868
  } else {
13796
13869
  // A turn that ended WITHOUT an answer (Ctrl+C cancel, or an abort/failure) must not
13797
13870
  // silently launch another turn. Any keystrokes the user typed DURING the dead turn
@@ -13841,6 +13914,12 @@ async function main() {
13841
13914
  process.exit(0);
13842
13915
  }
13843
13916
 
13917
+ /** One yellow line in the transcript saying which model stood in for which (task #343). */
13918
+ function printFallbackLine(fb) {
13919
+ const rows = wrapBlock(fallbackHistoryLine(fb), GUTTER, process.stdout.columns || 80);
13920
+ console.log(rows.map((r) => GUTTER + yellow(r)).join("\n") + "\n");
13921
+ }
13922
+
13844
13923
  /**
13845
13924
  * Give the screen back to the shell with its prompt RIGHT UNDER the last output (#314). unpinBar()
13846
13925
  * parks the caret at the zone's top row, which is where the shell then printed its prompt: seven
@@ -0,0 +1,127 @@
1
+ /**
2
+ * ANOTHER MODEL STOOD IN — and the screen says so (task #343).
3
+ *
4
+ * Reported: "When the coding model has an issue and it falls back to another model it should show
5
+ * a warning at the bottom bar in yellow." Until now a fallback was a line in the worker's log: the
6
+ * turn "worked", only with a model nobody picked, and worse code or an answer with no tools was
7
+ * the only evidence.
8
+ *
9
+ * The agent reports each fallback as one step — `__MODELFALLBACK__::{json}` (see MODEL_FALLBACK in
10
+ * gonext_agent_chat.py) — and this module turns it into the bar's row and the turn's history line.
11
+ * Pure: no terminal, no colour. The caller paints it yellow.
12
+ *
13
+ * WHAT IS A FALLBACK. Only "a different model did the work, or answered". A retry on the same
14
+ * model is not one, and a turn that failed outright (429 overloaded, 502) is not one either —
15
+ * nothing answered, and the failure notice already says that.
16
+ */
17
+
18
+ export const MODELFALLBACK_PREFIX = "__MODELFALLBACK__::";
19
+
20
+ const text = (v) => String(v ?? "").replace(/\s+/g, " ").trim();
21
+
22
+ /** One step line → the fallback it reports, or null when it is not one (or is malformed). */
23
+ export function parseModelFallback(line) {
24
+ const s = String(line ?? "");
25
+ if (!s.startsWith(MODELFALLBACK_PREFIX)) return null;
26
+ let body;
27
+ try {
28
+ body = JSON.parse(s.slice(MODELFALLBACK_PREFIX.length));
29
+ } catch {
30
+ return null;
31
+ }
32
+ if (!body || typeof body !== "object" || Array.isArray(body)) return null;
33
+ const from = text(body.from);
34
+ const to = text(body.to);
35
+ // Without the model that was used instead there is nothing true to say.
36
+ if (!from && !to) return null;
37
+ return {
38
+ role: body.role === "chat" ? "chat" : "coder",
39
+ from,
40
+ to,
41
+ reason: text(body.reason),
42
+ // Absent = the stand-in had tools / did answer: only an explicit false changes the wording.
43
+ tools: body.tools !== false,
44
+ answered: body.answered !== false,
45
+ };
46
+ }
47
+
48
+ const clipName = (name, max) => (name.length <= max ? name : name.slice(0, Math.max(1, max - 1)) + "…");
49
+
50
+ /** A model name is never shortened below this — fewer characters no longer identify it. */
51
+ const NAME_FLOOR = 5;
52
+
53
+ /** The sentence's three parts, so the fitting below can shorten each on its own. */
54
+ function parts(fb, { fromMax = Infinity, toMax = Infinity } = {}) {
55
+ const from = clipName(fb.from || (fb.role === "chat" ? "the chat model" : "the coding model"), fromMax);
56
+ const to = clipName(fb.to || "another model", toMax);
57
+ const same = Boolean(fb.from) && fb.from === fb.to;
58
+ const subject = fb.role === "chat" ? `chat model ${from}` : same ? from : `coder ${from}`;
59
+ // "unavailable": it never started (its server named no model). "failed": it was asked and broke.
60
+ const verb = fb.tools && fb.answered ? "unavailable" : "failed";
61
+ let outcome;
62
+ if (!fb.answered) outcome = same ? "and its plain reply failed too" : `${to} failed too`;
63
+ else if (fb.role === "chat") outcome = `${to} answered`;
64
+ else if (fb.tools) outcome = `coding with ${to}`;
65
+ else outcome = same ? "answered without tools" : `${to} answered without tools`;
66
+ return { subject, verb, outcome };
67
+ }
68
+
69
+ const join = ({ subject, verb, outcome }, reason) =>
70
+ `⚠ ${subject} ${verb}${reason ? ` (${reason})` : ""} — ${outcome}`;
71
+
72
+ /**
73
+ * The bar's row for a fallback, FITTED to `width` columns (after the gutter): every bar row must
74
+ * stay one physical row (#109), and a row clipped mid-name hides the one thing it is for. So the
75
+ * REASON is shortened first, then dropped, and only then are the model names shortened — the
76
+ * longer one first.
77
+ */
78
+ export function fallbackNotice(fb, { width = 80, gutter = " " } = {}) {
79
+ if (!fb) return "";
80
+ const room = Math.max(20, (width || 80) - 1 - gutter.length);
81
+ const full = join(parts(fb), fb.reason);
82
+ if (full.length <= room) return full;
83
+ const bare = join(parts(fb), "");
84
+ if (fb.reason) {
85
+ const left = room - bare.length - 3; // " (" + ")"
86
+ if (left >= 6) return join(parts(fb), clipName(fb.reason, left));
87
+ }
88
+ if (bare.length <= room) return bare;
89
+ let fromMax = Math.max(NAME_FLOOR, (fb.from || "").length);
90
+ let toMax = Math.max(NAME_FLOOR, (fb.to || "").length);
91
+ while (join(parts(fb, { fromMax, toMax }), "").length > room && (fromMax > NAME_FLOOR || toMax > NAME_FLOOR)) {
92
+ if (fromMax >= toMax && fromMax > NAME_FLOOR) fromMax -= 1;
93
+ else toMax -= 1;
94
+ }
95
+ // A window too narrow even for that: cut the row rather than let it wrap onto a second one.
96
+ return clipName(join(parts(fb, { fromMax, toMax }), ""), room);
97
+ }
98
+
99
+ /** The line a fallback leaves in the turn's history — whole, wrapped by the caller. */
100
+ export function fallbackHistoryLine(fb) {
101
+ return fb ? join(parts(fb), fb.reason) : "";
102
+ }
103
+
104
+ /**
105
+ * THE CODER WAS DROPPED FOR WANT OF A KEY (task #222): the API omits a coding backend whose API
106
+ * key is missing and the chat model does the coding. Nothing failed, so the agent has nothing to
107
+ * report — the terminal knows it from the payload and says it on the same row.
108
+ */
109
+ export function keyMissingNotice({ coder = "", width = 80, gutter = " " } = {}) {
110
+ const room = Math.max(20, (width || 80) - 1 - gutter.length);
111
+ const tail = " — coding with the chat model · ctrl+k";
112
+ const named = `⚠ no API key for coder ${text(coder)}${tail}`;
113
+ if (text(coder) && named.length <= room) return named;
114
+ const plain = `⚠ no API key for the coding model${tail}`;
115
+ if (plain.length <= room) return plain;
116
+ return clipName("⚠ no coder API key — on the chat model · ctrl+k", room);
117
+ }
118
+
119
+ /**
120
+ * What the bar shows after a turn ended. A fallback reported THIS turn stays; a turn that was
121
+ * answered with none reported means the model works again, so the warning goes; a turn that
122
+ * failed or was stopped proves nothing either way and leaves it as it was.
123
+ */
124
+ export function fallbackAfterTurn({ current = null, reportedThisTurn = null, answered = false } = {}) {
125
+ if (reportedThisTurn) return reportedThisTurn;
126
+ return answered ? null : current;
127
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.793",
3
+ "version": "1.0.795",
4
4
  "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,6 +21,9 @@
21
21
  "postinstall": "node install-path.mjs",
22
22
  "test": "python3 -m unittest discover tests -v && node --test",
23
23
  "test:js": "node --test",
24
+ "test:ui": "node --test --test-concurrency=1 tests/ui/ui.test.mjs",
25
+ "ui:show": "node tests/ui/show.mjs",
26
+ "ui:accept": "UPDATE_UI=1 node --test --test-concurrency=1 tests/ui/ui.test.mjs",
24
27
  "test:keys": "node --test tests/key-choice-regression.test.mjs tests/coding-key-line.test.mjs tests/coding-key-picker.test.mjs tests/coding-key-prompt.test.mjs tests/coding-key-notice.test.mjs tests/coding-key-fallback-line.test.mjs tests/manage-coding-providers.test.mjs tests/add-coding-provider.test.mjs tests/coding-provider-setup.test.mjs",
25
28
  "deploy:local": "npm install -g .",
26
29
  "run": "gu-cli",
@@ -105,6 +108,7 @@
105
108
  "proxy-dispatcher.mjs",
106
109
  "model-doctor.mjs",
107
110
  "coding-key-prompt.mjs",
111
+ "model-fallback.mjs",
108
112
  "server-keys.mjs",
109
113
  "add-server.mjs",
110
114
  "powershell-alias.mjs",
@@ -528,6 +528,21 @@ export function buildFooterRows({
528
528
  ];
529
529
  }
530
530
 
531
+ /**
532
+ * The bytes that print a NEW thought block into the transcript (task #344): its painted rows,
533
+ * with ONE empty line above when the row above is not already blank (`gap`).
534
+ *
535
+ * A thought used to go directly under whatever was last printed. Under "✓ Command finished → …"
536
+ * it then read as that command's output, and under a bright "✗ Command failed → …" as its error
537
+ * text — but a thought is printed when its step's reasoning ends, before that step's tools
538
+ * report: it introduces what follows. Never two blanks: the caller passes gap=false when the
539
+ * last row is already empty. No blank BELOW either — the next bullet opens its own.
540
+ */
541
+ export function thoughtPrintSeq(paintedRows, { gap = false } = {}) {
542
+ if (!paintedRows.length) return "";
543
+ return (gap ? "\n" : "") + paintedRows.join("\n") + "\n";
544
+ }
545
+
531
546
  /**
532
547
  * Bytes that draw the bar below the input line and put the caret back exactly where readline
533
548
  * left it. Relative moves only: the block must survive the terminal scrolling — writing past