@tiens.nguyen/gu-cli 1.0.694 → 1.0.695

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
 
@@ -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,6 +12,8 @@
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";
@@ -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,23 @@ function _isLocalCodingUrl(baseURL) {
3263
3265
  return !host.includes(".") && !host.includes(":");
3264
3266
  }
3265
3267
 
3268
+ /** TRUE only for the Ollama CLOUD endpoint (https://ollama.com[/...]) — the one Ollama
3269
+ * backend that actually requires an API key. Any other Ollama URL (localhost, a LAN
3270
+ * box, a self-hosted remote) has no auth and gets no key. Mirrors isOllamaCloudUrl in
3271
+ * web/src/lib/coderChoices.mjs — keep the two in step. */
3272
+ function _isOllamaCloudUrl(baseURL) {
3273
+ const v = String(baseURL || "").trim();
3274
+ if (!/^https:\/\//i.test(v)) return false;
3275
+ let host = "";
3276
+ try {
3277
+ host = new URL(v).hostname.toLowerCase().replace(/\.$/, "");
3278
+ } catch {
3279
+ return false;
3280
+ }
3281
+ return host === "ollama.com" || host.endsWith(".ollama.com");
3282
+ }
3283
+
3284
+
3266
3285
  /** Resolve to "ollama" | "openai" | "local". `kind` is the user's Settings choice
3267
3286
  * ("ollama" | "openai" | "" = Auto). Only Auto costs a probe. */
3268
3287
  async function resolveCodingBackend(kind, baseURL) {
@@ -3445,7 +3464,9 @@ async function runAgentChatJob(job) {
3445
3464
  // anything else that endpoint doesn't exist, and on a cloud one we'd be pinging a paid
3446
3465
  // API every 4 minutes (which an Auto+cloud config did until #117 was fixed — the old
3447
3466
  // gate only skipped an EXPLICIT openai kind).
3448
- if (codingBackend === "ollama") {
3467
+ // ...and NOT for the Ollama CLOUD (https://ollama.com): that is a paid API, so the
3468
+ // keep-warm ping must not run against it — same rule as the key fetch below.
3469
+ if (codingBackend === "ollama" && !_isOllamaCloudUrl(payload?.codingBaseURL)) {
3449
3470
  trackCodingModelForWarmup(payload?.codingBaseURL ?? "", payload?.codingModelId ?? "");
3450
3471
  }
3451
3472
 
@@ -3598,7 +3619,9 @@ async function runAgentChatJob(job) {
3598
3619
  // "openai" kind, and python's `coding_api_key or agent_api_key` fallback then
3599
3620
  // authenticated a cloud provider with the LOCAL agent key → 401.
3600
3621
  let codingApiKey = "";
3601
- if (codingBackend === "openai") {
3622
+ // Ollama has no auth — EXCEPT the Ollama cloud (https://ollama.com), which requires a
3623
+ // key like any other paid endpoint. A local/LAN Ollama gets no key (and ignores one).
3624
+ if (codingBackend === "openai" || (codingBackend === "ollama" && _isOllamaCloudUrl(payload?.codingBaseURL))) {
3602
3625
  try {
3603
3626
  // NAME THE JOB (task #222 phase 2). Two turns run concurrently by default
3604
3627
  // (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);
@@ -6904,7 +6907,7 @@ async function promptNewCodingKey(backendId = "", provider = "") {
6904
6907
  const id = await addCodingKey(name, secret, backendId);
6905
6908
  console.log(
6906
6909
  id
6907
- ? cyan(` ✓ added ${name || "Unknown"}`) +
6910
+ ? accent(` ✓ added ${name || "Unknown"}`) +
6908
6911
  (backendId && provider ? dim(` · for ${provider}`) : "") + "\n"
6909
6912
  : red(" couldn't save the key — try again, or set it in web Settings → Agent\n")
6910
6913
  );
@@ -6974,7 +6977,7 @@ async function replaceCodingKey() {
6974
6977
  // would collect something with nowhere to go. The original prompt is exactly right here.
6975
6978
  console.log(
6976
6979
  "\n" + dim(` ${ks.model} API key `) +
6977
- (ks.stored ? dim("(replacing ") + cyan(maskKey(ks.stored)) + dim(")") : dim("(none stored)")) +
6980
+ (ks.stored ? dim("(replacing ") + accent(maskKey(ks.stored)) + dim(")") : dim("(none stored)")) +
6978
6981
  dim(" · Enter alone cancels, \"clear\" removes it\n")
6979
6982
  );
6980
6983
  console.log(GUTTER + dim(SECRET_KEY_HINT));
@@ -6989,8 +6992,8 @@ async function replaceCodingKey() {
6989
6992
  console.log(
6990
6993
  ok
6991
6994
  ? 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`)
6995
+ ? accent(` ✓ ${ks.model} API key removed`) + dim(" — coding falls back to your local model\n")
6996
+ : accent(` ✓ ${ks.model} API key saved\n`)
6994
6997
  : red(" couldn't save the key — try again, or set it in web Settings → Agent\n")
6995
6998
  );
6996
6999
  paintBar();
@@ -7007,15 +7010,15 @@ async function manageCodingKeys(ks, keys) {
7007
7010
  const current = sessionCodingKeyId;
7008
7011
  const rows = keys.map(
7009
7012
  (k) =>
7010
- `${k.name}${dim(` ····${k.tail}`)}${k.id === current ? cyan(" (this folder)") : ""}`
7013
+ `${k.name}${dim(` ····${k.tail}`)}${k.id === current ? accent(" (this folder)") : ""}`
7011
7014
  );
7012
7015
  const ADD = rows.length;
7013
7016
  rows.push(dim("add a new key…"));
7014
7017
  console.log(
7015
7018
  "\n" + GUTTER +
7016
7019
  (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"))
7020
+ ? dim(`Which key should ${folderLabel()} use for `) + accent(ks.model) + dim("?\n")
7021
+ : dim(`${folderLabel()} uses this key for `) + accent(ks.model) + dim(" — or add another.\n"))
7019
7022
  );
7020
7023
  console.log(GUTTER + dim("Enter chooses · ctrl+d deletes the highlighted key\n"));
7021
7024
  const at = Math.max(0, keys.findIndex((k) => k.id === current));
@@ -7059,7 +7062,7 @@ async function manageCodingKeys(ks, keys) {
7059
7062
  keyChoicePending = false; // chosen — the bar's mark comes off
7060
7063
  codingKeyMissingKnown = false;
7061
7064
  console.log(
7062
- cyan(` ✓ ${folderLabel()} → ${keys[idx].name}`) + dim(` ····${keys[idx].tail}\n`)
7065
+ accent(` ✓ ${folderLabel()} → ${keys[idx].name}`) + dim(` ····${keys[idx].tail}\n`)
7063
7066
  );
7064
7067
  paintBar();
7065
7068
  }
@@ -7110,7 +7113,7 @@ async function deleteCodingKeyFlow(ks, keys, idx) {
7110
7113
  sessionCodingKeyId = "";
7111
7114
  await saveSessionCodingKeyId(resolve(process.cwd()));
7112
7115
  }
7113
- console.log(cyan(` ✓ deleted ${out.name || victim.name}`) +
7116
+ console.log(accent(` ✓ deleted ${out.name || victim.name}`) +
7114
7117
  dim(` ····${victim.tail} · ${out.remaining} key(s) left\n`));
7115
7118
  paintBar();
7116
7119
  }
@@ -7138,7 +7141,7 @@ async function promptForCodingKey(override = "") {
7138
7141
  askSecret,
7139
7142
  saveCodingKey,
7140
7143
  log: (s) => console.log(s),
7141
- colors: { dim, cyan, red },
7144
+ colors: { dim, cyan: accent, red },
7142
7145
  });
7143
7146
  }
7144
7147
 
@@ -7225,15 +7228,15 @@ function drawApprovalOptions(redraw) {
7225
7228
  // is 2 cols so Yes/No align; the \x1b[2A row-up count is unchanged (still 2 rows).
7226
7229
  // Both rows are a handful of columns wide, so they survive any resize on ONE physical row
7227
7230
  // 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");
7231
+ const yes = approvalSel === 0 ? accent("▸ Yes") : dim(" Yes");
7232
+ const no = approvalSel === 1 ? accent("▸ No") : dim(" No");
7230
7233
  regionUi((redraw ? "\x1b[2A" : "") + "\x1b[K" + GUTTER + yes + "\n\x1b[K" + GUTTER + no + "\n");
7231
7234
  }
7232
7235
  function finishApproval(allow) {
7233
7236
  if (!approvalActive) return;
7234
7237
  approvalActive = false;
7235
7238
  // 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");
7239
+ process.stdout.write("\x1b[2A\x1b[J" + GUTTER + (allow ? accent("▸ Yes — running it") : red("▸ No — skipped")) + "\n");
7237
7240
  const r = approvalResolve;
7238
7241
  approvalResolve = null;
7239
7242
  if (r) r(!!allow);
@@ -7398,7 +7401,7 @@ function drawList(redraw) {
7398
7401
  // multi-select feel like it forgot what you chose.
7399
7402
  const box = listMulti ? (listChecked.has(i) ? "[x] " : "[ ] ") : "";
7400
7403
  const body = box + listItems[i];
7401
- return clipVisible(GUTTER + (i === listSel ? cyan("▸ " + body) : dim(" " + body)), width - 1);
7404
+ return clipVisible(GUTTER + (i === listSel ? accent("▸ " + body) : dim(" " + body)), width - 1);
7402
7405
  };
7403
7406
  const marker = (text) => clipVisible(GUTTER + dim(text), width - 1);
7404
7407
  const rows = [];
@@ -8283,7 +8286,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
8283
8286
  // figure holds rather than jittering against flowing output.
8284
8287
  shownOutputTokens = easeCount(shownOutputTokens, latestOutputTokens);
8285
8288
  const outLabel =
8286
- shownOutputTokens > 0 ? dim(" · ↓ ") + cyan(fmtTokens(shownOutputTokens)) : "";
8289
+ shownOutputTokens > 0 ? dim(" · ↓ ") + accent(fmtTokens(shownOutputTokens)) : "";
8287
8290
  // Task #96: the thought line is "clickable" only when the fuller thought has MORE text than
8288
8291
  // fits on line 1 (a live command / bare "Thinking" / a thought that already fits has nothing
8289
8292
  // to reveal). Line 1 (what's happening now): a live command > the model's current Thought >
@@ -8388,7 +8391,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
8388
8391
  const clipRow = (r) => clipVisible(r, Math.max(20, process.stdout.columns || 80) - 1);
8389
8392
  const contentRows = [];
8390
8393
  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)}`));
8394
+ contentRows.push(clipRow(`${markLine(accent(BULLET))}${hovering ? hoverThought(label) : accent(label)}`));
8392
8395
  for (const dr of detailRows) contentRows.push(clipRow(dim(dr)));
8393
8396
  for (const wl of expLines) contentRows.push(clipRow(GUTTER + dim(wl)));
8394
8397
  for (let i = 0; i < WORD_GAP; i++) contentRows.push(""); // see WORD_GAP
@@ -8623,7 +8626,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
8623
8626
  onContent();
8624
8627
  inEditCard = true;
8625
8628
  openBullet();
8626
- process.stdout.write(markLine(cyan("⏺")) + bold(line.slice(1).replace(/^\s+/, "")) + "\n");
8629
+ process.stdout.write(markLine(accent("⏺")) + bold(line.slice(1).replace(/^\s+/, "")) + "\n");
8627
8630
  lastWasBlank = false;
8628
8631
  lastContentAt = Date.now();
8629
8632
  continue;
@@ -8634,7 +8637,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
8634
8637
  const m = EDIT_DIFF_RE.exec(line);
8635
8638
  if (m) {
8636
8639
  // " 19 - old text" → dim line number, red/cyan signed content.
8637
- const paintDiff = m[3] === "+" ? cyan : red;
8640
+ const paintDiff = m[3] === "+" ? accent : red;
8638
8641
  process.stdout.write(`${GUTTER}${m[1]}${dim(m[2])} ${paintDiff(`${m[3]} ${m[4]}`)}\n`);
8639
8642
  } else {
8640
8643
  process.stdout.write(GUTTER + dim(line) + "\n"); // "└ summary" / "… (+N more)"
@@ -9349,7 +9352,7 @@ async function main() {
9349
9352
  servers: await (await import("./model-doctor.mjs")).discoverModelServers(),
9350
9353
  });
9351
9354
  const statusRows = [
9352
- bold(cyan("GU AGENT")) + dim(` v${REPL_VERSION}`),
9355
+ bold(accent("GU AGENT")) + dim(` v${REPL_VERSION}`),
9353
9356
  // Task #134: a client machine OVERRIDES the account's agent model locally (the
9354
9357
  // account value points at the model host's own server). Show what will actually be
9355
9358
  // used, not a value this machine can't reach.
@@ -9383,10 +9386,10 @@ async function main() {
9383
9386
  ...(() => {
9384
9387
  const m = modeRow({ clientExpert: isClientExpert(), clientMode: clientModeBanner });
9385
9388
  if (!m) return [];
9386
- const name = m.label === "client expert" ? cyan(m.label + " ") : m.label + " ";
9389
+ const name = m.label === "client expert" ? accent(m.label + " ") : m.label + " ";
9387
9390
  return [lbl("mode") + name + dim(m.detail)];
9388
9391
  })(),
9389
- sessionTestAuto ? cyan("auto-test on") : dim("auto-test off"),
9392
+ sessionTestAuto ? accent("auto-test on") : dim("auto-test off"),
9390
9393
  // Same reason as workspaceSyncLabel: on Client Expert there is no cloud to sync TO, so
9391
9394
  // the banner must not advertise one.
9392
9395
  lbl("folder") +
@@ -9534,7 +9537,7 @@ async function main() {
9534
9537
  askSecret,
9535
9538
  saveCodingKey,
9536
9539
  log: (s) => console.log(s),
9537
- colors: { dim, cyan, red },
9540
+ colors: { dim, cyan: accent, red },
9538
9541
  });
9539
9542
  // Task #190: saving the key is the moment the coder becomes real, and the payload that
9540
9543
  // seeded defaultCodingModel above was fetched BEFORE that — it carried no coder at all.
@@ -9892,7 +9895,7 @@ async function main() {
9892
9895
  // default; this opts in for the session. Ctrl+T expands the thought either way.
9893
9896
  hoverMouse = !hoverMouse;
9894
9897
  console.log(
9895
- (hoverMouse ? cyan("hover: ON") : dim("hover: OFF")) +
9898
+ (hoverMouse ? accent("hover: ON") : dim("hover: OFF")) +
9896
9899
  dim(
9897
9900
  hoverMouse
9898
9901
  ? " — mouse hover/click expands the thinking line; scrolling and drag-select are captured while a turn runs.\n"
@@ -9908,7 +9911,7 @@ async function main() {
9908
9911
  sessionShowThought = !sessionShowThought;
9909
9912
  await saveSessionShowThought(resolve(process.cwd()), sessionShowThought);
9910
9913
  console.log(
9911
- (sessionShowThought ? cyan("thought: ON") : dim("thought: OFF")) +
9914
+ (sessionShowThought ? accent("thought: ON") : dim("thought: OFF")) +
9912
9915
  dim(
9913
9916
  sessionShowThought
9914
9917
  ? " — the model's reasoning prints in grey as it works; click a block (or Ctrl+T) to expand it.\n"
@@ -9958,7 +9961,7 @@ async function main() {
9958
9961
  sessionTestAuto = !sessionTestAuto;
9959
9962
  await saveSession(cwd, history); // persist the choice for this folder
9960
9963
  console.log(
9961
- (sessionTestAuto ? cyan("auto-test: ON") : dim("auto-test: OFF")) +
9964
+ (sessionTestAuto ? accent("auto-test: ON") : dim("auto-test: OFF")) +
9962
9965
  dim(
9963
9966
  sessionTestAuto
9964
9967
  ? " — the agent will verify its work and fix until it passes, for this folder.\n"
@@ -9973,7 +9976,7 @@ async function main() {
9973
9976
  await saveSessionSkip(cwd, "skipModelSetupPrompt", false);
9974
9977
  await saveSessionSkip(cwd, "skipDaemonPrompt", false);
9975
9978
  console.log(
9976
- cyan("host mode") +
9979
+ accent("host mode") +
9977
9980
  dim(" — this machine will run turns again. Start the worker with `gu start`, ") +
9978
9981
  dim("and `gu doctor` to set up models here.\n")
9979
9982
  );
@@ -9996,7 +9999,7 @@ async function main() {
9996
9999
  (r) =>
9997
10000
  r.label +
9998
10001
  dim(` · ${r.note}`) +
9999
- (r.id === sessionRagStorage ? cyan(" (current)") : "")
10002
+ (r.id === sessionRagStorage ? accent(" (current)") : "")
10000
10003
  ),
10001
10004
  Math.max(0, RAG_STORAGE.findIndex((r) => r.id === sessionRagStorage))
10002
10005
  );
@@ -10017,7 +10020,7 @@ async function main() {
10017
10020
  // switching to cloud leaves the local shards on disk unread, and switching to local
10018
10021
  // leaves the S3 ones untouched — either way the new store is built by the next index.
10019
10022
  console.log(
10020
- cyan(` ✓ RAG storage → ${row.label}`) +
10023
+ accent(` ✓ RAG storage → ${row.label}`) +
10021
10024
  dim(`\n ${row.note}`) +
10022
10025
  dim("\n Existing shards are not moved — the new store fills as this folder is re-indexed.\n")
10023
10026
  );
@@ -10046,7 +10049,7 @@ async function main() {
10046
10049
  0
10047
10050
  );
10048
10051
  console.log(
10049
- GUTTER + cyan("prompt sent to the coding model") +
10052
+ GUTTER + accent("prompt sent to the coding model") +
10050
10053
  dim(` · ${fmtTokens(total)} tokens in the newest request`) +
10051
10054
  // The per-folder maximum (task #84), which used to sit on the bottom bar. It answers
10052
10055
  // "have I ever come close to this model's window?" — a question you ask once, not
@@ -10064,7 +10067,7 @@ async function main() {
10064
10067
  GUTTER + dim(r.label.padEnd(21)) +
10065
10068
  String(fmtTokens(r.tokens)).padStart(7) +
10066
10069
  dim(` ${String(r.percent).padStart(3)}% `) +
10067
- (r.tokens ? cyan(bar) : "") + (r.note ? dim(` ${r.note}`) : "")
10070
+ (r.tokens ? accent(bar) : "") + (r.note ? dim(` ${r.note}`) : "")
10068
10071
  );
10069
10072
  }
10070
10073
  console.log("");
@@ -10087,7 +10090,7 @@ async function main() {
10087
10090
  // The bar takes the FRESHER of the two totals, so the last turn's copy has to be
10088
10091
  // zeroed too or the reset wouldn't show up in it.
10089
10092
  lastTurnTotalOutput = 0;
10090
- console.log(GUTTER + cyan("✓ global + workspace output-token totals reset to 0.\n"));
10093
+ console.log(GUTTER + accent("✓ global + workspace output-token totals reset to 0.\n"));
10091
10094
  } else {
10092
10095
  console.log(GUTTER + dim("Could not reset the output-token totals (API error).\n"));
10093
10096
  }
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.695",
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.