@tiens.nguyen/gu-cli 1.0.694 → 1.0.696

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/accent.mjs ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * THE BRAND COLOUR, IN A TERMINAL.
3
+ *
4
+ * gu's accent was ANSI cyan — one of the original sixteen colours, chosen when the only question
5
+ * was "which of these eight-ish names looks least bad". The web app has a real palette now, built
6
+ * around indigo, and the terminal is the face of this product people see every day: two different
7
+ * accents makes them look like two different programs.
8
+ *
9
+ * THE COLOUR IS #a5b4fc — the web app's --accent-fg, the same token that paints the block wordmark
10
+ * on the landing page. Not --accent (#6366f1): that one is a BUTTON FILL, and as text on a dark
11
+ * terminal it is too dark to read comfortably. The web app makes exactly the same distinction.
12
+ *
13
+ * WHICH IS A PROBLEM, BECAUSE TERMINALS DISAGREE ABOUT COLOUR. There is no "purple" to ask for —
14
+ * there are three separate mechanisms, and a terminal supports one of them:
15
+ *
16
+ * 24-bit \x1b[38;2;165;180;252m the exact colour. iTerm2, VS Code, Windows Terminal,
17
+ * kitty, alacritty, most modern emulators.
18
+ * 256 \x1b[38;5;147m the nearest cell of the xterm cube (#afafff). Apple's
19
+ * Terminal.app is here — it has never supported 24-bit.
20
+ * 16 \x1b[35m magenta, the only purple the original palette has. Old
21
+ * terminals, CI logs, anything reporting a TERM it cannot
22
+ * back up.
23
+ *
24
+ * Sending a 24-bit sequence to a terminal that does not understand it does not degrade — some
25
+ * print the escape as text, across the banner. So the capability is detected rather than assumed,
26
+ * and the fallbacks are deliberate rather than accidental.
27
+ *
28
+ * ESCAPE HATCHES, because a colour is a preference and someone will hate this one:
29
+ * NO_COLOR=1 no colour at all (the standard; https://no-color.org)
30
+ * GU_ACCENT=cyan the old cyan, exactly as it was
31
+ * GU_ACCENT=off same as NO_COLOR, for people who only want gu quiet
32
+ */
33
+
34
+ /** The web app's --accent-fg. Keep in step with web-v2/src/App.css. */
35
+ export const ACCENT_RGB = [165, 180, 252];
36
+ /** The nearest xterm-256 cube cell: #afafff. */
37
+ export const ACCENT_256 = 147;
38
+
39
+ export const TRUECOLOR = `\x1b[38;2;${ACCENT_RGB.join(";")}m`;
40
+ export const COLOR_256 = `\x1b[38;5;${ACCENT_256}m`;
41
+ export const COLOR_16 = "\x1b[35m";
42
+ export const CYAN = "\x1b[36m";
43
+ export const RESET = "\x1b[0m";
44
+
45
+ /**
46
+ * The escape sequence this environment should get, or "" for no colour.
47
+ *
48
+ * Pure, and takes the environment, so every branch is testable — the alternative is a colour
49
+ * nobody can check without owning six terminal emulators.
50
+ */
51
+ export function accentPrefix(env = process.env) {
52
+ const pref = String(env.GU_ACCENT ?? "").trim().toLowerCase();
53
+ if (pref === "off" || pref === "none") return "";
54
+ if (pref === "cyan") return CYAN;
55
+ // NO_COLOR: "when present and not an empty string, regardless of its value".
56
+ if (env.NO_COLOR != null && String(env.NO_COLOR) !== "") return "";
57
+
58
+ const term = String(env.TERM ?? "").toLowerCase();
59
+ if (term === "dumb") return "";
60
+
61
+ const colorterm = String(env.COLORTERM ?? "").trim().toLowerCase();
62
+ if (colorterm === "truecolor" || colorterm === "24bit") return TRUECOLOR;
63
+ // Windows Terminal supports 24-bit but sets neither TERM nor COLORTERM.
64
+ if (env.WT_SESSION) return TRUECOLOR;
65
+ if (/truecolor|direct/.test(term)) return TRUECOLOR;
66
+ if (term.includes("256color")) return COLOR_256;
67
+
68
+ // Everything else — including a TERM we have never heard of, and Windows consoles that report
69
+ // nothing — gets the sixteen-colour magenta. It is the conservative answer: every terminal that
70
+ // renders colour at all renders this one.
71
+ return COLOR_16;
72
+ }
73
+
74
+ /** Which mechanism was chosen. For `gu doctor`-style reporting, and for the tests. */
75
+ export function accentMode(env = process.env) {
76
+ const prefix = accentPrefix(env);
77
+ if (prefix === "") return "none";
78
+ if (prefix === TRUECOLOR) return "truecolor";
79
+ if (prefix === COLOR_256) return "256";
80
+ if (prefix === CYAN) return "cyan";
81
+ return "16";
82
+ }
83
+
84
+ /**
85
+ * Wrap a string in the accent colour.
86
+ *
87
+ * Resolved ONCE at module load, like the colour helpers it replaces: these are called thousands of
88
+ * times while drawing a frame, and re-reading the environment for each would be work done to answer
89
+ * a question that cannot change.
90
+ */
91
+ const PREFIX = accentPrefix();
92
+ export function accent(s) {
93
+ return PREFIX ? `${PREFIX}${s}${RESET}` : String(s);
94
+ }
95
+
96
+ /** Bold accent — the startup banner's name. */
97
+ export function accentBold(s) {
98
+ return PREFIX ? `\x1b[1m${PREFIX}${s}${RESET}` : String(s);
99
+ }
@@ -74,7 +74,8 @@ async function fetchOllamaTags(url) {
74
74
 
75
75
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
76
76
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
77
- const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
77
+ // The brand accent, shared with the REPL — see accent.mjs.
78
+ import { accent as cyan } from "./accent.mjs";
78
79
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
79
80
  const red = (s) => `\x1b[31m${s}\x1b[0m`;
80
81
 
@@ -52,6 +52,34 @@ export function isKeylessCodingHost(url) {
52
52
  return host === "gomarsic.cc" || host.endsWith(".gomarsic.cc");
53
53
  }
54
54
 
55
+ /**
56
+ * TRUE only for the Ollama CLOUD (https://ollama.com, or a subdomain of it). Mirrors
57
+ * isOllamaCloudUrl in api/src/store/settingsTypes.ts, web/src/lib/coderChoices.mjs and
58
+ * _isOllamaCloudUrl in gu-cli.mjs — keep them in step.
59
+ */
60
+ export function isOllamaCloudUrl(url) {
61
+ const v = String(url ?? "").trim();
62
+ if (!/^https:\/\//i.test(v)) return false;
63
+ let host;
64
+ try {
65
+ host = new URL(v).hostname.toLowerCase().replace(/\.$/, "");
66
+ } catch {
67
+ return false;
68
+ }
69
+ return host === "ollama.com" || host.endsWith(".ollama.com");
70
+ }
71
+
72
+ /**
73
+ * DOES THIS CODER NEED AN API KEY? — an OpenAI-compatible provider, or Ollama's paid CLOUD.
74
+ *
75
+ * Reported: /model on kimi-k3 at https://ollama.com said "needs no API key — nothing to set", so
76
+ * there was no way to give it one. Every "needs a key" check had been `kind === "openai"`, on the
77
+ * belief that Ollama never authenticates — true of a local or LAN Ollama, false of ollama.com.
78
+ */
79
+ export function codingNeedsKey(kind, url) {
80
+ return kind === "openai" || (kind === "ollama" && isOllamaCloudUrl(url));
81
+ }
82
+
55
83
  /**
56
84
  * @param payload the /agent-payload `payload` object (reads codingKeyMissing/codingKeyModel).
57
85
  * @param deps.askSecret async (prompt) => string — masked input; "" means the user skipped.
@@ -187,9 +215,10 @@ export function codingKeyFallbackLine(kj, jobId = "") {
187
215
  * when a choice HAS been made, and when only one key exists — with one there is nothing to
188
216
  * choose, and a warning on a correct setup is how warnings stop being read.
189
217
  */
190
- export function unchosenKeyWarning({ codingKind = "", selectedKeyId = "", keys = null } = {}) {
218
+ export function unchosenKeyWarning({ codingKind = "", codingUrl = "", selectedKeyId = "", keys = null } = {}) {
191
219
  const quiet = { warn: false, using: null, why: "" };
192
- if (codingKind !== "openai") return quiet; // Ollama and local MLX need no credential
220
+ // Local Ollama and MLX need no credential; the Ollama cloud does.
221
+ if (!codingNeedsKey(codingKind, codingUrl)) return quiet;
193
222
  if (!Array.isArray(keys) || keys.length < 2) return quiet;
194
223
  if (String(selectedKeyId ?? "").trim()) return quiet;
195
224
  return { warn: true, using: keys[0], why: "none-chosen" };
@@ -200,7 +229,8 @@ export function unchosenKeyWarning({ codingKind = "", selectedKeyId = "", keys =
200
229
  *
201
230
  * REPORTED: the bar's "key not chosen" mark only appeared after running /model. The startup gate
202
231
  * read `payload.codingKind`, and a payload fetched with NO override describes the ACCOUNT DEFAULT
203
- * — on the reporting machine that was kimi-k3 at ollama.com, kind "ollama", which needs no key —
232
+ * — on the reporting machine kimi-k3 at ollama.com, kind "ollama" (which, it turned out later,
233
+ * DOES need a key: see codingNeedsKey) —
204
234
  * while the folder was pointed at gpt-5, kind "openai". So the check answered a question about a
205
235
  * coder the folder was not using, said "nothing to warn about", and stayed silent. /model looked
206
236
  * right because it re-probes WITH the override.
@@ -212,6 +242,17 @@ export function unchosenKeyWarning({ codingKind = "", selectedKeyId = "", keys =
212
242
  * Matched by id OR by bare model name, mirroring that validation: a session saved before ids were
213
243
  * qualified ("<kind>::<model>", task #123) stores the bare form and is still a valid pick.
214
244
  */
245
+ /** The folder's coder — its kind AND its URL, which together decide whether it needs a key. */
246
+ export function effectiveCoder({ sessionCodingModel = "", choices = null, defaultKind = "", defaultUrl = "" } = {}) {
247
+ const want = String(sessionCodingModel ?? "").trim();
248
+ const list = Array.isArray(choices) ? choices : [];
249
+ if (want) {
250
+ const hit = list.find((c) => c?.id === want || c?.model === want);
251
+ if (hit) return { kind: String(hit.kind ?? ""), url: String(hit.url ?? "") };
252
+ }
253
+ return { kind: String(defaultKind ?? ""), url: String(defaultUrl ?? "") };
254
+ }
255
+
215
256
  export function effectiveCoderKind({
216
257
  sessionCodingModel = "",
217
258
  choices = null,
@@ -9707,9 +9707,9 @@ def run_agent_chat(cfg):
9707
9707
  raise _AgentConfigError(
9708
9708
  f"The agent coding backend ({_host}) rejected the request: "
9709
9709
  "NO coding API key was sent, so the local agent key was "
9710
- "used instead. Set 'Coding backend' to OpenAI-compatible "
9711
- "in Settings → Agent and enter the API key — the key field "
9712
- "only appears once that backend is selected."
9710
+ "used instead. Enter the API key in Settings → Agent for "
9711
+ "this coding backend (OpenAI-compatible, or Ollama with an "
9712
+ "https://ollama.com URL — local Ollama needs no key)."
9713
9713
  ) from e
9714
9714
  if "404" in emsg and "not found" in emsg.lower():
9715
9715
  # Probe with the CODING key when there is one: using the agent
package/gu-cli.mjs CHANGED
@@ -12,13 +12,15 @@
12
12
  // FIRST, before anything that could itself fail on an old runtime: npm only WARNS about
13
13
  // `engines`, so an unsupported Node installs fine and then breaks somewhere unrelated.
14
14
  import { requireSupportedNode } from "./node-version.mjs";
15
+ // The brand accent, shared with the REPL — see accent.mjs.
16
+ import { accent } from "./accent.mjs";
15
17
  requireSupportedNode();
16
18
  import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
17
19
  import { execFile as execFileCallback, spawn } from "node:child_process";
18
20
  import { createHash } from "node:crypto";
19
21
  import { createTaskPool, createFolderQueue } from "./job-pools.mjs";
20
22
  import { ragJobConfig } from "./rag-job-config.mjs";
21
- import { codingKeyFallbackLine } from "./coding-key-prompt.mjs";
23
+ import { codingKeyFallbackLine, isOllamaCloudUrl } from "./coding-key-prompt.mjs";
22
24
  // Pure layout helpers — safe to import here (no side effects on load, unlike gu-repl.mjs).
23
25
  // Shared so the mid-run picker and the REPL's post-stop advice cannot word the same budget
24
26
  // differently.
@@ -1207,7 +1209,7 @@ if (
1207
1209
  // shared ones, and `node --check` cannot see a missing helper — it is a syntax checker, not a
1208
1210
  // resolver. That precise shape has crashed this CLI before.
1209
1211
  const bold = (t) => `\x1b[1m${t}\x1b[0m`;
1210
- const cyan = (t) => `\x1b[36m${t}\x1b[0m`;
1212
+ const cyan = (t) => accent(t);
1211
1213
  const red = (t) => `\x1b[31m${t}\x1b[0m`;
1212
1214
  const dim = (t) => `\x1b[2m${t}\x1b[0m`;
1213
1215
  console.log("\n" + bold("proxy") + dim(" — can this machine route model calls through a proxy?\n"));
@@ -1238,7 +1240,7 @@ if (args[0] === "rag" && (args.includes("--test") || args[1] === "test")) {
1238
1240
  // cannot see a missing one — it is a syntax checker, not a resolver. That exact shape has
1239
1241
  // crashed this CLI before.
1240
1242
  const bold = (t) => `\x1b[1m${t}\x1b[0m`;
1241
- const cyan = (t) => `\x1b[36m${t}\x1b[0m`;
1243
+ const cyan = (t) => accent(t);
1242
1244
  const red = (t) => `\x1b[31m${t}\x1b[0m`;
1243
1245
  const dim = (t) => `\x1b[2m${t}\x1b[0m`;
1244
1246
  console.log("\n" + bold("rag") + dim(" — can this machine embed and retrieve?\n"));
@@ -3263,6 +3265,9 @@ function _isLocalCodingUrl(baseURL) {
3263
3265
  return !host.includes(".") && !host.includes(":");
3264
3266
  }
3265
3267
 
3268
+ /** The Ollama cloud check lives in coding-key-prompt.mjs, shared with the terminal. */
3269
+ const _isOllamaCloudUrl = isOllamaCloudUrl;
3270
+
3266
3271
  /** Resolve to "ollama" | "openai" | "local". `kind` is the user's Settings choice
3267
3272
  * ("ollama" | "openai" | "" = Auto). Only Auto costs a probe. */
3268
3273
  async function resolveCodingBackend(kind, baseURL) {
@@ -3445,7 +3450,9 @@ async function runAgentChatJob(job) {
3445
3450
  // anything else that endpoint doesn't exist, and on a cloud one we'd be pinging a paid
3446
3451
  // API every 4 minutes (which an Auto+cloud config did until #117 was fixed — the old
3447
3452
  // gate only skipped an EXPLICIT openai kind).
3448
- if (codingBackend === "ollama") {
3453
+ // ...and NOT for the Ollama CLOUD (https://ollama.com): that is a paid API, so the
3454
+ // keep-warm ping must not run against it — same rule as the key fetch below.
3455
+ if (codingBackend === "ollama" && !_isOllamaCloudUrl(payload?.codingBaseURL)) {
3449
3456
  trackCodingModelForWarmup(payload?.codingBaseURL ?? "", payload?.codingModelId ?? "");
3450
3457
  }
3451
3458
 
@@ -3598,7 +3605,9 @@ async function runAgentChatJob(job) {
3598
3605
  // "openai" kind, and python's `coding_api_key or agent_api_key` fallback then
3599
3606
  // authenticated a cloud provider with the LOCAL agent key → 401.
3600
3607
  let codingApiKey = "";
3601
- if (codingBackend === "openai") {
3608
+ // Ollama has no auth — EXCEPT the Ollama cloud (https://ollama.com), which requires a
3609
+ // key like any other paid endpoint. A local/LAN Ollama gets no key (and ignores one).
3610
+ if (codingBackend === "openai" || (codingBackend === "ollama" && _isOllamaCloudUrl(payload?.codingBaseURL))) {
3602
3611
  try {
3603
3612
  // NAME THE JOB (task #222 phase 2). Two turns run concurrently by default
3604
3613
  // (agentConcurrency = 2), and each may have chosen a different key — so the key is
package/gu-repl.mjs CHANGED
@@ -133,6 +133,10 @@ import { createHoverController, applyToggleShift } from "./terminal-hover.mjs";
133
133
  // The required pair, imported rather than re-typed: the startup "no models" message names them,
134
134
  // and a second copy of these strings would drift from the check that enforces them.
135
135
  import { REQUIRED_AGENT_MODEL, REQUIRED_AGENT_PREFIX, REQUIRED_EMBED_MODEL } from "./ollama-setup.mjs";
136
+ // The brand accent — indigo, the same token the web app paints its wordmark with. It is NOT a
137
+ // plain SGR code: terminals disagree about colour, so accent.mjs picks 24-bit, 256 or the
138
+ // sixteen-colour magenta from what this one actually claims to support.
139
+ import { accent } from "./accent.mjs";
136
140
  import {
137
141
  createTranscript,
138
142
  displayRows,
@@ -169,7 +173,6 @@ const WS_FILE = join(homedir(), ".gonext", "workspaces.json");
169
173
  const SESSIONS_DIR = join(homedir(), ".gonext", "sessions");
170
174
 
171
175
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
172
- const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
173
176
  const red = (s) => `\x1b[31m${s}\x1b[0m`;
174
177
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
175
178
  const white = (s) => `\x1b[37m${s}\x1b[0m`;
@@ -327,11 +330,11 @@ const renderBanner = (statusRows) => {
327
330
  if (GUTTER.length + LOGO_W + gap.length + widest <= cols) {
328
331
  const n = Math.max(LOGO.length, statusRows.length);
329
332
  for (let i = 0; i < n; i++) {
330
- const left = i < LOGO.length ? cyan(LOGO[i].padEnd(LOGO_W)) : " ".repeat(LOGO_W);
333
+ const left = i < LOGO.length ? accent(LOGO[i].padEnd(LOGO_W)) : " ".repeat(LOGO_W);
331
334
  out.push(GUTTER + left + gap + (statusRows[i] ?? ""));
332
335
  }
333
336
  } else {
334
- for (const r of LOGO) out.push(GUTTER + cyan(r));
337
+ for (const r of LOGO) out.push(GUTTER + accent(r));
335
338
  for (const r of statusRows) out.push(GUTTER + clipVisible(r, cols - 1 - GUTTER.length));
336
339
  }
337
340
  return out.join("\n");
@@ -503,7 +506,7 @@ const brightText = (s) => s; // default terminal colour (un-dimmed) — the "pop
503
506
  // forward reference here is a startup crash, not a lint warning.
504
507
  const BULLET_STYLE = {
505
508
  fail: { mark: CROSS, markColor: red, textColor: brightText },
506
- ok: { mark: CHECK, markColor: cyan, textColor: dim },
509
+ ok: { mark: CHECK, markColor: accent, textColor: dim },
507
510
  info: { mark: BULLET, markColor: dim, textColor: dim },
508
511
  };
509
512
  const classifyBullet = (text) => BULLET_STYLE[bulletKind(text)];
@@ -536,7 +539,7 @@ const cliVerdict = classifyCliArg(argv, CLI_SUBCOMMANDS);
536
539
  if (cliVerdict.kind === "unknown") {
537
540
  console.error(red(`gu: unknown command "${cliVerdict.typed}"`));
538
541
  if (cliVerdict.suggestion) {
539
- console.error(cyan(` did you mean: gu ${cliVerdict.suggestion}`));
542
+ console.error(accent(` did you mean: gu ${cliVerdict.suggestion}`));
540
543
  }
541
544
  console.error(dim(` commands: ${CLI_SUBCOMMANDS.join(" · ")}`));
542
545
  console.error(dim(" gu --help what each one does · gu (no command) opens the agent"));
@@ -625,7 +628,7 @@ if (!workerKey || !apiBase) {
625
628
  output: process.stdout,
626
629
  });
627
630
  const ask0 = (q) =>
628
- new Promise((res) => rl0.question(cyan(q), (a) => res(String(a || ""))));
631
+ new Promise((res) => rl0.question(accent(q), (a) => res(String(a || ""))));
629
632
  let typed = "";
630
633
  // Set when Client Expert setup stops on something the user must do first: the run ends
631
634
  // after the instructions instead of continuing into questions that assume a paired,
@@ -739,7 +742,7 @@ if (!workerKey || !apiBase) {
739
742
  } else {
740
743
  console.error(
741
744
  red("gu: no worker key / API base found.") +
742
- `\nRun ${cyan("gu-cli login")} to sign in, or configure manually:` +
745
+ `\nRun ${accent("gu-cli login")} to sign in, or configure manually:` +
743
746
  "\n gu-cli set <workerKey> --api-base https://chat-api-v2.gomarsic.cc"
744
747
  );
745
748
  process.exit(1);
@@ -820,8 +823,8 @@ function hintRows(cmds, sel) {
820
823
  cmds.forEach((c, i) => {
821
824
  const label = c.usage ?? c.name;
822
825
  const alias = c.aliases?.length ? dim(` (${c.aliases.join(", ")})`) : "";
823
- const body = `${cyan(label)}${alias}${dim(" — " + c.desc)}`;
824
- rows.push(i === sel ? `${cyan("❯")} ${bold(body)}` : ` ${body}`);
826
+ const body = `${accent(label)}${alias}${dim(" — " + c.desc)}`;
827
+ rows.push(i === sel ? `${accent("❯")} ${bold(body)}` : ` ${body}`);
825
828
  });
826
829
  return rows;
827
830
  }
@@ -1329,7 +1332,7 @@ if (IS_TTY) {
1329
1332
  // the user's typed input lands on the SAME text column (GUTTER+1) as the agent's bullets and
1330
1333
  // answer. ">> " is exactly GUTTER-wide, so input aligns; the ">>" right-edge sits at the same
1331
1334
  // column the ● / ✓ / ✗ markers hang at (markers right-aligned in the margin, text in one column).
1332
- const PROMPT = cyan(">> ");
1335
+ const PROMPT = accent(">> ");
1333
1336
  // …and once a question has been SUBMITTED, its marker recedes to grey. Cyan then means exactly
1334
1337
  // one thing on screen — "this is the line you are typing" — while the questions already asked
1335
1338
  // are history, the same way completed actions dim (see the UI hierarchy in #41/#115).
@@ -1748,7 +1751,7 @@ const ragSegmentText = () => {
1748
1751
  // one's. Showing it bright would claim the current prompt has been measured when it has
1749
1752
  // not; showing nothing would put a misleading 0 there. Dim says "last known".
1750
1753
  if (b.stale) return dim(" · rag ") + dim(`${fmtTokens(b.rag)} ${pct}%·`) + lexSeg;
1751
- return dim(" · rag ") + cyan(`${fmtTokens(b.rag)} ${pct}%`) + lexSeg;
1754
+ return dim(" · rag ") + accent(`${fmtTokens(b.rag)} ${pct}%`) + lexSeg;
1752
1755
  }
1753
1756
  // A STATE, NOT A MEASUREMENT — and states are verbose-only (user, 2026-09-05: "we need to
1754
1757
  // hide rag - not indexed, only show if user go with gu --verbose").
@@ -1853,7 +1856,7 @@ function announceCliUpdate() {
1853
1856
  cliNoticeShown = true;
1854
1857
  console.log("");
1855
1858
  for (const l of lines) {
1856
- if (l.kind === "head") console.log(cyan(" ⭯ " + l.text));
1859
+ if (l.kind === "head") console.log(accent(" ⭯ " + l.text));
1857
1860
  else if (l.kind === "cmd") console.log(" " + l.text);
1858
1861
  else console.log(dim(" " + l.text));
1859
1862
  }
@@ -1909,7 +1912,7 @@ async function runUpdate() {
1909
1912
  r.stderr ? ` — ${String(r.stderr).trim().split("\n").slice(-1)[0]}` : ""}`);
1910
1913
  }
1911
1914
  });
1912
- console.log(cyan(` ✓ API updated to ${api}\n`));
1915
+ console.log(accent(` ✓ API updated to ${api}\n`));
1913
1916
  availableUpdates = { ...availableUpdates, api: "", any: Boolean(availableUpdates.cli) };
1914
1917
  } else {
1915
1918
  await withPendingStatus(`updating gu to ${cli}`, async () => {
@@ -1930,7 +1933,7 @@ async function runUpdate() {
1930
1933
  });
1931
1934
  // NOT "updated" — the files changed, THIS process did not. Claiming otherwise is the one
1932
1935
  // way this feature could lie to someone.
1933
- console.log(cyan(` ✓ gu ${cli} installed`) +
1936
+ console.log(accent(` ✓ gu ${cli} installed`) +
1934
1937
  dim(` — ${updateRestartNote()}\n`));
1935
1938
  availableUpdates = { ...availableUpdates, cli: "", any: Boolean(availableUpdates.api) };
1936
1939
  }
@@ -1978,7 +1981,7 @@ const identityLineText = () =>
1978
1981
  folder: folderLabel(),
1979
1982
  keyUnchosen: keyChoicePending,
1980
1983
  },
1981
- { orange, cyan, dim, red },
1984
+ { orange, cyan: accent, dim, red },
1982
1985
  );
1983
1986
 
1984
1987
  const tokenLineText = () => {
@@ -2015,10 +2018,10 @@ const tokenLineText = () => {
2015
2018
  // `ctx` and `sent`, which do drive decisions, need more.
2016
2019
  ragSegmentText() +
2017
2020
  dim(" · ") +
2018
- cyan(`↓ out ${fmtTokens(lastTurnOutputTokens)}`) +
2021
+ accent(`↓ out ${fmtTokens(lastTurnOutputTokens)}`) +
2019
2022
  dim(" · ") +
2020
2023
  dim("total ") +
2021
- cyan(`↓ ${fmtTokens(Math.max(globalOutputTokens, lastTurnTotalOutput))}`)
2024
+ accent(`↓ ${fmtTokens(Math.max(globalOutputTokens, lastTurnTotalOutput))}`)
2022
2025
  );
2023
2026
  };
2024
2027
  // The newest RAG index file the agent reported writing (task #136), and where it lives.
@@ -2034,7 +2037,7 @@ const ragLineText = () => {
2034
2037
  const parts = lastRagFile.split("/").filter(Boolean);
2035
2038
  const shortName = parts.slice(-2).join("/") || lastRagFile;
2036
2039
  return (
2037
- dim("rag · wrote ") + (isS3 ? cyan(`s3:…/${shortName}`) : shortName) +
2040
+ dim("rag · wrote ") + (isS3 ? accent(`s3:…/${shortName}`) : shortName) +
2038
2041
  dim(isS3 ? " (cloud)" : " (local)")
2039
2042
  );
2040
2043
  };
@@ -2146,7 +2149,7 @@ function frozenZone() {
2146
2149
  const zone = inputZoneRows({
2147
2150
  headControl,
2148
2151
  width,
2149
- prompt: secretInput && secretPrompt ? cyan(secretPrompt) : inlinePrompt ? cyan(inlinePrompt) : PROMPT,
2152
+ prompt: secretInput && secretPrompt ? accent(secretPrompt) : inlinePrompt ? accent(inlinePrompt) : PROMPT,
2150
2153
  // WHILE A SECRET IS OPEN, THIS ROW IS THE PROMPT — "kimi-k3 API key: ••••" rather than
2151
2154
  // ">> ••••". Same row, same masking; what changes is that it now says what it wants. The
2152
2155
  // width is measured, not assumed: the label is caller-supplied and every offset in here
@@ -2199,11 +2202,11 @@ function frozenZone() {
2199
2202
  agentsRowTarget = null;
2200
2203
  if (scrolledBack()) {
2201
2204
  const hint = jumpToBottomRow({ below: scrollOffset, width });
2202
- rows[sep] = clipVisible(cyan(hint.text), width - 1);
2205
+ rows[sep] = clipVisible(accent(hint.text), width - 1);
2203
2206
  } else if (availableUpdates.any) {
2204
2207
  const up = updateRow({ ...availableUpdates, width });
2205
2208
  if (up) {
2206
- rows[sep] = clipVisible(dim(up.text.slice(0, up.from)) + cyan(up.text.slice(up.from)), width - 1);
2209
+ rows[sep] = clipVisible(dim(up.text.slice(0, up.from)) + accent(up.text.slice(up.from)), width - 1);
2207
2210
  updateRowTarget = { from: up.from, to: up.to };
2208
2211
  }
2209
2212
  } else if (agentsIndicator(otherAgents)) {
@@ -2216,7 +2219,7 @@ function frozenZone() {
2216
2219
  const len = visibleLen(label);
2217
2220
  if (len + 4 <= width - 1) {
2218
2221
  const from = width - 1 - len;
2219
- rows[sep] = clipVisible(dim("\u2500".repeat(Math.max(0, from - 1)) + " ") + cyan(label), width - 1);
2222
+ rows[sep] = clipVisible(dim("\u2500".repeat(Math.max(0, from - 1)) + " ") + accent(label), width - 1);
2220
2223
  agentsRowTarget = { from, to: from + len };
2221
2224
  }
2222
2225
  }
@@ -2904,7 +2907,7 @@ function paintCopyBadge(row, place, ok = true) {
2904
2907
  //
2905
2908
  // \x1b7/\x1b8 is the ONE save slot the whole REPL shares — the output caret lives in it
2906
2909
  // (task #141) — so this moves and returns explicitly instead of borrowing it.
2907
- ui(`\x1b[${row};${place.col}H` + (ok ? cyan(place.text) : dim(place.text)));
2910
+ ui(`\x1b[${row};${place.col}H` + (ok ? accent(place.text) : dim(place.text)));
2908
2911
  paintBar(); // put the caret back on the input line
2909
2912
  if (copyBadgeTimer) clearTimeout(copyBadgeTimer);
2910
2913
  copyBadgeTimer = setTimeout(() => {
@@ -4014,12 +4017,12 @@ async function ensureWorkspace() {
4014
4017
  wsSync = { enabled: allowSync, name: basename(cwd), path: cwd, run: allowRun };
4015
4018
  console.log(
4016
4019
  GUTTER +
4017
- cyan(`✓ workspace registered: ${basename(cwd)}`) +
4018
- (allowRun ? cyan(" (run-commands enabled)") : dim(" (run-commands disabled)")) +
4020
+ accent(`✓ workspace registered: ${basename(cwd)}`) +
4021
+ (allowRun ? accent(" (run-commands enabled)") : dim(" (run-commands disabled)")) +
4019
4022
  (isClientExpert()
4020
4023
  ? ""
4021
4024
  : allowSync
4022
- ? cyan(workspaceSyncLabel({ allowSync, clientExpert: false }))
4025
+ ? accent(workspaceSyncLabel({ allowSync, clientExpert: false }))
4023
4026
  : dim(workspaceSyncLabel({ allowSync, clientExpert: false })))
4024
4027
  );
4025
4028
  }
@@ -4676,7 +4679,7 @@ async function runModelDoctor() {
4676
4679
  console.log(
4677
4680
  "\n" + GUTTER +
4678
4681
  (bad.length === 0
4679
- ? cyan("✓ every configured model answered.")
4682
+ ? accent("✓ every configured model answered.")
4680
4683
  : red(`✗ ${bad.length} of ${results.length} did not answer.`)) +
4681
4684
  "\n"
4682
4685
  );
@@ -4944,7 +4947,7 @@ async function reconfigureOllamaServer() {
4944
4947
  if (g !== null) globalOutputTokens = g;
4945
4948
  }
4946
4949
  paintBar(); // …and show all of it now, not at the next keystroke
4947
- console.log(cyan(" ✓ models saved"));
4950
+ console.log(accent(" ✓ models saved"));
4948
4951
  if (agent) {
4949
4952
  console.log(dim(` chat ${chat} @ ${agent.normalized.host}`));
4950
4953
  console.log(dim(` RAG ${os.REQUIRED_EMBED_MODEL} @ ${agent.normalized.host}`));
@@ -5221,7 +5224,7 @@ async function changeProviderModel(ps, rows, p) {
5221
5224
  await saveSessionCoding(resolve(process.cwd()));
5222
5225
  console.log(GUTTER + dim("this folder's pick named the old model — cleared it."));
5223
5226
  }
5224
- console.log(cyan(` ✓ ${p.host} → ${model}`) + dim(" · pick it in /model\n"));
5227
+ console.log(accent(` ✓ ${p.host} → ${model}`) + dim(" · pick it in /model\n"));
5225
5228
  }
5226
5229
 
5227
5230
  /** Point a provider at a different URL, keeping its id so saved picks still resolve. */
@@ -5258,7 +5261,7 @@ async function changeProviderUrl(ps, rows, p) {
5258
5261
  }
5259
5262
  // The KEY still belongs to this row, because the row id did not change — which is the whole
5260
5263
  // reason an id is not derived from the URL.
5261
- console.log(cyan(` ✓ ${p.host} → ${r.host}`) + dim(" · its key still applies\n"));
5264
+ console.log(accent(` ✓ ${p.host} → ${r.host}`) + dim(" · its key still applies\n"));
5262
5265
  }
5263
5266
 
5264
5267
  /** Remove a provider, and the keys that would be stranded by its removal. */
@@ -5314,7 +5317,7 @@ async function removeCodingProvider(ps, rows, p) {
5314
5317
  console.log(GUTTER + dim("this folder was using it — cleared the pick.\n"));
5315
5318
  }
5316
5319
  console.log(
5317
- cyan(` ✓ removed ${p.host}`) +
5320
+ accent(` ✓ removed ${p.host}`) +
5318
5321
  (stranded.length ? dim(` · ${deleted} key${deleted === 1 ? "" : "s"} deleted`) : "") +
5319
5322
  "\n"
5320
5323
  );
@@ -5622,7 +5625,7 @@ async function addCodingProvider(roleName = "coding") {
5622
5625
  console.log(GUTTER + dim("run /agent-model to finish, or check the API is reachable.\n"));
5623
5626
  return;
5624
5627
  }
5625
- console.log(cyan(` ✓ agent model → ${model}`) + dim(` · ${host}`));
5628
+ console.log(accent(` ✓ agent model → ${model}`) + dim(` · ${host}`));
5626
5629
  console.log(dim(` API key ${name || "Unknown"} ····${secret.slice(-4)} · this machine\n`));
5627
5630
  // Restart advice, because the banner and the running daemon both read this at startup: a
5628
5631
  // machine that keeps talking to the old model after a successful save looks like the save
@@ -5692,7 +5695,7 @@ async function addCodingProvider(roleName = "coding") {
5692
5695
  // The key question for this coder is now answered — leaving the flag set would warn about a
5693
5696
  // key that was just entered, which reads as the save having failed.
5694
5697
  codingKeyMissingKnown = false;
5695
- console.log(cyan(` ✓ coding model → ${model}`) + dim(` · ${host}`));
5698
+ console.log(accent(` ✓ coding model → ${model}`) + dim(` · ${host}`));
5696
5699
  console.log(dim(` API key ${name || "Unknown"} ····${secret.slice(-4)} · this folder only\n`));
5697
5700
  // A HOSTED PROVIDER NEEDS ITS WINDOW SET (requirement 11). With nothing set gu assumes
5698
5701
  // 24,000 prompt tokens and compacts there — on a large hosted model that throws history away
@@ -5826,7 +5829,7 @@ async function chooseModel() {
5826
5829
  await saveSessionCoding(resolve(process.cwd()));
5827
5830
  const where = showTag ? ` (${tags[idx]})` : "";
5828
5831
  console.log(
5829
- cyan(` ✓ coding model → ${chosen.model}${where}${chosen.id === def ? " (default)" : ""}\n`)
5832
+ accent(` ✓ coding model → ${chosen.model}${where}${chosen.id === def ? " (default)" : ""}\n`)
5830
5833
  );
5831
5834
  // DOES THIS ONE NEED AN API KEY? (task #181) Reported live on Windows 11 / Client Expert:
5832
5835
  // picking kimi-k3 here and asking a question just span — the coder needs a key, none was
@@ -5870,7 +5873,7 @@ async function chooseModel() {
5870
5873
  host: ks.provider,
5871
5874
  });
5872
5875
  console.log(
5873
- dim(" API key ") + cyan(line.text) +
5876
+ dim(" API key ") + accent(line.text) +
5874
5877
  (line.note ? orange(` · ${line.note}`) : "") +
5875
5878
  dim(` · ctrl+k to ${line.verb} it`)
5876
5879
  );
@@ -5928,7 +5931,7 @@ async function chooseEmbedModel() {
5928
5931
  console.log(dim(` Choose the embedder (${PICK_HINT}):`));
5929
5932
  const labels = rows.map(
5930
5933
  (r) => `${r.model}${r.dims ? dim(` · ${r.dims}d`) : ""}${dim(` · ${r.note}`)}` +
5931
- (r.current ? cyan(" (current)") : "")
5934
+ (r.current ? accent(" (current)") : "")
5932
5935
  );
5933
5936
  const idx = await pickFromList(labels, Math.max(0, rows.findIndex((r) => r.current)));
5934
5937
  if (idx < 0) {
@@ -5966,7 +5969,7 @@ async function chooseEmbedModel() {
5966
5969
  console.log(red(` couldn't save that to your account — still using ${current}.\n`));
5967
5970
  return;
5968
5971
  }
5969
- console.log(cyan(` ✓ embedding model → ${chosen}`));
5972
+ console.log(accent(` ✓ embedding model → ${chosen}`));
5970
5973
  if (plan.stale.length) {
5971
5974
  console.log(
5972
5975
  dim(` ${plan.stale.length} knowledge base${plan.stale.length === 1 ? "" : "s"} ` +
@@ -6073,7 +6076,7 @@ async function chooseAgentModel() {
6073
6076
  // "· an embedding model · embedding model, not for chat".
6074
6077
  (!r.note && r.embedder ? dim(" · embedding model, not for chat") : "") +
6075
6078
  (r.missing ? dim(" · not on the box any more") : "") +
6076
- (r.current ? cyan(" (current)") : "")
6079
+ (r.current ? accent(" (current)") : "")
6077
6080
  );
6078
6081
  });
6079
6082
  // APPENDED LAST, after every model, for the reason #223 records: a new entry must never shift
@@ -6125,7 +6128,7 @@ async function chooseAgentModel() {
6125
6128
  console.log(GUTTER + dim("`gu api restart` to pick up the current version.\n"));
6126
6129
  return;
6127
6130
  }
6128
- console.log(cyan(` ✓ agent model → ${chosen}`));
6131
+ console.log(accent(` ✓ agent model → ${chosen}`));
6129
6132
  console.log(dim(" takes effect on your next question.\n"));
6130
6133
  }
6131
6134
 
@@ -6222,7 +6225,7 @@ async function runContextWindowCommand(line) {
6222
6225
  console.log(GUTTER + dim(` set by GONEXT_CODING_CONTEXT_WINDOW=${fromEnv} in the environment —`));
6223
6226
  console.log(GUTTER + dim(" that wins over anything stored here."));
6224
6227
  } else if (stored) {
6225
- console.log(GUTTER + ` context window: ${cyan(fmtTokens(stored))}` +
6228
+ console.log(GUTTER + ` context window: ${accent(fmtTokens(stored))}` +
6226
6229
  dim(` (compacting above ~${fmtTokens(Math.floor(stored * 2 / 3))})`));
6227
6230
  } else {
6228
6231
  console.log(GUTTER + dim(" context window: not set — the worker probes the server, falls"));
@@ -6238,7 +6241,7 @@ async function runContextWindowCommand(line) {
6238
6241
  if (!arg) return show();
6239
6242
  if (arg === "auto" || arg === "clear" || arg === "off") {
6240
6243
  await ctl.setCodingContextWindow(0);
6241
- console.log(GUTTER + cyan("✓ cleared") + dim(" — probing the model server again.\n"));
6244
+ console.log(GUTTER + accent("✓ cleared") + dim(" — probing the model server again.\n"));
6242
6245
  return;
6243
6246
  }
6244
6247
  // "1m" / "128k" as well as digits: the number people quote is the round one.
@@ -6251,7 +6254,7 @@ async function runContextWindowCommand(line) {
6251
6254
  console.log(GUTTER + dim(" give a token count above 4096 — e.g. 128k, 1m, or 1000000.\n"));
6252
6255
  return;
6253
6256
  }
6254
- console.log(GUTTER + cyan(`✓ context window → ${fmtTokens(saved)}`) +
6257
+ console.log(GUTTER + accent(`✓ context window → ${fmtTokens(saved)}`) +
6255
6258
  dim(` · compacting above ~${fmtTokens(Math.floor(saved * 2 / 3))}`));
6256
6259
  console.log(GUTTER + dim(" takes effect on your next question.\n"));
6257
6260
  }
@@ -6329,7 +6332,7 @@ async function runS3Command(line) {
6329
6332
  const { ok, rows } = s3.describeS3Test(res);
6330
6333
  for (const row of rows) console.log(GUTTER + (ok ? dim(` ${row}`) : ` ${dim(row)}`));
6331
6334
  console.log(
6332
- GUTTER + (ok ? cyan("✓ S3 is reachable, readable and writable.\n") : red("✗ S3 is not usable yet.\n"))
6335
+ GUTTER + (ok ? accent("✓ S3 is reachable, readable and writable.\n") : red("✗ S3 is not usable yet.\n"))
6333
6336
  );
6334
6337
  return ok;
6335
6338
  };
@@ -6406,7 +6409,7 @@ async function runS3Command(line) {
6406
6409
  console.log(GUTTER + dim(" if this machine's local API is old, restart it with `gu api restart`.\n"));
6407
6410
  return;
6408
6411
  }
6409
- console.log(GUTTER + cyan("✓ saved.") + dim(" now checking it actually works…\n"));
6412
+ console.log(GUTTER + accent("✓ saved.") + dim(" now checking it actually works…\n"));
6410
6413
  // Tested with what was just SAVED (no body) rather than what was typed: that proves the
6411
6414
  // stored config works, which is the thing every later turn will use.
6412
6415
  await runTest({});
@@ -6452,7 +6455,7 @@ async function runProxyCommand(line) {
6452
6455
  // just said they are no longer using, and off an office network those fail slowly.
6453
6456
  resetDispatchers();
6454
6457
  console.log(
6455
- GUTTER + (action === "on" ? cyan("proxy on") : dim("proxy off")) +
6458
+ GUTTER + (action === "on" ? accent("proxy on") : dim("proxy off")) +
6456
6459
  dim(action === "on" ? " — model calls will be routed." : " — model calls go out directly (credentials kept).") + "\n"
6457
6460
  );
6458
6461
  return;
@@ -6469,7 +6472,7 @@ async function runProxyCommand(line) {
6469
6472
  const proxy = await store.loadProxy();
6470
6473
  console.log(GUTTER + dim("checking the proxy against your configured model…"));
6471
6474
  const res = await cmd.verifyProxy({ proxy, settings, fetchImpl: proxyFetch });
6472
- console.log(GUTTER + (res.ok ? cyan("✓ ") : yellow("✗ ")) + dim(res.detail) + "\n");
6475
+ console.log(GUTTER + (res.ok ? accent("✓ ") : yellow("✗ ")) + dim(res.detail) + "\n");
6473
6476
  return;
6474
6477
  }
6475
6478
 
@@ -6496,14 +6499,14 @@ async function runProxyCommand(line) {
6496
6499
  const proxy = await store.saveProxy({ enabled: true, host, port, username, password });
6497
6500
  resetDispatchers();
6498
6501
  const { describeProxy } = await import("./proxy-config.mjs");
6499
- console.log(GUTTER + cyan("saved") + dim(` — ${describeProxy(proxy)}`));
6502
+ console.log(GUTTER + accent("saved") + dim(` — ${describeProxy(proxy)}`));
6500
6503
 
6501
6504
  // VERIFY NOW, while the user still has the password in their head. A proxy that is merely
6502
6505
  // stored fails later, mid-turn, as an error the model cannot act on.
6503
6506
  console.log(GUTTER + dim("checking it against your configured model…"));
6504
6507
  const res = await cmd.verifyProxy({ proxy, settings, fetchImpl: proxyFetch });
6505
6508
  if (res.ok) {
6506
- console.log(GUTTER + cyan("✓ ") + dim(res.detail) + "\n");
6509
+ console.log(GUTTER + accent("✓ ") + dim(res.detail) + "\n");
6507
6510
  } else {
6508
6511
  console.log(GUTTER + yellow("✗ ") + dim(res.detail));
6509
6512
  // Left ENABLED on purpose: the user asked for it, and the failure may be the model host
@@ -6555,7 +6558,7 @@ async function chooseServer() {
6555
6558
  }
6556
6559
  const s = servers[idx];
6557
6560
  selectedServer = { name: s.name, host: s.host, user: s.user };
6558
- console.log(cyan(` ✓ deploy target → ${s.name} (${s.user}@${s.host})`));
6561
+ console.log(accent(` ✓ deploy target → ${s.name} (${s.user}@${s.host})`));
6559
6562
  await checkServerKeyAuth(selectedServer);
6560
6563
  console.log("");
6561
6564
  }
@@ -6709,7 +6712,7 @@ async function stopOtherAgent(jobId, shortName) {
6709
6712
  if (r?.ok) {
6710
6713
  // "asked", not "stopped": this marks the job cancelled, and the worker kills the agent
6711
6714
  // when it next sees that. Claiming the process is already gone would be a guess.
6712
- console.log(GUTTER + cyan("✓ asked it to stop") + dim(" — the worker ends it within a few seconds.\n"));
6715
+ console.log(GUTTER + accent("✓ asked it to stop") + dim(" — the worker ends it within a few seconds.\n"));
6713
6716
  // Drop it from the cached list NOW so the separator's count stops offering an agent the
6714
6717
  // user has just ended; the 20s poll would otherwise leave it there until it catches up.
6715
6718
  otherAgents = otherAgents.filter((a) => a.id !== jobId);
@@ -6776,7 +6779,9 @@ async function coderKeyState(override = "") {
6776
6779
  const active = override
6777
6780
  ? choices.find((c) => c.id === override)
6778
6781
  : choices.find((c) => c.isDefault);
6779
- const needsKey = active?.kind === "openai";
6782
+ // Kind AND url: an Ollama coder at https://ollama.com needs a key; a local one does not.
6783
+ const { codingNeedsKey } = await import("./coding-key-prompt.mjs");
6784
+ const needsKey = codingNeedsKey(active?.kind, active?.url);
6780
6785
  return {
6781
6786
  needsKey,
6782
6787
  model: p.codingKeyModel || active?.model || "the coding model",
@@ -6904,7 +6909,7 @@ async function promptNewCodingKey(backendId = "", provider = "") {
6904
6909
  const id = await addCodingKey(name, secret, backendId);
6905
6910
  console.log(
6906
6911
  id
6907
- ? cyan(` ✓ added ${name || "Unknown"}`) +
6912
+ ? accent(` ✓ added ${name || "Unknown"}`) +
6908
6913
  (backendId && provider ? dim(` · for ${provider}`) : "") + "\n"
6909
6914
  : red(" couldn't save the key — try again, or set it in web Settings → Agent\n")
6910
6915
  );
@@ -6974,7 +6979,7 @@ async function replaceCodingKey() {
6974
6979
  // would collect something with nowhere to go. The original prompt is exactly right here.
6975
6980
  console.log(
6976
6981
  "\n" + dim(` ${ks.model} API key `) +
6977
- (ks.stored ? dim("(replacing ") + cyan(maskKey(ks.stored)) + dim(")") : dim("(none stored)")) +
6982
+ (ks.stored ? dim("(replacing ") + accent(maskKey(ks.stored)) + dim(")") : dim("(none stored)")) +
6978
6983
  dim(" · Enter alone cancels, \"clear\" removes it\n")
6979
6984
  );
6980
6985
  console.log(GUTTER + dim(SECRET_KEY_HINT));
@@ -6989,8 +6994,8 @@ async function replaceCodingKey() {
6989
6994
  console.log(
6990
6995
  ok
6991
6996
  ? clearing
6992
- ? cyan(` ✓ ${ks.model} API key removed`) + dim(" — coding falls back to your local model\n")
6993
- : cyan(` ✓ ${ks.model} API key saved\n`)
6997
+ ? accent(` ✓ ${ks.model} API key removed`) + dim(" — coding falls back to your local model\n")
6998
+ : accent(` ✓ ${ks.model} API key saved\n`)
6994
6999
  : red(" couldn't save the key — try again, or set it in web Settings → Agent\n")
6995
7000
  );
6996
7001
  paintBar();
@@ -7007,15 +7012,15 @@ async function manageCodingKeys(ks, keys) {
7007
7012
  const current = sessionCodingKeyId;
7008
7013
  const rows = keys.map(
7009
7014
  (k) =>
7010
- `${k.name}${dim(` ····${k.tail}`)}${k.id === current ? cyan(" (this folder)") : ""}`
7015
+ `${k.name}${dim(` ····${k.tail}`)}${k.id === current ? accent(" (this folder)") : ""}`
7011
7016
  );
7012
7017
  const ADD = rows.length;
7013
7018
  rows.push(dim("add a new key…"));
7014
7019
  console.log(
7015
7020
  "\n" + GUTTER +
7016
7021
  (keys.length > 1
7017
- ? dim(`Which key should ${folderLabel()} use for `) + cyan(ks.model) + dim("?\n")
7018
- : dim(`${folderLabel()} uses this key for `) + cyan(ks.model) + dim(" — or add another.\n"))
7022
+ ? dim(`Which key should ${folderLabel()} use for `) + accent(ks.model) + dim("?\n")
7023
+ : dim(`${folderLabel()} uses this key for `) + accent(ks.model) + dim(" — or add another.\n"))
7019
7024
  );
7020
7025
  console.log(GUTTER + dim("Enter chooses · ctrl+d deletes the highlighted key\n"));
7021
7026
  const at = Math.max(0, keys.findIndex((k) => k.id === current));
@@ -7059,7 +7064,7 @@ async function manageCodingKeys(ks, keys) {
7059
7064
  keyChoicePending = false; // chosen — the bar's mark comes off
7060
7065
  codingKeyMissingKnown = false;
7061
7066
  console.log(
7062
- cyan(` ✓ ${folderLabel()} → ${keys[idx].name}`) + dim(` ····${keys[idx].tail}\n`)
7067
+ accent(` ✓ ${folderLabel()} → ${keys[idx].name}`) + dim(` ····${keys[idx].tail}\n`)
7063
7068
  );
7064
7069
  paintBar();
7065
7070
  }
@@ -7110,7 +7115,7 @@ async function deleteCodingKeyFlow(ks, keys, idx) {
7110
7115
  sessionCodingKeyId = "";
7111
7116
  await saveSessionCodingKeyId(resolve(process.cwd()));
7112
7117
  }
7113
- console.log(cyan(` ✓ deleted ${out.name || victim.name}`) +
7118
+ console.log(accent(` ✓ deleted ${out.name || victim.name}`) +
7114
7119
  dim(` ····${victim.tail} · ${out.remaining} key(s) left\n`));
7115
7120
  paintBar();
7116
7121
  }
@@ -7138,7 +7143,7 @@ async function promptForCodingKey(override = "") {
7138
7143
  askSecret,
7139
7144
  saveCodingKey,
7140
7145
  log: (s) => console.log(s),
7141
- colors: { dim, cyan, red },
7146
+ colors: { dim, cyan: accent, red },
7142
7147
  });
7143
7148
  }
7144
7149
 
@@ -7225,15 +7230,15 @@ function drawApprovalOptions(redraw) {
7225
7230
  // is 2 cols so Yes/No align; the \x1b[2A row-up count is unchanged (still 2 rows).
7226
7231
  // Both rows are a handful of columns wide, so they survive any resize on ONE physical row
7227
7232
  // each and the \x1b[2A count stays exact — which is why a resize can simply redraw here.
7228
- const yes = approvalSel === 0 ? cyan("▸ Yes") : dim(" Yes");
7229
- const no = approvalSel === 1 ? cyan("▸ No") : dim(" No");
7233
+ const yes = approvalSel === 0 ? accent("▸ Yes") : dim(" Yes");
7234
+ const no = approvalSel === 1 ? accent("▸ No") : dim(" No");
7230
7235
  regionUi((redraw ? "\x1b[2A" : "") + "\x1b[K" + GUTTER + yes + "\n\x1b[K" + GUTTER + no + "\n");
7231
7236
  }
7232
7237
  function finishApproval(allow) {
7233
7238
  if (!approvalActive) return;
7234
7239
  approvalActive = false;
7235
7240
  // Settle the picker: overwrite the two option lines with a single result line.
7236
- process.stdout.write("\x1b[2A\x1b[J" + GUTTER + (allow ? cyan("▸ Yes — running it") : red("▸ No — skipped")) + "\n");
7241
+ process.stdout.write("\x1b[2A\x1b[J" + GUTTER + (allow ? accent("▸ Yes — running it") : red("▸ No — skipped")) + "\n");
7237
7242
  const r = approvalResolve;
7238
7243
  approvalResolve = null;
7239
7244
  if (r) r(!!allow);
@@ -7398,7 +7403,7 @@ function drawList(redraw) {
7398
7403
  // multi-select feel like it forgot what you chose.
7399
7404
  const box = listMulti ? (listChecked.has(i) ? "[x] " : "[ ] ") : "";
7400
7405
  const body = box + listItems[i];
7401
- return clipVisible(GUTTER + (i === listSel ? cyan("▸ " + body) : dim(" " + body)), width - 1);
7406
+ return clipVisible(GUTTER + (i === listSel ? accent("▸ " + body) : dim(" " + body)), width - 1);
7402
7407
  };
7403
7408
  const marker = (text) => clipVisible(GUTTER + dim(text), width - 1);
7404
7409
  const rows = [];
@@ -8283,7 +8288,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
8283
8288
  // figure holds rather than jittering against flowing output.
8284
8289
  shownOutputTokens = easeCount(shownOutputTokens, latestOutputTokens);
8285
8290
  const outLabel =
8286
- shownOutputTokens > 0 ? dim(" · ↓ ") + cyan(fmtTokens(shownOutputTokens)) : "";
8291
+ shownOutputTokens > 0 ? dim(" · ↓ ") + accent(fmtTokens(shownOutputTokens)) : "";
8287
8292
  // Task #96: the thought line is "clickable" only when the fuller thought has MORE text than
8288
8293
  // fits on line 1 (a live command / bare "Thinking" / a thought that already fits has nothing
8289
8294
  // to reveal). Line 1 (what's happening now): a live command > the model's current Thought >
@@ -8388,7 +8393,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
8388
8393
  const clipRow = (r) => clipVisible(r, Math.max(20, process.stdout.columns || 80) - 1);
8389
8394
  const contentRows = [];
8390
8395
  for (let i = 0; i < LEAD_ROWS; i++) contentRows.push(""); // the separator — see LEAD_ROWS
8391
- contentRows.push(clipRow(`${markLine(cyan(BULLET))}${hovering ? hoverThought(label) : cyan(label)}`));
8396
+ contentRows.push(clipRow(`${markLine(accent(BULLET))}${hovering ? hoverThought(label) : accent(label)}`));
8392
8397
  for (const dr of detailRows) contentRows.push(clipRow(dim(dr)));
8393
8398
  for (const wl of expLines) contentRows.push(clipRow(GUTTER + dim(wl)));
8394
8399
  for (let i = 0; i < WORD_GAP; i++) contentRows.push(""); // see WORD_GAP
@@ -8623,7 +8628,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
8623
8628
  onContent();
8624
8629
  inEditCard = true;
8625
8630
  openBullet();
8626
- process.stdout.write(markLine(cyan("⏺")) + bold(line.slice(1).replace(/^\s+/, "")) + "\n");
8631
+ process.stdout.write(markLine(accent("⏺")) + bold(line.slice(1).replace(/^\s+/, "")) + "\n");
8627
8632
  lastWasBlank = false;
8628
8633
  lastContentAt = Date.now();
8629
8634
  continue;
@@ -8634,7 +8639,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
8634
8639
  const m = EDIT_DIFF_RE.exec(line);
8635
8640
  if (m) {
8636
8641
  // " 19 - old text" → dim line number, red/cyan signed content.
8637
- const paintDiff = m[3] === "+" ? cyan : red;
8642
+ const paintDiff = m[3] === "+" ? accent : red;
8638
8643
  process.stdout.write(`${GUTTER}${m[1]}${dim(m[2])} ${paintDiff(`${m[3]} ${m[4]}`)}\n`);
8639
8644
  } else {
8640
8645
  process.stdout.write(GUTTER + dim(line) + "\n"); // "└ summary" / "… (+N more)"
@@ -9349,7 +9354,7 @@ async function main() {
9349
9354
  servers: await (await import("./model-doctor.mjs")).discoverModelServers(),
9350
9355
  });
9351
9356
  const statusRows = [
9352
- bold(cyan("GU AGENT")) + dim(` v${REPL_VERSION}`),
9357
+ bold(accent("GU AGENT")) + dim(` v${REPL_VERSION}`),
9353
9358
  // Task #134: a client machine OVERRIDES the account's agent model locally (the
9354
9359
  // account value points at the model host's own server). Show what will actually be
9355
9360
  // used, not a value this machine can't reach.
@@ -9383,10 +9388,10 @@ async function main() {
9383
9388
  ...(() => {
9384
9389
  const m = modeRow({ clientExpert: isClientExpert(), clientMode: clientModeBanner });
9385
9390
  if (!m) return [];
9386
- const name = m.label === "client expert" ? cyan(m.label + " ") : m.label + " ";
9391
+ const name = m.label === "client expert" ? accent(m.label + " ") : m.label + " ";
9387
9392
  return [lbl("mode") + name + dim(m.detail)];
9388
9393
  })(),
9389
- sessionTestAuto ? cyan("auto-test on") : dim("auto-test off"),
9394
+ sessionTestAuto ? accent("auto-test on") : dim("auto-test off"),
9390
9395
  // Same reason as workspaceSyncLabel: on Client Expert there is no cloud to sync TO, so
9391
9396
  // the banner must not advertise one.
9392
9397
  lbl("folder") +
@@ -9534,7 +9539,7 @@ async function main() {
9534
9539
  askSecret,
9535
9540
  saveCodingKey,
9536
9541
  log: (s) => console.log(s),
9537
- colors: { dim, cyan, red },
9542
+ colors: { dim, cyan: accent, red },
9538
9543
  });
9539
9544
  // Task #190: saving the key is the moment the coder becomes real, and the payload that
9540
9545
  // seeded defaultCodingModel above was fetched BEFORE that — it carried no coder at all.
@@ -9558,7 +9563,7 @@ async function main() {
9558
9563
  // folder has not chosen, and more than one is stored.
9559
9564
  try {
9560
9565
  if (!keyRes?.saved) {
9561
- const { unchosenKeyWarning, effectiveCoderKind } = await import(
9566
+ const { unchosenKeyWarning, effectiveCoder, codingNeedsKey } = await import(
9562
9567
  "./coding-key-prompt.mjs"
9563
9568
  );
9564
9569
  // THE RULE LIVES IN THE MODULE so it can be tested by calling it. Open-coded here it
@@ -9571,15 +9576,17 @@ async function main() {
9571
9576
  // machine an Ollama coder, which needs no key — while the folder was on gpt-5. Reading
9572
9577
  // it directly made this check answer about a coder nobody was using, and the mark only
9573
9578
  // appeared after /model, which re-probes WITH the override.
9574
- const kind = effectiveCoderKind({
9579
+ const { kind, url } = effectiveCoder({
9575
9580
  sessionCodingModel,
9576
9581
  choices: probe?.codingChoices,
9577
9582
  defaultKind: p?.codingKind,
9583
+ defaultUrl: probe?.codingChoices?.find?.((c) => c?.isDefault)?.url ?? "",
9578
9584
  });
9579
9585
  const allKeys =
9580
- kind === "openai" && !sessionCodingKeyId ? await fetchCodingKeys() : null;
9586
+ codingNeedsKey(kind, url) && !sessionCodingKeyId ? await fetchCodingKeys() : null;
9581
9587
  const w = unchosenKeyWarning({
9582
9588
  codingKind: kind,
9589
+ codingUrl: url,
9583
9590
  selectedKeyId: sessionCodingKeyId,
9584
9591
  keys: allKeys,
9585
9592
  });
@@ -9892,7 +9899,7 @@ async function main() {
9892
9899
  // default; this opts in for the session. Ctrl+T expands the thought either way.
9893
9900
  hoverMouse = !hoverMouse;
9894
9901
  console.log(
9895
- (hoverMouse ? cyan("hover: ON") : dim("hover: OFF")) +
9902
+ (hoverMouse ? accent("hover: ON") : dim("hover: OFF")) +
9896
9903
  dim(
9897
9904
  hoverMouse
9898
9905
  ? " — mouse hover/click expands the thinking line; scrolling and drag-select are captured while a turn runs.\n"
@@ -9908,7 +9915,7 @@ async function main() {
9908
9915
  sessionShowThought = !sessionShowThought;
9909
9916
  await saveSessionShowThought(resolve(process.cwd()), sessionShowThought);
9910
9917
  console.log(
9911
- (sessionShowThought ? cyan("thought: ON") : dim("thought: OFF")) +
9918
+ (sessionShowThought ? accent("thought: ON") : dim("thought: OFF")) +
9912
9919
  dim(
9913
9920
  sessionShowThought
9914
9921
  ? " — the model's reasoning prints in grey as it works; click a block (or Ctrl+T) to expand it.\n"
@@ -9958,7 +9965,7 @@ async function main() {
9958
9965
  sessionTestAuto = !sessionTestAuto;
9959
9966
  await saveSession(cwd, history); // persist the choice for this folder
9960
9967
  console.log(
9961
- (sessionTestAuto ? cyan("auto-test: ON") : dim("auto-test: OFF")) +
9968
+ (sessionTestAuto ? accent("auto-test: ON") : dim("auto-test: OFF")) +
9962
9969
  dim(
9963
9970
  sessionTestAuto
9964
9971
  ? " — the agent will verify its work and fix until it passes, for this folder.\n"
@@ -9973,7 +9980,7 @@ async function main() {
9973
9980
  await saveSessionSkip(cwd, "skipModelSetupPrompt", false);
9974
9981
  await saveSessionSkip(cwd, "skipDaemonPrompt", false);
9975
9982
  console.log(
9976
- cyan("host mode") +
9983
+ accent("host mode") +
9977
9984
  dim(" — this machine will run turns again. Start the worker with `gu start`, ") +
9978
9985
  dim("and `gu doctor` to set up models here.\n")
9979
9986
  );
@@ -9996,7 +10003,7 @@ async function main() {
9996
10003
  (r) =>
9997
10004
  r.label +
9998
10005
  dim(` · ${r.note}`) +
9999
- (r.id === sessionRagStorage ? cyan(" (current)") : "")
10006
+ (r.id === sessionRagStorage ? accent(" (current)") : "")
10000
10007
  ),
10001
10008
  Math.max(0, RAG_STORAGE.findIndex((r) => r.id === sessionRagStorage))
10002
10009
  );
@@ -10017,7 +10024,7 @@ async function main() {
10017
10024
  // switching to cloud leaves the local shards on disk unread, and switching to local
10018
10025
  // leaves the S3 ones untouched — either way the new store is built by the next index.
10019
10026
  console.log(
10020
- cyan(` ✓ RAG storage → ${row.label}`) +
10027
+ accent(` ✓ RAG storage → ${row.label}`) +
10021
10028
  dim(`\n ${row.note}`) +
10022
10029
  dim("\n Existing shards are not moved — the new store fills as this folder is re-indexed.\n")
10023
10030
  );
@@ -10046,7 +10053,7 @@ async function main() {
10046
10053
  0
10047
10054
  );
10048
10055
  console.log(
10049
- GUTTER + cyan("prompt sent to the coding model") +
10056
+ GUTTER + accent("prompt sent to the coding model") +
10050
10057
  dim(` · ${fmtTokens(total)} tokens in the newest request`) +
10051
10058
  // The per-folder maximum (task #84), which used to sit on the bottom bar. It answers
10052
10059
  // "have I ever come close to this model's window?" — a question you ask once, not
@@ -10064,7 +10071,7 @@ async function main() {
10064
10071
  GUTTER + dim(r.label.padEnd(21)) +
10065
10072
  String(fmtTokens(r.tokens)).padStart(7) +
10066
10073
  dim(` ${String(r.percent).padStart(3)}% `) +
10067
- (r.tokens ? cyan(bar) : "") + (r.note ? dim(` ${r.note}`) : "")
10074
+ (r.tokens ? accent(bar) : "") + (r.note ? dim(` ${r.note}`) : "")
10068
10075
  );
10069
10076
  }
10070
10077
  console.log("");
@@ -10087,7 +10094,7 @@ async function main() {
10087
10094
  // The bar takes the FRESHER of the two totals, so the last turn's copy has to be
10088
10095
  // zeroed too or the reset wouldn't show up in it.
10089
10096
  lastTurnTotalOutput = 0;
10090
- console.log(GUTTER + cyan("✓ global + workspace output-token totals reset to 0.\n"));
10097
+ console.log(GUTTER + accent("✓ global + workspace output-token totals reset to 0.\n"));
10091
10098
  } else {
10092
10099
  console.log(GUTTER + dim("Could not reset the output-token totals (API error).\n"));
10093
10100
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.694",
3
+ "version": "1.0.696",
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",
@@ -42,6 +42,7 @@
42
42
  "test:bearer-e2e": "bash scripts/bearer-e2e.sh"
43
43
  },
44
44
  "files": [
45
+ "accent.mjs",
45
46
  "gu-cli.mjs",
46
47
  "gu-repl.mjs",
47
48
  "terminal-hover.mjs",
@@ -516,13 +516,14 @@ export function dragEventKind(btn, press) {
516
516
  return (b & 0b11) === 0 ? "press" : null;
517
517
  }
518
518
 
519
- // The selection's colours (task #178). THE LOGO'S CYAN, as a BACKGROUND — not as text.
519
+ // The selection's colours (task #178). THE LOGO'S COLOUR, as a BACKGROUND — not as text.
520
520
  //
521
- // 46 is the background of `\x1b[36m`, the exact colour the startup chevron is drawn in, so the
522
- // selection is the product's own colour rather than a fourth one invented for it. It is the
523
- // user's palette cyan, which means it still inherits their theme — unlike a 256-colour or
524
- // truecolor background, which would fight every custom scheme and every light terminal, the
525
- // same argument that kept syntax colour out of the edit cards.
521
+ // 105 is bright magenta, the sixteen-colour palette's purple, following the accent from cyan to
522
+ // indigo when the brand moved (accent.mjs). Still a PALETTE colour, not the exact #a5b4fc: a
523
+ // background inherits the user's theme this way, where a 256-colour or truecolor background would
524
+ // fight every custom scheme and every light terminal — the same argument that kept syntax colour
525
+ // out of the edit cards. The bright variant rather than 45, because black text on a dark magenta
526
+ // is where this stops being legible.
526
527
  //
527
528
  // A BACKGROUND, because the cyan FOREGROUND is already spoken for: the ">> " prompt, where it
528
529
  // means "this is the line you are typing", and the jump-to-bottom hint. A foreground also
@@ -530,12 +531,12 @@ export function dragEventKind(btn, press) {
530
531
  // reserves for "you must not miss this" would be erased by looking at it. A background leaves
531
532
  // every foreground meaning intact and reads as a REGION, which is what a selection is.
532
533
  //
533
- // The explicit 30 (black) is not decoration: the prompt and the jump hint are CYAN TEXT, and
534
- // cyan on cyan is invisible. Flattening the foreground inside the selection costs colour
534
+ // The explicit 30 (black) is not decoration: the prompt and the jump hint are ACCENT TEXT, and
535
+ // accent on accent is invisible. Flattening the foreground inside the selection costs colour
535
536
  // meaning for as long as the selection lasts, and is the price of legibility. Reverse video
536
537
  // (\x1b[7m) avoids it by letting the terminal swap whatever is there; a named colour was chosen
537
538
  // deliberately over it, so the selection looks like gu rather than like the terminal.
538
- export const SELECTION_SGR = "\x1b[46;30m";
539
+ export const SELECTION_SGR = "\x1b[105;30m";
539
540
 
540
541
  /**
541
542
  * One row with the visible columns [from, to) highlighted.