@tiens.nguyen/gu-cli 1.0.795 → 1.0.796

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.
@@ -701,7 +701,7 @@ export async function probeLocalOllama() {
701
701
  */
702
702
  /** The shared Ollama every client machine can already reach. */
703
703
  export const SHARED_OLLAMA_URL =
704
- process.env.GONEXT_CLIENT_AGENT_URL?.trim() || "https://ollama1.gomarsic.cc/v1";
704
+ process.env.GONEXT_CLIENT_AGENT_URL?.trim() || "https://ollama1.getmarsic.cc/v1";
705
705
  /**
706
706
  * Chat model — the one the agent routes and thinks with, on every step of every turn.
707
707
  *
@@ -69,7 +69,7 @@ export function codingChoiceTags(choices) {
69
69
  }
70
70
 
71
71
  /**
72
- * WHERE AN OLLAMA CODER RUNS — "ollama.com", "127.0.0.1", "ollama1.gomarsic.cc" — or "" for any
72
+ * WHERE AN OLLAMA CODER RUNS — "ollama.com", "127.0.0.1", "ollama1.getmarsic.cc" — or "" for any
73
73
  * other kind.
74
74
  *
75
75
  * Reported: "/model ✓ coding model → kimi-k3 (ollama)". The tag was the API's provider label, the
@@ -37,10 +37,10 @@ export const SECRET_KEY_HINT = "type or paste the key, then press Enter — it s
37
37
  * that does not exist and, worse, reappears on every start because it can never be satisfied.
38
38
  *
39
39
  * Reported live: a Client Expert machine whose coder is gemma4-quadro-49k on
40
- * ollama1.gomarsic.cc being asked, at every launch, for that model's "API key".
40
+ * ollama1.getmarsic.cc being asked, at every launch, for that model's "API key".
41
41
  *
42
- * Matched on the HOST, not a substring: `evil.com/?x=gomarsic.cc` and
43
- * `gomarsic.cc.attacker.net` must not be treated as ours — being wrong here would suppress a
42
+ * Matched on the HOST, not a substring: `evil.com/?x=getmarsic.cc` and
43
+ * `getmarsic.cc.attacker.net` must not be treated as ours — being wrong here would suppress a
44
44
  * genuine prompt and leave someone's coder quietly unusable.
45
45
  */
46
46
  export function isKeylessCodingHost(url) {
@@ -51,7 +51,7 @@ export function isKeylessCodingHost(url) {
51
51
  return false;
52
52
  }
53
53
  if (host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "[::1]") return true;
54
- return host === "gomarsic.cc" || host.endsWith(".gomarsic.cc");
54
+ return host === "getmarsic.cc" || host.endsWith(".getmarsic.cc");
55
55
  }
56
56
 
57
57
  /**
package/device-login.mjs CHANGED
@@ -8,7 +8,7 @@ import { homedir, platform } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import dotenv from "dotenv";
10
10
 
11
- export const DEFAULT_API_BASE = "https://chat-api-v2.gomarsic.cc";
11
+ export const DEFAULT_API_BASE = "https://chat-api-v2.getmarsic.cc";
12
12
  const ENV_FILE = join(homedir(), ".gonext", "worker.env");
13
13
  const POLL_INTERVAL_MS = 2500;
14
14
  const POLL_TIMEOUT_MS = 10 * 60_000; // matches the server's pairing TTL
@@ -186,3 +186,40 @@ export function describeEmbedChange(plan) {
186
186
  lines.push("They will be ignored at search time and rebuilt on the next search.");
187
187
  return lines;
188
188
  }
189
+
190
+ /**
191
+ * IS RAG ACTUALLY ON, AND IF NOT, WHY? (task #347)
192
+ *
193
+ * Reported: "Choose embed model but exit and gu again, it keeps asking 'embed model not set',
194
+ * again and again." Two settings decide it and they were treated as one: WHICH embedder
195
+ * (`ragEmbedModel` + where it is served) and WHETHER RAG is on (`ragEnabled`). /embed-model saved
196
+ * the first and never the second, and the bar's warning is driven by the second — so the choice
197
+ * was stored, never took effect, and the warning came back at every start.
198
+ *
199
+ * → { state, model }
200
+ * "on" an embedder is chosen and RAG is on
201
+ * "off" an embedder IS chosen, RAG is switched off — not "no embed model"
202
+ * "unset" no embedder was ever chosen
203
+ * Pure. `settings` is the worker-settings view (null/undefined = unknown → "unset").
204
+ */
205
+ export function embedState(settings) {
206
+ const model = String(settings?.ragEmbedModel ?? "").trim();
207
+ const where = String(settings?.ragEmbedUrl ?? "").trim() || String(settings?.ragEmbedModelPath ?? "").trim();
208
+ if (!model || !where) return { state: "unset", model };
209
+ return { state: settings?.ragEnabled === true ? "on" : "off", model };
210
+ }
211
+
212
+ /**
213
+ * What saving an embedder must write: the model, its server — AND RAG on. Choosing an embedder
214
+ * is the act of turning RAG on; a choice that leaves it off is the bug above.
215
+ */
216
+ export function embedSaveFields({ url, model }) {
217
+ return { ragEmbedUrl: url, ragEmbedModel: model, ragEmbedKeyId: "", ragEnabled: true };
218
+ }
219
+
220
+ /** The bar's row for a state that is not "on" — "" when RAG is on. One short row (#317). */
221
+ export function embedNotice({ state, model } = {}) {
222
+ if (state === "on") return "";
223
+ if (state === "off" && model) return `⚠ RAG is switched off — ${model} is set · /embed-model turns it on`;
224
+ return "⚠ no embed model — RAG is off · /embed-model";
225
+ }
@@ -1426,7 +1426,7 @@ def _resolve_coding_backend(kind, base_url, probe=None):
1426
1426
  budget). Every branch now keys off this one value.
1427
1427
 
1428
1428
  Order matters. The Ollama probe runs BEFORE the remote-host rule so a remote Ollama
1429
- (ollama1.gomarsic.cc) still resolves to "ollama" and keeps its own treatment.
1429
+ (ollama1.getmarsic.cc) still resolves to "ollama" and keeps its own treatment.
1430
1430
 
1431
1431
  explicit kind → that value
1432
1432
  probe says Ollama → "ollama"
@@ -1467,7 +1467,7 @@ def _sentinel_against_ollama(agent_base_url, agent_model_id, probe=None):
1467
1467
 
1468
1468
  — an error naming a model the user never chose, on a machine whose settings look fine. That
1469
1469
  is the whole defect: not that it fails, but that it fails illegibly and the reader has no
1470
- thread to pull. Verified live against ollama1.gomarsic.cc, 2026-08-28.
1470
+ thread to pull. Verified live against ollama1.getmarsic.cc, 2026-08-28.
1471
1471
 
1472
1472
  WHY IT CANNOT BE FIXED WHERE IT IS PRODUCED. The API builds that payload with no idea which
1473
1473
  KIND of server the agent URL points at — there is no agentModelKind, only
@@ -1505,7 +1505,7 @@ def _resolve_coding_target(agent_base_url, agent_model_id, raw_coding_base, raw_
1505
1505
  Extracted from run_agent_chat unchanged (task #213). It decides the single most consequential
1506
1506
  thing about a turn — the model that writes every code block — and it lived in the middle of a
1507
1507
  400-line function, so no test could execute it. The Client Expert default puts the chat model,
1508
- the coder and the embedder on ONE Ollama box (ollama1.gomarsic.cc), which is exactly the shape
1508
+ the coder and the embedder on ONE Ollama box (ollama1.getmarsic.cc), which is exactly the shape
1509
1509
  where "same server" and "different model" collide, and where a silent fall-back to the chat
1510
1510
  model is invisible: both answers are a real model on a real server and the turn still works,
1511
1511
  just not with the coder that was chosen.
@@ -3877,7 +3877,7 @@ def _is_request_timeout(err) -> bool:
3877
3877
  likely STILL RUNNING on the box. Re-sending the identical request — streamed or
3878
3878
  not — starts that same multi-minute generation again from zero.
3879
3879
 
3880
- Live (2026-08-30, qwen3.8:27b on ollama1.gomarsic.cc): a 7,251-token prompt produced no
3880
+ Live (2026-08-30, qwen3.8:27b on ollama1.getmarsic.cc): a 7,251-token prompt produced no
3881
3881
  first token inside the 600s client timeout. We logged "streamed generate failed (Request
3882
3882
  timed out.) → falling back to non-streaming", told the user "Streaming unavailable", and
3883
3883
  re-sent it unstreamed — buying a second 600s wait with no first-token signal at all.
@@ -10088,7 +10088,7 @@ def run_agent_chat(cfg):
10088
10088
  # stream_agent: request the completion with stream=True and reassemble the
10089
10089
  # deltas into one message. Enabled for Ollama, where a long single-shot
10090
10090
  # (non-stream) generation sends nothing until the very end — a reverse proxy
10091
- # in front of the box (ollama1.gomarsic.cc) then hits its idle read-timeout
10091
+ # in front of the box (ollama1.getmarsic.cc) then hits its idle read-timeout
10092
10092
  # and kills the request mid-generation. Streaming keeps tokens flowing so the
10093
10093
  # idle timer never fires. smolagents still receives the COMPLETE text, so its
10094
10094
  # <code>/final_answer parsing is unaffected.
package/gu-cli.mjs CHANGED
@@ -1926,7 +1926,7 @@ const AGENT_WARMUP_IDLE_MS = 30 * 60_000;
1926
1926
  const AGENT_WARMUP_INTERVAL_MS = 240_000; // 4 min — under Ollama's 5-min default unload
1927
1927
  // OCR translation/correction is hardcoded to a managed Ollama service + model.
1928
1928
  // (The user-configurable "OCR translation model" chooser was removed.)
1929
- const OCR_TRANSLATE_OLLAMA_URL = "https://ollama1.gomarsic.cc";
1929
+ const OCR_TRANSLATE_OLLAMA_URL = "https://ollama1.getmarsic.cc";
1930
1930
  const OCR_TRANSLATE_OLLAMA_MODEL = "qwen3:14b";
1931
1931
  // Ollama defaults num_ctx to 2048 and silently truncates longer inputs (keeping
1932
1932
  // only the tail), which would drop the start of a long OCR page. Pin the context
@@ -3068,7 +3068,7 @@ function normalizeCorrection(raw, originalText) {
3068
3068
 
3069
3069
  /**
3070
3070
  * Call an Ollama host's native chat endpoint (POST {base}/api/chat) for text
3071
- * correction/translation, e.g. base "https://ollama1.gomarsic.cc" model "qwen3:14b".
3071
+ * correction/translation, e.g. base "https://ollama1.getmarsic.cc" model "qwen3:14b".
3072
3072
  * `think:false` suppresses reasoning models' <think> preamble so we get clean text.
3073
3073
  */
3074
3074
  const OCR_CORRECT_SYSTEM_PROMPT =
package/gu-repl.mjs CHANGED
@@ -138,7 +138,8 @@ 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";
141
+ import { questionEchoText, thoughtPrintSeq } from "./terminal-layout.mjs";
142
+ import { embedNotice, embedState } from "./embed-model-command.mjs";
142
143
  import {
143
144
  MODELFALLBACK_PREFIX, fallbackAfterTurn, fallbackHistoryLine, fallbackNotice, keyMissingNotice,
144
145
  parseModelFallback,
@@ -160,7 +161,7 @@ import {
160
161
  registeredRange,
161
162
  selectionKeyAction,
162
163
  } from "./terminal-copy.mjs";
163
- import { createHoverController, applyToggleShift } from "./terminal-hover.mjs";
164
+ import { createHoverController, applyToggleShift, rowsDiffer } from "./terminal-hover.mjs";
164
165
  // The required pair, imported rather than re-typed: the startup "no models" message names them,
165
166
  // and a second copy of these strings would drift from the check that enforces them.
166
167
  import { ACCEPTED_EMBED_MODELS, REQUIRED_AGENT_MODEL, REQUIRED_AGENT_PREFIX } from "./ollama-setup.mjs";
@@ -847,7 +848,7 @@ if (!workerKey || !apiBase) {
847
848
  console.error(red(`gu: setup failed: ${e instanceof Error ? e.message : e}`));
848
849
  if (e && e.code === "PAIRING_UNSUPPORTED") {
849
850
  console.error(
850
- "Configure manually: gu-cli set <workerKey> --api-base https://chat-api-v2.gomarsic.cc"
851
+ "Configure manually: gu-cli set <workerKey> --api-base https://chat-api-v2.getmarsic.cc"
851
852
  );
852
853
  }
853
854
  if (e && (e.code === "KEY_REJECTED" || e.code === "KEY_UNCHECKED")) {
@@ -862,7 +863,7 @@ if (!workerKey || !apiBase) {
862
863
  console.error(
863
864
  red("gu: no worker key / API base found.") +
864
865
  `\nRun ${accent("gu-cli login")} to sign in, or configure manually:` +
865
- "\n gu-cli set <workerKey> --api-base https://chat-api-v2.gomarsic.cc"
866
+ "\n gu-cli set <workerKey> --api-base https://chat-api-v2.getmarsic.cc"
866
867
  );
867
868
  process.exit(1);
868
869
  }
@@ -1633,11 +1634,14 @@ let agentMissing = false; // no agent model chosen → agent falls back to the c
1633
1634
  // question to. Seeded from the startup payload; re-checked before a question is refused.
1634
1635
  let noModelAtAll = false;
1635
1636
  let embedMissing = false; // no embed model chosen → RAG is off until /embed-model picks one
1637
+ // #347: WHY it is off — "unset" (none chosen) or "off" (one IS chosen, RAG is switched off) — and
1638
+ // the chosen model's name, so the row never says "no embed model" about a model that is set.
1639
+ let embedWhy = { state: "unset", model: "" };
1636
1640
  // One footer row; the two halves are independent so a model that IS set is never named.
1637
1641
  // ONE SHORT ROW EACH (#317): joined, they ran past 80 columns and were cut mid-word.
1638
1642
  const missingNoticeRows = () => [
1639
1643
  ...(agentMissing ? ["⚠ agent model not set — using the coding model · /agent-model"] : []),
1640
- ...(embedMissing ? ["⚠ no embed model — RAG is off · /embed-model"] : []),
1644
+ ...(embedMissing ? [embedNotice(embedWhy) || "⚠ no embed model — RAG is off · /embed-model"] : []),
1641
1645
  ];
1642
1646
 
1643
1647
  // ANOTHER MODEL STOOD IN (task #343). The agent reports it as a __MODELFALLBACK__:: step; it is
@@ -1662,6 +1666,30 @@ const fallbackNoticeRows = () => {
1662
1666
  ];
1663
1667
  };
1664
1668
 
1669
+ /**
1670
+ * Set the embed warning from what the settings SAY (task #347) — the one place it is decided
1671
+ * after startup's first guess.
1672
+ *
1673
+ * `repair`: an embedder is chosen but RAG is off. On a Local-mode machine the only way to choose
1674
+ * one is /embed-model or setup, and until this fix /embed-model never switched RAG on — so that
1675
+ * state is this bug's leftover, and it is finished here instead of asking the person to choose
1676
+ * again. In Cloud mode the web Settings has its own RAG switch; "off" may be a decision, so it is
1677
+ * reported, not changed.
1678
+ */
1679
+ async function syncEmbedWarning({ repair = false } = {}) {
1680
+ const settings = await fetchWorkerSettings();
1681
+ if (!settings) return; // offline: keep what the bar already says
1682
+ let st = embedState(settings);
1683
+ if (st.state === "off" && repair && isClientExpert()) {
1684
+ const md = await import("./model-doctor.mjs");
1685
+ const saved = await md.saveWorkerSettings(apiBase, workerKey, { ragEnabled: true }).catch(() => false);
1686
+ if (saved) st = { ...st, state: "on" };
1687
+ }
1688
+ embedWhy = st;
1689
+ embedMissing = st.state !== "on";
1690
+ if (barPinned()) paintBar();
1691
+ }
1692
+
1665
1693
  // #307: the calm note after a quick change of mind — dim, not the ⚠ orange of a warning, and in the
1666
1694
  // bar rather than the transcript (which the whole point is to leave untouched).
1667
1695
  let stopNotice = "";
@@ -2577,12 +2605,18 @@ const tokenLineText = () => {
2577
2605
  );
2578
2606
  const total = dim(" · ") + dim("total ") + accent(`↓ ${fmtTokens(Math.max(globalOutputTokens, lastTurnTotalOutput))}`);
2579
2607
  // #323: what the last turn's prompt cost, LAST on the row after a "|" — "~" when the cached share
2580
- // was assumed; nothing when the model has no price (never a misleading "| $0"). It survives a
2608
+ // was assumed; "no price" when the model has none (never a misleading "| $0"). It survives a
2581
2609
  // narrow window: "total ↓" is dropped before it is (tokenRowWithPrice).
2582
2610
  return tokenRowWithPrice({
2583
2611
  withTotal: head + total,
2584
2612
  withoutTotal: head,
2585
- price: lastTurnCost?.usd != null ? dim(" | ") + accent(fmtCost(lastTurnCost)) : "",
2613
+ // #349: a model with NO price says so ("no price", dim) — the blank it used to leave read as
2614
+ // the cost feature being broken. Still never "$0": unpriced is not free.
2615
+ price: lastTurnCost?.usd != null
2616
+ ? dim(" | ") + accent(fmtCost(lastTurnCost))
2617
+ : lastTurnCost
2618
+ ? dim(" | no price")
2619
+ : "",
2586
2620
  room: (process.stdout.columns || 80) - 1 - visibleLen(GUTTER),
2587
2621
  });
2588
2622
  };
@@ -3348,6 +3382,7 @@ const hoverCtl = createHoverController({
3348
3382
  pinned: () => barPinned(),
3349
3383
  transcriptRows: () => transcriptRows(),
3350
3384
  blockAtRow: (row) => thoughtBlockAtRow(row),
3385
+ canToggle: (b) => blockCanToggle(b),
3351
3386
  paintRows: (b, on) => paintThoughtRows(b, on),
3352
3387
  replaceRange: (from, to, rows) => transcript.replaceRange(from, to, rows),
3353
3388
  repaintSeq: (b) => thoughtBlockRepaintSeq(b),
@@ -3646,9 +3681,20 @@ function copyTargetAtRow(row) {
3646
3681
  return { block, renderedLines: lines.slice(from, to + 1) };
3647
3682
  }
3648
3683
 
3684
+ /**
3685
+ * Would a click on this block change anything? (#350) — its collapsed and its expanded rows, laid
3686
+ * out for THIS window, are not the same. Asked by the hover (may it light up?), by the click and
3687
+ * by Ctrl+T, so the three can never disagree. See rowsDiffer in terminal-hover.mjs.
3688
+ */
3689
+ function blockCanToggle(block) {
3690
+ if (!block) return false;
3691
+ return rowsDiffer(paintThoughtRows(block, false, true), paintThoughtRows(block, false, false));
3692
+ }
3693
+
3649
3694
  function toggleThoughtAtRow(row) {
3650
3695
  const block = thoughtBlockAtRow(row);
3651
- return block ? toggleThoughtBlock(block) : false;
3696
+ // A block shown whole has no other form: it did not light up, and the click is not for it.
3697
+ return block && blockCanToggle(block) ? toggleThoughtBlock(block) : false;
3652
3698
  }
3653
3699
 
3654
3700
  /**
@@ -3718,8 +3764,12 @@ function toggleThoughtBlock(block) {
3718
3764
 
3719
3765
  /** Expand/collapse the most recently printed thought block. The keyboard path (Ctrl+T). */
3720
3766
  function toggleNewestThought() {
3721
- const block = thoughtBlocks[thoughtBlocks.length - 1];
3722
- if (!barPinned() || !block) return false;
3767
+ if (!barPinned()) return false;
3768
+ // The newest block that HAS another form (#350): Ctrl+T on a thought already shown whole did
3769
+ // nothing visible, while an expandable one sat just above it.
3770
+ let block = null;
3771
+ for (let i = thoughtBlocks.length - 1; i >= 0 && !block; i--) if (blockCanToggle(thoughtBlocks[i])) block = thoughtBlocks[i];
3772
+ if (!block) return false;
3723
3773
  return toggleThoughtBlock(block);
3724
3774
  }
3725
3775
 
@@ -4617,13 +4667,13 @@ const ask = async (q, { prefill = "", hint = [], atomic = false, noCancel = fals
4617
4667
  rl.line = String(prefill);
4618
4668
  rl.cursor = rl.line.length;
4619
4669
  }
4620
- // ELLIPSIS, so a truncation is VISIBLE. Silently cutting "…[https://ollama2.gomarsic.cc/v1]:"
4670
+ // ELLIPSIS, so a truncation is VISIBLE. Silently cutting "…[https://ollama2.getmarsic.cc/v1]:"
4621
4671
  // to "…[https:/" hides the default Enter would accept, which is worse than not showing it —
4622
4672
  // the reader cannot tell whether the value is short or the row lied. Callers should keep the
4623
4673
  // final line short and put the long part above; this is the guard, not the plan.
4624
4674
  // "… " with the space: the label normally ends in ": ", and a truncation that eats the
4625
4675
  // separator leaves the typed text jammed against the last surviving word
4626
- // ("agent + embed ser…ollama2.gomarsic.cc"). Two columns for the ellipsis and its space.
4676
+ // ("agent + embed ser…ollama2.getmarsic.cc"). Two columns for the ellipsis and its space.
4627
4677
  inlinePrompt =
4628
4678
  (visibleLen(label) > budget ? clipVisible(label, budget - 2) + "… " : label) || PROMPT;
4629
4679
  try {
@@ -5704,7 +5754,7 @@ async function reconfigureOllamaServer() {
5704
5754
  const typed = (await ask(` ${short}: `)).trim();
5705
5755
  // "s" is the advertised skip (#301); the older words still work.
5706
5756
  if (ui.readStepAnswer(typed).kind === "skip") return null;
5707
- // EVERY READING OF A BARE HOSTNAME, in order. Typing "ollama2.gomarsic.cc" means
5757
+ // EVERY READING OF A BARE HOSTNAME, in order. Typing "ollama2.getmarsic.cc" means
5708
5758
  // https://…:443 behind a proxy, while "192.168.1.40" means http://…:11434 — one guess
5709
5759
  // is right for one of them and produces a timeout that looks like a dead server for the
5710
5760
  // other. See urlCandidates; the load decides, not the heuristic.
@@ -5785,7 +5835,7 @@ async function reconfigureOllamaServer() {
5785
5835
  // ask again. Keep it, say where it is changed, and go on to the coder.
5786
5836
  const agentOnProvider = am.agentIsOnProvider(settings);
5787
5837
  const curAgent =
5788
- String(settings.agentModelUrl ?? "").trim() || "https://ollama1.gomarsic.cc/v1";
5838
+ String(settings.agentModelUrl ?? "").trim() || "https://ollama1.getmarsic.cc/v1";
5789
5839
  const curCode = am.coderServerDefault(settings);
5790
5840
 
5791
5841
  let agent = null;
@@ -6704,7 +6754,9 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6704
6754
  }
6705
6755
  if (role === "embed") {
6706
6756
  // ragEmbedKeyId "" — the server's own keys decide now (its default, or this folder's pick).
6707
- const fields = { ragEmbedUrl: target.roleUrl("embed"), ragEmbedModel: model, ragEmbedKeyId: "" };
6757
+ // …and ragEnabled: true (#347): choosing an embedder IS turning RAG on. Without it the choice
6758
+ // was stored and never took effect, and the "no embed model" warning came back at every start.
6759
+ const fields = ec.embedSaveFields({ url: target.roleUrl("embed"), model });
6708
6760
  if (!(await saveEmbedSettings(fields))) return;
6709
6761
  if (plan) announceEmbedSaved({ chosen: model, plan, url: target.roleUrl("embed") });
6710
6762
  else {
@@ -7849,6 +7901,7 @@ async function noteTurnCost(inputTokens, breakdown, coder = null) {
7849
7901
  const c = mp.turnCost({ input: inputTokens, ...cache, price });
7850
7902
  if (!c) {
7851
7903
  lastTurnCost = { usd: null, model, host: mp.hostOf(url) };
7904
+ if (barPinned()) paintBar(); // #349: "no price" is shown, so it is painted like a cost is
7852
7905
  return;
7853
7906
  }
7854
7907
  lastTurnCost = { ...c, input: inputTokens, cacheReported: cache.cacheReported, model, host: mp.hostOf(url) };
@@ -7891,7 +7944,7 @@ async function runPricesCommand(line = "/prices") {
7891
7944
  console.log(GUTTER + dim(pricePublishedAt ? `from gu's tables published ${new Date(pricePublishedAt).toLocaleString()}` : "gu's built-in tables (none published applied yet)"));
7892
7945
  if (priceUpdate) console.log(GUTTER + accent(`${mp.priceUpdateLabel(priceUpdate.publishedAt)} — click it below, or /prices update`));
7893
7946
  if (!mp.findPrice(rows, url, model)) {
7894
- console.log(GUTTER + yellow(`no price for ${model || "the coder"} at ${mp.hostOf(url) || "its server"} — no cost is shown for its turns.`));
7947
+ for (const r of wrapBlock(mp.noPriceLine({ model, host: mp.hostOf(url) }), GUTTER, width)) console.log(GUTTER + yellow(r));
7895
7948
  }
7896
7949
  if (lastTurnCost?.usd != null) {
7897
7950
  const share = lastTurnCost.input ? Math.round((100 * lastTurnCost.cachedTokens) / lastTurnCost.input) : 0;
@@ -11088,6 +11141,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11088
11141
  // whether the user has clicked to expand it. Both reset when the step's Thought changes.
11089
11142
  let fullLiveThought = "";
11090
11143
  let thoughtExpanded = false;
11144
+ let liveExpandable = false; // the live thought line has more to show than fits (set each tick)
11091
11145
  // Task #148: the CURRENT step's raw model output, accumulated in full (fenceThought stops at
11092
11146
  // 400 chars — enough for a one-line label, not for the paragraphs we now print). Bounded so a
11093
11147
  // runaway generation cannot grow without limit; the prose we keep is far shorter anyway.
@@ -11298,6 +11352,9 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11298
11352
  // "almost done" > "Thinking". When clickable we drive line 1 from the SAME fuller string, so
11299
11353
  // the grey expansion CONTINUES where line 1 was cut off instead of repeating the head.
11300
11354
  const clickable = !!fullLiveThought && !runningCmd && fullLiveThought.length > max;
11355
+ // #350: the click and Ctrl+T ask the SAME question the highlight does. They used to accept
11356
+ // any thought at all, so a line that was already whole took the click and redrew for nothing.
11357
+ liveExpandable = clickable;
11301
11358
  let label, rest;
11302
11359
  if (clickable) {
11303
11360
  // Split at a WORD boundary near the line-1 width so nothing is cut mid-word: the last
@@ -11450,14 +11507,14 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11450
11507
  if (row < top || row > top + statusContentLines - LEAD_ROWS - WORD_GAP - 2) return;
11451
11508
  // Only meaningful when there IS a fuller thought to reveal; otherwise ignore the click
11452
11509
  // so we don't flicker an empty expand.
11453
- if (!thoughtExpanded && !(fullLiveThought && !runningCmd)) return;
11510
+ if (!thoughtExpanded && !liveExpandable) return;
11454
11511
  toggleThought();
11455
11512
  };
11456
11513
  // Same expand/collapse, reachable without the mouse: Ctrl+T while a turn runs (see
11457
11514
  // toggleActiveThought). Ignored when there's nothing more to reveal, so it never flickers an
11458
11515
  // empty expansion.
11459
11516
  const toggleThought = () => {
11460
- if (!thoughtExpanded && !(fullLiveThought && !runningCmd)) return;
11517
+ if (!thoughtExpanded && !liveExpandable) return;
11461
11518
  thoughtExpanded = !thoughtExpanded;
11462
11519
  clearStatus(); // next tick (≤120ms) redraws at the new size; keeps cursor math correct
11463
11520
  };
@@ -12457,7 +12514,10 @@ async function main() {
12457
12514
  // reaches the model over HTTP), so it runs HERE, where the files are, and calls the
12458
12515
  // model machine for tokens. That needs a worker here like any other machine. The earlier
12459
12516
  // build suppressed the daemon entirely, which is why this comment exists.
12460
- if (!status.running) {
12517
+ // GU_NO_WORKER_AUTOSTART=1 (task #351): the terminal UI tests play a scripted turn from a fake
12518
+ // API — no worker is needed, and one started for a throwaway home outlived the test (on
12519
+ // Windows it would be a scheduled task). Nothing a user sets; without it, as always:
12520
+ if (!status.running && process.env.GU_NO_WORKER_AUTOSTART !== "1") {
12461
12521
  // NO LONGER A QUESTION (task #159, user 2026-08-09: "it need to run otherwise cannot work,
12462
12522
  // so, just notify"). It used to ask, and a "no" was then remembered per folder — but the
12463
12523
  // only workable answer was yes: without a worker nothing claims your turns, so a question
@@ -12613,7 +12673,12 @@ async function main() {
12613
12673
  // private mode's local API can itself be unconfigured.
12614
12674
  if (p.agentMissing === true) agentMissing = true;
12615
12675
  noModelAtAll = nothingToAnswerWith(p);
12616
- if (p.ragEnabled !== true) embedMissing = true;
12676
+ if (p.ragEnabled !== true) {
12677
+ embedMissing = true;
12678
+ // …but "off" has two causes, and only the settings can tell them apart (#347). In the
12679
+ // background: startup never waits on it, and the bar is repainted when the answer is in.
12680
+ void syncEmbedWarning({ repair: true });
12681
+ }
12617
12682
  // A RESTORED PICK MUST STILL EXIST. The stored id names a model on the box this folder was
12618
12683
  // last pointed at — and the box can change (`/model` → change the Ollama server), a model
12619
12684
  // can be removed, or the folder can be opened on another machine entirely. Sending a name
@@ -12797,7 +12862,10 @@ async function main() {
12797
12862
  // check used to sit INSIDE the "chat model is down" branch, so a client pointing at
12798
12863
  // a healthy remote model was never offered them and every turn died with
12799
12864
  // "No module named 'smolagents'". Independent now.
12800
- if (!(await md.hasAgentLibs())) {
12865
+ // GU_NO_AGENT_DEPS_PROMPT=1 (task #351): the terminal UI tests run no agent — on a machine
12866
+ // without the framework every test's throwaway home answered "yes" and sat through a pip
12867
+ // install (found on the Ubuntu server: 21 seconds of downloads, then a timeout).
12868
+ if (process.env.GU_NO_AGENT_DEPS_PROMPT !== "1" && !(await md.hasAgentLibs())) {
12801
12869
  const skipDeps = await loadSessionSkip(resolve(process.cwd()), "skipDepsPrompt");
12802
12870
  if (!skipDeps) {
12803
12871
  console.log(
@@ -13338,7 +13406,12 @@ async function main() {
13338
13406
  // TRANSCRIPT: the echo is written exactly once per submitted line and never repainted,
13339
13407
  // so there is nothing here for a blank to accumulate against.
13340
13408
  // As typed, not as sent: "[Image #1]", never the file path the agent needs (#51).
13341
- const echo = PROMPT_PAST + imageMarkersForDisplay(line) + "\n\n";
13409
+ // Every row under the first one's text (#348): a pasted multi-line question used to be
13410
+ // echoed with only its first line indented, the rest hard against the left edge.
13411
+ const echo = questionEchoText(imageMarkersForDisplay(line), {
13412
+ width: process.stdout.columns || 80,
13413
+ paintPrompt: () => PROMPT_PAST,
13414
+ }) + "\n\n";
13342
13415
  if (stopNotice) {
13343
13416
  stopNotice = "";
13344
13417
  if (stopNoticeTimer) clearTimeout(stopNoticeTimer);
@@ -13418,7 +13491,10 @@ async function main() {
13418
13491
  }
13419
13492
  if (line === "/embed-model") {
13420
13493
  await chooseEmbedModel();
13421
- embedMissing = false; // Task #14: an embed model was just chosen → clear its warning
13494
+ // Read back what is SAVED (#347). The warning used to be cleared here whatever happened —
13495
+ // a cancelled menu or a failed save removed it for the session, and it returned at the
13496
+ // next start as if the choice had been forgotten.
13497
+ await syncEmbedWarning({ repair: false });
13422
13498
  continue;
13423
13499
  }
13424
13500
  if (line === "/server") {
package/model-prices.mjs CHANGED
@@ -177,6 +177,33 @@ export function turnCost({ input = 0, cached = 0, cacheReported = false, firstIn
177
177
  }
178
178
 
179
179
  /** "$1.23", "$0.123", "$0.0042"; "~" in front when the cached share was assumed. */
180
+ /**
181
+ * WHAT THE BAR SAYS ABOUT THE LAST TURN'S COST (task #349).
182
+ *
183
+ * Reported: "price and cost is not shown on this gu terminal on this Mac." The coder there was a
184
+ * self-hosted Ollama model, which is in no price table — and for a model with no price the bar
185
+ * showed NOTHING, by design ("never a misleading | $0"). Right about the $0, wrong about the
186
+ * silence: a missing figure looked like a broken feature. So the three cases are told apart:
187
+ *
188
+ * a price was found → { kind: "cost", text: "$0.334" | "~$0.334" }
189
+ * the turn's model has none → { kind: "none", text: "no price" } — never "$0": it is not free,
190
+ * it is not priced
191
+ * no turn has run yet → null (there is nothing to say)
192
+ *
193
+ * `last` is the terminal's lastTurnCost: { usd, estimated } or { usd: null, model, host }.
194
+ */
195
+ export function costSegment(last) {
196
+ if (!last) return null;
197
+ if (last.usd == null) return { kind: "none", text: "no price" };
198
+ return { kind: "cost", text: formatUsd(last.usd, { estimated: last.estimated === true }) };
199
+ }
200
+
201
+ /** The line /prices prints when the coder in use has no price — why, and that nothing is broken. */
202
+ export function noPriceLine({ model = "", host = "" } = {}) {
203
+ const what = `${model || "the coder"}${host ? ` at ${host}` : ""}`;
204
+ return `no price for ${what} — its turns show "no price" on the bar instead of a cost. The table prices hosted providers; a model on your own server has none.`;
205
+ }
206
+
180
207
  export function formatUsd(usd, { estimated = false } = {}) {
181
208
  if (!Number.isFinite(usd)) return "";
182
209
  const v = Math.max(0, usd);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * THE WAY OUT OF THE MODEL QUESTIONS, and the key question that follows a typed server (#301).
3
3
  *
4
- * Reported: at "Agent + embedding server [https://ollama1.gomarsic.cc]:" there was no visible way
4
+ * Reported: at "Agent + embedding server [https://ollama1.getmarsic.cc]:" there was no visible way
5
5
  * to NOT choose. Enter takes the default server; a decline existed ("n", "no", "skip", "-") but no
6
6
  * prompt said so, and one hint even claimed "press Enter with no address to skip", which was the
7
7
  * opposite of what Enter does. Some people want no model setup at all and to load a saved preset.
package/ollama-setup.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Pointing a Client Expert machine at ITS OWN Ollama box (task #220).
3
3
  *
4
- * Setup used to seed https://ollama1.gomarsic.cc and three model names, so anyone whose box is
4
+ * Setup used to seed https://ollama1.getmarsic.cc and three model names, so anyone whose box is
5
5
  * somewhere else — a company LAN, http://192.168.1.40:11434, their own dnsname — had to know to
6
6
  * override environment variables they had never heard of. This module is the pure half of asking
7
7
  * instead: normalise whatever URL they type, and turn the list that box actually reports into the
@@ -180,8 +180,8 @@ export function describeNotReady(host, missing) {
180
180
  * 192.168.1.40:11434 → http://192.168.1.40:11434/v1
181
181
  * ollama.local → http://ollama.local:11434/v1
182
182
  * http://box:11434/v1 → http://box:11434/v1
183
- * https://ollama1.gomarsic.cc → https://ollama1.gomarsic.cc/v1
184
- * https://ollama1.gomarsic.cc/v1/ → https://ollama1.gomarsic.cc/v1
183
+ * https://ollama1.getmarsic.cc → https://ollama1.getmarsic.cc/v1
184
+ * https://ollama1.getmarsic.cc/v1/ → https://ollama1.getmarsic.cc/v1
185
185
  *
186
186
  * TWO DEFAULTS, AND THEY ARE NOT SYMMETRICAL:
187
187
  *
@@ -243,7 +243,7 @@ export function normalizeOllamaUrl(raw) {
243
243
  * Every address a bare hostname could reasonably mean, in the order worth trying.
244
244
  *
245
245
  * ONE GUESS IS NOT ENOUGH, and this was found by typing a real hostname at a real prompt:
246
- * `ollama2.gomarsic.cc` became `http://ollama2.gomarsic.cc:11434` and timed out, because that
246
+ * `ollama2.getmarsic.cc` became `http://ollama2.getmarsic.cc:11434` and timed out, because that
247
247
  * box is behind a reverse proxy on HTTPS:443 — exactly like ollama1. The single default is
248
248
  * right for a LAN box and wrong for a public name, and the reader has no way to know which
249
249
  * assumption we made beyond a timeout that looks like their server is down.
@@ -390,7 +390,7 @@ export async function localOllamaDefault(fallbackUrl, fetchTags, { log } = {}) {
390
390
  *
391
391
  * DISPLAY ONLY. Every settings field in gu stores the /v1 base, because that is the
392
392
  * OpenAI-compatible endpoint the API actually calls; nothing about what is SAVED changes here.
393
- * What changes is what we show: a default of "https://ollama1.gomarsic.cc/v1" reads as an
393
+ * What changes is what we show: a default of "https://ollama1.getmarsic.cc/v1" reads as an
394
394
  * instruction, and the reader is left wondering whether their own address needs the suffix too
395
395
  * (reported 2026-09-04: "it makes user confused if he/she should input v1 to the url").
396
396
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.795",
3
+ "version": "1.0.796",
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",
package/proxy-config.mjs CHANGED
@@ -126,8 +126,8 @@ export function isLoopbackUrl(url) {
126
126
  /**
127
127
  * The origin (scheme + host + port) of a URL, which is what "the same endpoint" means here.
128
128
  *
129
- * A configured model is https://ollama1.gomarsic.cc/v1 while the actual call is
130
- * https://ollama1.gomarsic.cc/v1/chat/completions — comparing full URLs would match neither, and
129
+ * A configured model is https://ollama1.getmarsic.cc/v1 while the actual call is
130
+ * https://ollama1.getmarsic.cc/v1/chat/completions — comparing full URLs would match neither, and
131
131
  * comparing hostnames alone would proxy a DIFFERENT service that happens to share the host.
132
132
  * Origin is the level that means "this endpoint", and URL normalizes the default port for us.
133
133
  */
@@ -141,7 +141,7 @@ export function originOf(url) {
141
141
 
142
142
  /**
143
143
  * The three model endpoints, read from the settings row rather than hardcoded — the user can
144
- * change any of them at any time (/model, the web app), and a hardcoded ollama1.gomarsic.cc
144
+ * change any of them at any time (/model, the web app), and a hardcoded ollama1.getmarsic.cc
145
145
  * would keep proxying an endpoint they have already moved away from.
146
146
  */
147
147
  export function modelOrigins(settings = {}) {
@@ -63,10 +63,38 @@ export function applyToggleShift(blocks, block, newEnd) {
63
63
  * write(seq) → put bytes on screen.
64
64
  * afterPaint() → put the caret back where the user is typing.
65
65
  */
66
+ /**
67
+ * DOES A CLICK ON THIS BLOCK CHANGE ANYTHING? (task #350) — the ONE question hover and click
68
+ * both ask.
69
+ *
70
+ * Reported: "hover an item and it becomes whiter; some items, you click and nothing expands — so
71
+ * the user is confused. Only allow hover to turn white if it is expandable." A highlight is a
72
+ * promise that a click does something. Every thought and every look-up row made that promise;
73
+ * a short thought that is already shown whole could not keep it.
74
+ *
75
+ * A block has two forms, collapsed and expanded. It can be toggled exactly when they are not the
76
+ * same rows — which answers every case without a list of kinds:
77
+ * · a long thought, collapsed → different rows → highlight, click expands
78
+ * · the same thought, expanded → different rows → highlight, click collapses
79
+ * · a thought that fits as it is → identical rows → no highlight, click does nothing
80
+ * · one look-up that fits its row → identical rows → no highlight
81
+ * …and it follows the WINDOW: the rows are laid out for the current width, so a thought that
82
+ * fits at 160 columns and is cut at 80 is expandable at 80 only.
83
+ */
84
+ export function rowsDiffer(collapsedRows, expandedRows) {
85
+ const a = collapsedRows ?? [];
86
+ const b = expandedRows ?? [];
87
+ if (a.length !== b.length) return true;
88
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return true;
89
+ return false;
90
+ }
91
+
66
92
  export function createHoverController({
67
93
  pinned = () => true,
68
94
  transcriptRows = () => 1,
69
95
  blockAtRow = () => null,
96
+ // #350: may this block be highlighted — i.e. would a click on it change anything?
97
+ canToggle = () => true,
70
98
  paintRows = () => [],
71
99
  replaceRange = () => null,
72
100
  repaintSeq = () => "",
@@ -101,7 +129,10 @@ export function createHoverController({
101
129
  if (key === lastKey) return false;
102
130
  lastKey = key;
103
131
 
104
- const block = blockAtRow(row);
132
+ // ONLY WHAT A CLICK WOULD CHANGE (#350). A block that is already shown whole is treated as
133
+ // no block at all: nothing lights up, and whatever was lit before is put back.
134
+ const under = blockAtRow(row);
135
+ const block = under && canToggle(under) ? under : null;
105
136
  if (block === hovered) return false;
106
137
  const previous = hovered;
107
138
  hovered = block;
@@ -528,6 +528,61 @@ export function buildFooterRows({
528
528
  ];
529
529
  }
530
530
 
531
+ /**
532
+ * A SUBMITTED QUESTION, LAID OUT FOR THE TRANSCRIPT (task #348): every row at the column where
533
+ * the first one's text starts — after ">> ".
534
+ *
535
+ * Reported with a screenshot: a pasted question of many lines was echoed as ">> Build the first
536
+ * playable…" and then every other line hard against the left edge. The echo was the prompt plus
537
+ * the text exactly as typed, so only the first line had anything in front of it; a long line was
538
+ * left for the terminal to soft-wrap, back to column 0 as well.
539
+ *
540
+ * → rows WITHOUT the prompt: the caller puts ">> " before the first and `indent` spaces before
541
+ * the rest. Blank lines stay empty (no trailing spaces). A line's own leading spaces are kept —
542
+ * pasted code and nested lists keep their shape — and its wrapped rows start under its first
543
+ * word. A word longer than the row is cut rather than left to overflow.
544
+ */
545
+ export function questionEchoRows(text, { width = 80, indent = 3 } = {}) {
546
+ const rows = [];
547
+ for (const line of String(text ?? "").replace(/\r/g, "").split("\n")) {
548
+ if (line.trim() === "") {
549
+ rows.push("");
550
+ continue;
551
+ }
552
+ const lead = /^[ \t]*/.exec(line)[0].replace(/\t/g, " ");
553
+ const avail = Math.max(8, (width || 80) - 1 - indent - visibleLen(lead));
554
+ let cur = "";
555
+ for (let word of line.trim().split(/\s+/)) {
556
+ while (visibleLen(word) > avail) {
557
+ if (cur) {
558
+ rows.push(lead + cur);
559
+ cur = "";
560
+ }
561
+ const head = clipVisible(word, avail);
562
+ rows.push(lead + head);
563
+ word = word.slice(head.length);
564
+ }
565
+ if (!word) continue;
566
+ const next = cur ? `${cur} ${word}` : word;
567
+ if (visibleLen(next) <= avail) cur = next;
568
+ else {
569
+ rows.push(lead + cur);
570
+ cur = word;
571
+ }
572
+ }
573
+ if (cur) rows.push(lead + cur);
574
+ }
575
+ return rows;
576
+ }
577
+
578
+ /** The echo itself: ">> " on the first row, the same width of spaces on every other. */
579
+ export function questionEchoText(text, { width = 80, prompt = ">> ", paintPrompt = (s) => s } = {}) {
580
+ const indent = visibleLen(prompt);
581
+ const rows = questionEchoRows(text, { width, indent });
582
+ if (rows.length === 0) return paintPrompt(prompt);
583
+ return rows.map((r, i) => (i === 0 ? paintPrompt(prompt) + r : r === "" ? "" : " ".repeat(indent) + r)).join("\n");
584
+ }
585
+
531
586
  /**
532
587
  * The bytes that print a NEW thought block into the transcript (task #344): its painted rows,
533
588
  * with ONE empty line above when the row above is not already blank (`gap`).