@tiens.nguyen/gu-cli 1.0.894 → 1.0.895

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.
@@ -14,6 +14,9 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
14
14
  import { homedir } from "node:os";
15
15
  import { dirname, join } from "node:path";
16
16
 
17
+ // Warnings are yellow text, no icon (GH #378: "⚠" draws as a broken emoji on Windows 11).
18
+ const yellow = (s) => (process.stdout.isTTY && !process.env.NO_COLOR ? `\x1b[33m${s}\x1b[0m` : String(s));
19
+
17
20
  export const DECLINED_FILE = join(homedir(), ".gonext", "agent-deps-declined");
18
21
 
19
22
  /** Is this start one that should check? Interactive terminals only, and not when a test or script opts out. Pure. */
@@ -74,13 +77,13 @@ export async function agentDepsPreflight({
74
77
  const out = await install();
75
78
  const ok = out && (out.agentLibs === "installed" || out.agentLibs === "present");
76
79
  if (!ok) {
77
- log(" ⚠ the agent framework did not install — run `gu-cli deps` to see why. Starting gu anyway.");
80
+ log(yellow(" the agent framework did not install — run `gu-cli deps` to see why. Starting gu anyway."));
78
81
  return "failed";
79
82
  }
80
83
  log(" ✓ the agent framework is installed.\n");
81
84
  return "installed";
82
85
  } catch (e) {
83
- log(` ⚠ ${e instanceof Error ? e.message : e}\n Starting gu anyway — run \`gu-cli deps\` to retry.`);
86
+ log(yellow(` ${e instanceof Error ? e.message : e}`) + `\n Starting gu anyway — run \`gu-cli deps\` to retry.`);
84
87
  return "failed";
85
88
  }
86
89
  }
@@ -140,36 +140,46 @@ export function allowIn(folder, allow = true) {
140
140
  writeFileSync(allowPath(), JSON.stringify({ folders }, null, 2));
141
141
  }
142
142
 
143
+ /**
144
+ * WHICH LINES ARE WARNINGS, without an icon to say so (GH #378 — "⚠" draws broken on Windows 11).
145
+ * The report stays a plain string array; `.warnings` is the set of its lines the caller paints
146
+ * yellow.
147
+ */
148
+ function withWarnings(lines, firstN = 0, extra = []) {
149
+ lines.warnings = new Set([...lines.slice(0, firstN), ...extra]);
150
+ return lines;
151
+ }
152
+
143
153
  export function browserReport({ connected, version, installType, refused = [], running = true, error = "" }, { allowed = false, storeUrl = STORE_URL, storeInstalled = false } = {}) {
144
154
  // The bridge itself would not start (#170): that is gu's fault, and says so — not "nothing to install".
145
155
  if (!running && error) {
146
- return [
147
- `⚠ gu's browser bridge did not start: ${error}`,
148
- "⚠ This is a problem in gu, not in Chrome — no browser needs installing.",
156
+ return withWarnings([
157
+ `gu's browser bridge did not start: ${error}`,
158
+ "This is a problem in gu, not in Chrome — no browser needs installing.",
149
159
  "Update gu: npm install -g @tiens.nguyen/gu-cli@latest — then restart gu.",
150
160
  "The bridge's log: ~/.gonext/browser-bridge.log",
151
- ];
161
+ ], 2);
152
162
  }
153
163
  // Two copies installed (the store one next to a developer one): the bridge kept one (#148).
154
164
  const twoCopies = refused.length
155
- ? [`⚠ A second gu extension (${refused.map((r) => r.id).join(", ")}) tried to connect and was`,
156
- "⚠ turned away — two copies are installed. Keep one: /browser cleanup removes",
157
- "⚠ the developer (unpacked) copy."]
165
+ ? [`A second gu extension (${refused.map((r) => r.id).join(", ")}) tried to connect and was`,
166
+ "turned away — two copies are installed. Keep one: /browser cleanup removes",
167
+ "the developer (unpacked) copy."]
158
168
  : [];
159
169
  const here = allowed
160
170
  ? "In this folder it may, without asking (/browser forget to undo)."
161
171
  : "In this folder it asks first (/browser allow to stop asking).";
162
172
  if (connected) {
163
173
  const kind = installType === "development" ? ", developer — loaded unpacked" : installType === "normal" ? ", Chrome Web Store" : "";
164
- return [
174
+ return withWarnings([
165
175
  `✓ The gu Chrome extension is connected (v${version}${kind}).`,
166
176
  "The agent uses your Chrome, in its own tabs (the \"gu\" tab group) and tabs",
167
177
  "you share from the extension's icon → Let gu use this tab.",
168
178
  here,
169
179
  ...twoCopies,
170
- ];
180
+ ], 0, twoCopies);
171
181
  }
172
- return [
182
+ return withWarnings([
173
183
  "Nothing to install. When the agent needs a browser it asks you first, then",
174
184
  "opens gu's own Chrome window (a separate profile — not signed in to your",
175
185
  "accounts) and works in it with a real mouse and keyboard.",
@@ -184,7 +194,7 @@ export function browserReport({ connected, version, installType, refused = [], r
184
194
  : ["To let it use YOUR Chrome (your logins, a tab you share): /browser install"]),
185
195
  here,
186
196
  ...twoCopies,
187
- ];
197
+ ], 0, twoCopies);
188
198
  }
189
199
 
190
200
  // /browser install — the extension, for the user's own Chrome. The store page when there is one;
@@ -839,7 +839,7 @@ export async function askMongoCredentials(ask, askSecret = null) {
839
839
  return { uri, custom: true };
840
840
  }
841
841
  if (verdict.unverifiable) {
842
- console.log("\n " + yellow("⚠ could not verify: ") + dim(verdict.why));
842
+ console.log("\n " + yellow("could not verify: ") + dim(verdict.why));
843
843
  if (await readYesNo(ask, " Continue with these credentials anyway? [Y/n]: ", true)) {
844
844
  return { uri, custom: true, unverified: true };
845
845
  }
@@ -1425,7 +1425,7 @@ export async function runClientExpertSetup({
1425
1425
  }
1426
1426
  } catch (e) {
1427
1427
  // Non-fatal, but say so: a stale daemon is the difference between "set up" and "works".
1428
- console.log("\n " + yellow("⚠ could not restart the background worker: ") + e.message);
1428
+ console.log("\n " + yellow("could not restart the background worker: ") + e.message);
1429
1429
  console.log(dim(" Run `gu-cli restart` — until you do, turns will be enqueued but"));
1430
1430
  console.log(dim(" never picked up, because the running worker is still polling the old API.\n"));
1431
1431
  }
@@ -1570,7 +1570,7 @@ export async function runClientExpertSetup({
1570
1570
  for (const r of await saveSetupKeys({ workerKey: creds.workerKey, models: chosenModels })) {
1571
1571
  console.log(r.ok
1572
1572
  ? " " + cyan("✓ API key stored") + dim(` for ${r.host} — the server's default key`)
1573
- : " " + yellow("⚠ could not store the key") + dim(` for ${r.host} — add it in /model with ctrl+k`));
1573
+ : " " + yellow("could not store the key") + dim(` for ${r.host} — add it in /model with ctrl+k`));
1574
1574
  }
1575
1575
  }
1576
1576
  if (skipModels) {
@@ -1614,7 +1614,7 @@ export async function runClientExpertSetup({
1614
1614
  } catch (e) {
1615
1615
  // NOT fatal: everything else works, and the model can be set from the terminal. But say so
1616
1616
  // clearly, because the symptom otherwise is the terminal refusing to start.
1617
- console.log("\n " + yellow("⚠ could not seed the model settings: ") + e.message);
1617
+ console.log("\n " + yellow("could not seed the model settings: ") + e.message);
1618
1618
  console.log(dim(" The terminal may say no agent model is configured. Set one with `/model`,"));
1619
1619
  console.log(dim(" or run `gu doctor` to set up a local one.\n"));
1620
1620
  state = recordStep(state, "models", "failed", { reason: e.message });
@@ -220,8 +220,8 @@ export function embedSaveFields({ url, model }) {
220
220
  /** The bar's row for a state that is not "on" — "" when RAG is on. One short row (#317). */
221
221
  export function embedNotice({ state, model } = {}) {
222
222
  if (state === "on") return "";
223
- if (state === "off" && model) return `⚠ RAG is switched off — ${model} is set · /embed-model turns it on`;
224
- return "⚠ no embed model — RAG is off · /embed-model";
223
+ if (state === "off" && model) return `RAG is switched off — ${model} is set · /embed-model turns it on`;
224
+ return "no embed model — RAG is off · /embed-model";
225
225
  }
226
226
 
227
227
  /**
@@ -233,9 +233,9 @@ export function embedNotice({ state, model } = {}) {
233
233
  * a RAG tool, or the user opened /embed-model), then the embed row.
234
234
  */
235
235
  export function setupNoticeRows({ noModel = false, agentMissing = false, embedMissing = false, embedNeeded = false, embedWhy } = {}) {
236
- if (noModel) return ["⚠ coding model not set · /model"];
236
+ if (noModel) return ["coding model not set · /model"];
237
237
  return [
238
- ...(agentMissing ? ["⚠ agent model not set — using the coding model · /agent-model"] : []),
239
- ...(embedMissing && embedNeeded ? [embedNotice(embedWhy) || "⚠ no embed model — RAG is off · /embed-model"] : []),
238
+ ...(agentMissing ? ["agent model not set — using the coding model · /agent-model"] : []),
239
+ ...(embedMissing && embedNeeded ? [embedNotice(embedWhy) || "no embed model — RAG is off · /embed-model"] : []),
240
240
  ];
241
241
  }
@@ -6519,6 +6519,17 @@ def _open_project_text(path: str):
6519
6519
  return open(path, "r", encoding=_sniff_text_encoding(path), errors="replace")
6520
6520
 
6521
6521
 
6522
+ def _rag_wanted_name(fn: str) -> bool:
6523
+ """Is a file with this NAME one the project tools read (text type, not a lockfile/minified)? Pure.
6524
+ One rule for the RAG walk and both grep engines, so ripgrep and Python see the same files (#380)."""
6525
+ import os
6526
+ ext = os.path.splitext(fn)[1].lower()
6527
+ base = fn.lower()
6528
+ if ext not in _RAG_TEXT_EXTS and base not in ("dockerfile", "makefile", ".gitignore"):
6529
+ return False
6530
+ return not (base in _RAG_SKIP_FILES or base.endswith(_RAG_SKIP_SUFFIXES))
6531
+
6532
+
6522
6533
  def _rag_iter_text_files(root: str):
6523
6534
  """Yield (abs_path, rel_path, ext) for indexable text files under root."""
6524
6535
  import os
@@ -6529,10 +6540,7 @@ def _rag_iter_text_files(root: str):
6529
6540
  dirnames[:] = [d for d in dirnames if d not in _RAG_SKIP_DIRS]
6530
6541
  for fn in filenames:
6531
6542
  ext = os.path.splitext(fn)[1].lower()
6532
- base = fn.lower()
6533
- if ext not in _RAG_TEXT_EXTS and base not in ("dockerfile", "makefile", ".gitignore"):
6534
- continue
6535
- if base in _RAG_SKIP_FILES or base.endswith(_RAG_SKIP_SUFFIXES):
6543
+ if not _rag_wanted_name(fn):
6536
6544
  continue
6537
6545
  abs_path = os.path.join(dirpath, fn)
6538
6546
  try:
@@ -6547,6 +6555,157 @@ def _rag_iter_text_files(root: str):
6547
6555
  yield abs_path, os.path.relpath(abs_path, root).replace(os.sep, "/"), ext
6548
6556
 
6549
6557
 
6558
+ # ---- grep: ripgrep when there is one, Python when not (#380) ---------------------------------
6559
+ #
6560
+ # Claude Code searches with ripgrep on every OS, Windows 11 included (#379). The Python walk below
6561
+ # needs nothing installed but decodes every line of every file in Python — slow on a big repo.
6562
+ # So grep_repo asks ripgrep first and keeps Python as the fallback. Both must return THE SAME hits:
6563
+ # the same files (skip dirs, text types, lockfiles, 2 MB cap — ripgrep's .gitignore handling is
6564
+ # OFF, because the Python walk never read .gitignore), the same line format, the same cap, and the
6565
+ # same "an invalid regex is searched as plain text". A regex ripgrep cannot run (Python-only
6566
+ # syntax such as look-around or back-references) falls back to Python instead of failing.
6567
+
6568
+ def _rg_binary(env=None):
6569
+ """The ripgrep to use, or "" for none. GU_RG=0 turns it off; GU_RG=<path> picks one; then PATH,
6570
+ then gu's own copy in ~/.gonext/bin."""
6571
+ import os
6572
+ import shutil
6573
+ env = os.environ if env is None else env
6574
+ want = (env.get("GU_RG") or "").strip()
6575
+ if want.lower() in ("0", "off", "false", "no"):
6576
+ return ""
6577
+ if want:
6578
+ return want if os.path.isfile(want) and os.access(want, os.X_OK) else ""
6579
+ found = shutil.which("rg", path=env.get("PATH"))
6580
+ if found:
6581
+ return found
6582
+ own = os.path.join(os.path.expanduser("~"), ".gonext", "bin", "rg.exe" if os.name == "nt" else "rg")
6583
+ return own if os.path.isfile(own) and os.access(own, os.X_OK) else ""
6584
+
6585
+
6586
+ def _grep_hit(path: str, line_no: int, line: str) -> str:
6587
+ """One hit, as both engines print it: ABSOLUTE path (read_file_lines resolves relative paths
6588
+ against the WORKSPACE root, not the search root), line number, the line cut at 200 chars."""
6589
+ return f"{path}:{line_no}: {line.rstrip()[:200]}"
6590
+
6591
+
6592
+ def _grep_hit_order(hit: str):
6593
+ """Sort key: path, then line number as a number (so :9 comes before :10)."""
6594
+ m = re.match(r"^(.*?):(\d+): ", hit)
6595
+ return (m.group(1), int(m.group(2))) if m else (hit, 0)
6596
+
6597
+
6598
+ def _grep_compile(pattern: str):
6599
+ """→ (compiled regex, literal?). An invalid regex is searched as plain text."""
6600
+ try:
6601
+ return re.compile(pattern), False
6602
+ except re.error:
6603
+ return re.compile(re.escape(pattern)), True
6604
+
6605
+
6606
+ def _grep_python(root: str, pattern: str, glob: str = "", limit: int = 100) -> list:
6607
+ import fnmatch
6608
+ import os
6609
+ rx, _ = _grep_compile(pattern)
6610
+ hits = []
6611
+ for abs_path, rel, _ext in _rag_iter_text_files(root):
6612
+ if glob and not fnmatch.fnmatch(os.path.basename(rel), glob) and not fnmatch.fnmatch(rel, glob):
6613
+ continue
6614
+ try:
6615
+ with _open_project_text(abs_path) as fh:
6616
+ for i, line in enumerate(fh, 1):
6617
+ if rx.search(line):
6618
+ hits.append(_grep_hit(abs_path, i, line))
6619
+ if len(hits) >= limit:
6620
+ return hits
6621
+ except OSError:
6622
+ continue
6623
+ return hits
6624
+
6625
+
6626
+ def _grep_rg_args(rg: str, root: str, pattern: str, literal: bool) -> list:
6627
+ """The ripgrep command line that selects the files the Python walk would. Pure."""
6628
+ args = [rg, "--no-config", "--no-ignore", "--hidden", "--no-messages", "--null", "--line-number",
6629
+ "--with-filename", "--no-heading", "--color=never", "--max-filesize", str(_RAG_MAX_FILE_BYTES)]
6630
+ # No --sort: it makes ripgrep single-threaded — measured 12 s instead of 2.3 s over 85 GB of
6631
+ # projects. The hits that come back are sorted instead; past the cap, which 100 is "first
6632
+ # found", as it always was with the Python walk (directory order).
6633
+ if literal:
6634
+ args.append("--fixed-strings")
6635
+ for d in sorted(_RAG_SKIP_DIRS):
6636
+ args += ["--glob", f"!{d}/"]
6637
+ for e in sorted(_RAG_TEXT_EXTS):
6638
+ args += ["--iglob", f"*{e}"]
6639
+ for n in ("dockerfile", "makefile", ".gitignore"):
6640
+ args += ["--iglob", n]
6641
+ return args + ["--regexp", pattern, "--", root]
6642
+
6643
+
6644
+ def _grep_rg(rg: str, root: str, pattern: str, glob: str = "", limit: int = 100, timeout: float = 60.0):
6645
+ """→ hits, or None when ripgrep could not do it (missing, crashed, or a regex it does not support)
6646
+ so the caller falls back to Python. The file list is filtered by the SAME rule as the walk."""
6647
+ import fnmatch
6648
+ import os
6649
+ import subprocess
6650
+ import time
6651
+ _rx, literal = _grep_compile(pattern)
6652
+ single_file = os.path.isfile(root)
6653
+ try:
6654
+ proc = subprocess.Popen(_grep_rg_args(rg, root, pattern, literal), stdout=subprocess.PIPE,
6655
+ stderr=subprocess.PIPE, stdin=subprocess.DEVNULL)
6656
+ except OSError:
6657
+ return None
6658
+ hits = []
6659
+ started = time.monotonic()
6660
+ try:
6661
+ for raw in proc.stdout:
6662
+ name, sep, rest = raw.partition(b"\0")
6663
+ if not sep:
6664
+ continue
6665
+ num, _, text = rest.partition(b":")
6666
+ path = os.path.normpath(name.decode("utf-8", "surrogateescape"))
6667
+ if not single_file:
6668
+ base = os.path.basename(path)
6669
+ if not _rag_wanted_name(base):
6670
+ continue
6671
+ if glob:
6672
+ rel = os.path.relpath(path, root).replace(os.sep, "/")
6673
+ if not fnmatch.fnmatch(base, glob) and not fnmatch.fnmatch(rel, glob):
6674
+ continue
6675
+ try:
6676
+ line_no = int(num)
6677
+ except ValueError:
6678
+ continue
6679
+ hits.append(_grep_hit(path, line_no, text.decode("utf-8", "replace")))
6680
+ if len(hits) >= limit or time.monotonic() - started > timeout:
6681
+ break
6682
+ finally:
6683
+ if proc.poll() is None:
6684
+ proc.kill()
6685
+ try:
6686
+ err = proc.stderr.read().decode("utf-8", "replace")
6687
+ except Exception: # noqa: BLE001
6688
+ err = ""
6689
+ proc.wait()
6690
+ proc.stdout.close()
6691
+ proc.stderr.close()
6692
+ # 0 = hits, 1 = none; 2 = an error. Killed on purpose after the cap → keep what we have.
6693
+ if proc.returncode == 2 and not hits:
6694
+ _log(f"grep: ripgrep could not search ({err.strip()[:200]}) — using the Python search")
6695
+ return None
6696
+ return sorted(hits, key=_grep_hit_order)
6697
+
6698
+
6699
+ def _grep_search(root: str, pattern: str, glob: str = "", limit: int = 100, env=None):
6700
+ """→ (hits, engine) with engine "ripgrep" or "python"."""
6701
+ rg = _rg_binary(env)
6702
+ if rg:
6703
+ hits = _grep_rg(rg, root, pattern, glob, limit)
6704
+ if hits is not None:
6705
+ return hits, "ripgrep"
6706
+ return _grep_python(root, pattern, glob, limit), "python"
6707
+
6708
+
6550
6709
  def _rag_posix_chunk(c: dict, sep: str = "") -> dict:
6551
6710
  """Move a chunk from an index built with `\\` paths (Windows, before #490) to `/` paths.
6552
6711
 
@@ -15246,33 +15405,9 @@ def run_agent_chat(cfg):
15246
15405
  try:
15247
15406
  _emit_running("Searching code",
15248
15407
  _fmt_call("grep_repo", pattern=pattern, path=path, glob=glob))
15249
- import fnmatch
15250
- import os
15251
15408
  root = _rag_read_allowed((path or "").strip() or _default_ws_root())
15252
- try:
15253
- rx = re.compile(pattern)
15254
- except re.error:
15255
- rx = re.compile(re.escape(pattern))
15256
- hits = []
15257
- for abs_path, rel, _ext in _rag_iter_text_files(root):
15258
- if glob and not fnmatch.fnmatch(os.path.basename(rel), glob) \
15259
- and not fnmatch.fnmatch(rel, glob):
15260
- continue
15261
- try:
15262
- with _open_project_text(abs_path) as fh:
15263
- for i, line in enumerate(fh, 1):
15264
- if rx.search(line):
15265
- # ABSOLUTE path — read_file_lines/read_text_file resolve
15266
- # relative paths against the WORKSPACE root, not this
15267
- # search root, so a path relative to `root` (e.g. when
15268
- # searching a subfolder) would be silently wrong there.
15269
- hits.append(f"{abs_path}:{i}: {line.rstrip()[:200]}")
15270
- if len(hits) >= 100:
15271
- break
15272
- except OSError:
15273
- continue
15274
- if len(hits) >= 100:
15275
- break
15409
+ # ripgrep when there is one (as Claude Code does, #379/#380), else the Python walk.
15410
+ hits, _engine = _grep_search(root, pattern, glob, 100)
15276
15411
  _search_tally_bump("grep", len(hits), pattern)
15277
15412
  _emit({"type": "step", "text": f"Searching code → {pattern[:60]} ({len(hits)} hits)"})
15278
15413
  if not hits:
@@ -17060,7 +17195,7 @@ def run_agent_chat(cfg):
17060
17195
  # "the real attempt never got to run" (seen live: workspace summarize request →
17061
17196
  # coding model timed out 3x on a slow/unreachable remote Ollama box → silent
17062
17197
  # fallback claimed "no access to your workspace").
17063
- note = f"⚠️ I couldn't finish the full investigation ({_clip(str(e), 120)}) — here's what I can say without it:\n\n{fallback}"
17198
+ note = f"I couldn't finish the full investigation ({_clip(str(e), 120)}) — here's what I can say without it:\n\n{fallback}"
17064
17199
  # Append the workspace's ACTUAL top-level contents (computed locally, no model
17065
17200
  # call — see _workspace_overview) so the user sees their files are really there
17066
17201
  # even during a total coding-model outage, instead of trusting a plain-chat
package/gu-cli.mjs CHANGED
@@ -486,7 +486,7 @@ if (["start", "stop", "status", "logs", "restart"].includes(args[0])) {
486
486
  if (!cfg.url) {
487
487
  lines.push([` ${label}`, cfg.model || "(none)", "(not set)", "NOT CONFIGURED"]);
488
488
  notes.push(
489
- `⚠ EMBEDDING MODEL NOT FOUND — no ${label} is configured, and there is no fallback\n` +
489
+ `EMBEDDING MODEL NOT FOUND — no ${label} is configured, and there is no fallback\n` +
490
490
  " (chat/code model servers cannot embed). Set the embedding model folder in the web\n" +
491
491
  ` app (${tab}), or run \`gu-cli doctor\` to download and start it.`
492
492
  );
@@ -508,7 +508,7 @@ if (["start", "stop", "status", "logs", "restart"].includes(args[0])) {
508
508
  ]);
509
509
  if (!r.reachable) {
510
510
  notes.push(
511
- `⚠ The ${label} (${tab}) is NOT reachable from this machine:\n` +
511
+ `The ${label} (${tab}) is NOT reachable from this machine:\n` +
512
512
  ` ${cfg.url} — ${r.error}.\n` +
513
513
  " Check the address, the network/tunnel, and that the server is listening on a\n" +
514
514
  " routable interface (127.0.0.1 on another machine is not reachable from here)."
@@ -532,7 +532,7 @@ if (["start", "stop", "status", "logs", "restart"].includes(args[0])) {
532
532
  const ours = !clientHere &&
533
533
  (cfg.configured === false || cfg.port === md.DEFAULT_CHAT_PORT || cfg.port === md.EMBED_PORT);
534
534
  notes.push(
535
- `⚠ The ${label} (${tab}) is NOT answering on this machine:\n` +
535
+ `The ${label} (${tab}) is NOT answering on this machine:\n` +
536
536
  ` ${cfg.model ? `${cfg.model} ` : ""}should be at ${cfg.url}, but nothing responded there.\n` +
537
537
  (cfg.source.startsWith("not set")
538
538
  ? ` Note: no ${label} URL is set, so the agent ${cfg.source.replace("not set — falls back to", "falls back to")}.\n`
package/gu-repl.mjs CHANGED
@@ -117,6 +117,7 @@ import {
117
117
  syncPrevRowsForResize,
118
118
  isToolStepSummary,
119
119
  bulletKind,
120
+ stripWarnMark,
120
121
  workspaceScopeWarning,
121
122
  isLocalApiBase,
122
123
  apiVersionSuffix,
@@ -599,6 +600,9 @@ const BULLET_STYLE = {
599
600
  // wait for approval — so its text is white. Look-ups and finished commands are activity and go
600
601
  // into the dim counted group instead (step-group.mjs); they never reach this style.
601
602
  info: { mark: BULLET, markColor: dim, textColor: brightText },
603
+ // GH #378: a warning is YELLOW TEXT and no icon — "⚠" draws as a broken wide emoji on Windows
604
+ // 11. The mark slot stays (a space), so the text column does not move.
605
+ warn: { mark: " ", markColor: (s) => s, textColor: yellow },
602
606
  };
603
607
  const classifyBullet = (text) => BULLET_STYLE[bulletKind(text)];
604
608
 
@@ -3030,7 +3034,7 @@ const footerBuilt = () =>
3030
3034
  modeHint: pasteHint({ images: coderTakesImages() }),
3031
3035
  // Its own row (#54), sized to the window so the fix it names is never the part cut off.
3032
3036
  notice: imageNotice
3033
- ? orange("⚠ " + fitImageNotice(imageNotice, (process.stdout.columns || 80) - visibleLen(GUTTER) - 1))
3037
+ ? orange(fitImageNotice(imageNotice, (process.stdout.columns || 80) - visibleLen(GUTTER) - 1))
3034
3038
  : stopNotice
3035
3039
  ? dim(clipVisible(stopNotice, (process.stdout.columns || 80) - visibleLen(GUTTER) - 1))
3036
3040
  : "",
@@ -7035,7 +7039,7 @@ async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locali
7035
7039
  ])
7036
7040
  );
7037
7041
  if (lost.length) {
7038
- console.log(yellow(` ⚠ this API dropped ${lost.length === 1 ? "a provider" : `${lost.length} providers`} when saving:`));
7042
+ console.log(yellow(` this API dropped ${lost.length === 1 ? "a provider" : `${lost.length} providers`} when saving:`));
7039
7043
  for (const u of lost) console.log(GUTTER + dim(` ${u}`));
7040
7044
  if (ps.apiKeysBackendsByKind(after?.codingChoices)) {
7041
7045
  console.log(GUTTER + dim("this API identifies a backend by its KIND, so it holds ONE"));
@@ -10194,7 +10198,7 @@ async function showBrowser() {
10194
10198
  const st = await bridgeStatus().catch(() => ({ running: false, connected: false }));
10195
10199
  const lines = browserReport(st, { allowed: allowedIn(resolve(process.cwd())) });
10196
10200
  console.log("");
10197
- for (const [i, l] of lines.entries()) console.log(GUTTER + (i === 0 && st.connected ? accent(l) : l.startsWith("⚠") ? yellow(l) : dim(l)));
10201
+ for (const [i, l] of lines.entries()) console.log(GUTTER + (i === 0 && st.connected ? accent(l) : lines.warnings?.has(l) ? yellow(l) : dim(l)));
10198
10202
  console.log("");
10199
10203
  paintBar();
10200
10204
  }
@@ -11214,7 +11218,7 @@ function drawApprovalOptions(redraw) {
11214
11218
  /** #204: the command block of the open Yes/No prompt — Ctrl+O toggles it (a click does too). */
11215
11219
  let approvalCommandBlock = null;
11216
11220
  /**
11217
- * Print "⚠ Allow running this command?" and the command (#204): every line indented, long lines
11221
+ * Print "Allow running this command?" (yellow, no icon — GH #378) and the command (#204): every line indented, long lines
11218
11222
  * wrapped under that indent instead of at column 0, and a tall command collapsed to its first
11219
11223
  * rows with "click to expand". Registered as a clickable block only when it has two forms and
11220
11224
  * `toggle` is set (a list picker below cannot survive the repaint); otherwise shown whole.
@@ -11223,7 +11227,7 @@ function printApprovalCommand(shown, { toggle = true } = {}) {
11223
11227
  const canToggle = toggle && barPinned();
11224
11228
  const block = { kind: "command", from: 0, to: 0, text: shown, collapsed: canToggle };
11225
11229
  const painted = paintThoughtRows(block, false);
11226
- process.stdout.write("\n" + markLine(yellow("⚠")) + yellow("Allow running this command?") + "\n" + painted.join("\n") + "\n");
11230
+ process.stdout.write("\n" + markLine(" ") + yellow("Allow running this command?") + "\n" + painted.join("\n") + "\n");
11227
11231
  if (!canToggle || !blockCanToggle(block)) return null;
11228
11232
  block.to = transcript.dropped() + transcript.length() - 2;
11229
11233
  block.from = block.to - painted.length + 1;
@@ -12265,7 +12269,7 @@ function announceScaffold() {
12265
12269
  // ONE yellow line (#222): the steps are the token question's own, behind "?" there.
12266
12270
  console.log(GUTTER + yellow("! " + text));
12267
12271
  } else {
12268
- console.log(GUTTER + (kind === "ok" ? accent("✓ " + text) : kind === "warn" ? yellow("⚠ " + text) : red("✗ " + text)));
12272
+ console.log(GUTTER + (kind === "ok" ? accent("✓ " + text) : kind === "warn" ? yellow(text) : red("✗ " + text)));
12269
12273
  }
12270
12274
  if (barPinned()) paintBar();
12271
12275
  }
@@ -12528,7 +12532,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12528
12532
  // warn nobody, which is the bug agent-payload-shape.test.mjs exists to catch.
12529
12533
  if (!skipAsk && codingKeyMissingKnown === true) {
12530
12534
  console.log(
12531
- GUTTER + orange("⚠ no API key for the coding model") +
12535
+ GUTTER + orange("no API key for the coding model") +
12532
12536
  dim(" — coding is running on your chat model. ctrl+k to set it.\n")
12533
12537
  );
12534
12538
  }
@@ -13471,15 +13475,18 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
13471
13475
  // hangs in the gutter (markLine); a long bullet wraps with every continuation row
13472
13476
  // re-indented to the SAME gutter, so it shares one text column with the prompt/answer.
13473
13477
  // Paths under this folder are shown relative to it (#326): the folder is on the bar.
13474
- const stepText = relativizePaths(line.trim(), turnCwd);
13478
+ const rawStep = relativizePaths(line.trim(), turnCwd);
13479
+ // GH #378: the agent's "⚠" marks a warning; it is shown as colour, never as the icon.
13480
+ const stepWarn = bulletKind(rawStep) === "warn";
13481
+ const stepText = stepWarn ? stripWarnMark(rawStep) : rawStep;
13475
13482
  // A LOOK-UP joins the turn's open group — one counted row, a click away from the lines
13476
13483
  // it stands for — instead of printing a line of its own.
13477
- if (isGroupableStep(stepText, bulletKind(stepText))) {
13484
+ if (!stepWarn && isGroupableStep(stepText, bulletKind(stepText))) {
13478
13485
  addLookupStep(stepText);
13479
13486
  lastWasBlank = false;
13480
13487
  continue;
13481
13488
  }
13482
- const st = classifyBullet(stepText);
13489
+ const st = stepWarn ? BULLET_STYLE.warn : classifyBullet(stepText);
13483
13490
  // A finished/failed command fits ONE row, trimmed at a word boundary with "…" (task #20).
13484
13491
  const mCmd = /^(Command (?:finished|failed) → )(.+)$/.exec(stepText);
13485
13492
  const shown = mCmd
@@ -13873,7 +13880,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
13873
13880
  localDaemonRunning
13874
13881
  ? GUTTER +
13875
13882
  dim("waiting for a free slot — the worker is busy with another folder's turn\n")
13876
- : red("\n⚠ no worker has claimed the job — is `gu-cli` running?\n")
13883
+ : red("\nno worker has claimed the job — is `gu-cli` running?\n")
13877
13884
  );
13878
13885
  }
13879
13886
  await sleep(700);
@@ -14469,7 +14476,7 @@ async function main() {
14469
14476
  agreed: ["agentLibs"],
14470
14477
  });
14471
14478
  if (depsOut?.python3 === "missing") {
14472
- console.log(yellow(" ⚠ Without Python the agent cannot answer. Setup goes on, but install Python 3 and run `gu doctor` before your first question.\n"));
14479
+ console.log(yellow(" Without Python the agent cannot answer. Setup goes on, but install Python 3 and run `gu doctor` before your first question.\n"));
14473
14480
  }
14474
14481
  // The worker reads worker.env ONCE at startup, so a daemon that is already
14475
14482
  // running still has the OLD python — the packages would look "not installed"
@@ -15015,7 +15022,7 @@ async function main() {
15015
15022
  if (!entries.length) continue;
15016
15023
  shellRecap = true;
15017
15024
  alignOutputToLineStart();
15018
- console.log("\n" + GUTTER + dim(`⏺ background ${entries.length === 1 ? "shell" : "shells"} finished — ${entries.map((e) => `${e.rec.id} ${shellStatusText(e.st)}`).join(", ")} · gu asks the agent to recap`));
15025
+ console.log("\n" + GUTTER + dim(`background ${entries.length === 1 ? "shell" : "shells"} finished — ${entries.map((e) => `${e.rec.id} ${shellStatusText(e.st)}`).join(", ")} · gu asks the agent to recap`));
15019
15026
  lineRaw = recapQuestion(entries);
15020
15027
  }
15021
15028
  // /resume (#285): show where the last unfinished session stopped; on "yes" the line becomes the
package/model-doctor.mjs CHANGED
@@ -237,7 +237,7 @@ async function ensureVenv(log) {
237
237
  });
238
238
  if (made && existsSync(py)) return py;
239
239
  // Debian/Ubuntu split venv into its own package, so this is a real and common outcome.
240
- log(" ⚠ could not create a virtualenv (on Debian/Ubuntu: sudo apt install python3-venv)");
240
+ log(yellow(" could not create a virtualenv (on Debian/Ubuntu: sudo apt install python3-venv)"));
241
241
  return "";
242
242
  }
243
243
 
@@ -282,7 +282,7 @@ async function pipInstall(pkgs, log) {
282
282
  // (GU_PIP_SYSTEM=1 keeps the old behaviour.)
283
283
  if (process.platform === "win32" && process.env.GU_PIP_SYSTEM !== "1") {
284
284
  if (await intoVenv()) return;
285
- log(" ⚠ could not use a private environment — trying the system Python instead");
285
+ log(yellow(" could not use a private environment — trying the system Python instead"));
286
286
  }
287
287
 
288
288
  log(` installing ${pkgs.join(" ")} … (pip, this can take a minute)`);
@@ -327,7 +327,7 @@ export function chatRamWarningLines(repo, totalBytes) {
327
327
  const ramGb = Math.round((Number(totalBytes) || 0) / 1024 ** 3);
328
328
  const name = String(repo).split("/").pop();
329
329
  const out = [
330
- yellow(` ⚠ ${name} needs a Mac with at least ${MIN_CHAT_RAM_GB} GB of RAM to run well.`),
330
+ yellow(` ${name} needs a Mac with at least ${MIN_CHAT_RAM_GB} GB of RAM to run well.`),
331
331
  ];
332
332
  if (ramGb > 0 && ramGb < MIN_CHAT_RAM_GB) {
333
333
  out.push(
@@ -1121,7 +1121,7 @@ export async function runDoctor({ apiBase, workerKey, log, confirm, prompt, mode
1121
1121
  // Only a REAL configured name is an expectation. "default_model" is mlx_lm's "whatever I
1122
1122
  // loaded" sentinel, so it agrees with anything by construction.
1123
1123
  if (namesAModel(modelId) && !sameModelName(modelId, served.name)) {
1124
- log(` ⚠ your account asks for "${modelId}", but that server is running`);
1124
+ log(yellow(` your account asks for "${modelId}", but that server is running`));
1125
1125
  log(` "${served.name}". Stop it and re-run doctor, or change the model in the web app.`);
1126
1126
  }
1127
1127
  } else {
@@ -1132,7 +1132,7 @@ export async function runDoctor({ apiBase, workerKey, log, confirm, prompt, mode
1132
1132
  chat = "already-running";
1133
1133
  } else if (configuredChat && !isLocalModelUrl(chatUrl)) {
1134
1134
  // Configured but REMOTE and not answering — we can't start a model on another machine.
1135
- log(` ⚠ your chat model server (${modelRoot(chatUrl)}) isn't answering.`);
1135
+ log(yellow(` your chat model server (${modelRoot(chatUrl)}) isn't answering.`));
1136
1136
  log(" It's remote, so start it there — this machine can't launch it.");
1137
1137
  chat = "remote";
1138
1138
  } else {
@@ -1142,7 +1142,7 @@ export async function runDoctor({ apiBase, workerKey, log, confirm, prompt, mode
1142
1142
  if (!dirReady && !chatRepo) {
1143
1143
  // A specifically-named, non-default model we don't know how to fetch — DON'T
1144
1144
  // substitute the default. Tell the user how to bring their own (review H2).
1145
- log(` ⚠ your account uses the chat model "${modelId}", which isn't the built-in`);
1145
+ log(yellow(` your account uses the chat model "${modelId}", which isn't the built-in`));
1146
1146
  log(` default, so I can't auto-download it. Put it at ${dir} (or any dir) and run:`);
1147
1147
  log(` mlx_lm.server --model <that dir> --port ${chatPort}`);
1148
1148
  chat = "unknown-model";
@@ -1163,7 +1163,7 @@ export async function runDoctor({ apiBase, workerKey, log, confirm, prompt, mode
1163
1163
  await downloadModel({ repo: chatRepo, dir, log });
1164
1164
  dirReady = true;
1165
1165
  } catch (e) {
1166
- log(` ⚠ ${e instanceof Error ? e.message : e}`);
1166
+ log(yellow(` ${e instanceof Error ? e.message : e}`));
1167
1167
  chat = "download-failed";
1168
1168
  }
1169
1169
  }
@@ -1200,7 +1200,7 @@ export async function runDoctor({ apiBase, workerKey, log, confirm, prompt, mode
1200
1200
  log(` the first answer may take a minute). Watch it: gu-cli logs`);
1201
1201
  chat = "loading";
1202
1202
  } else {
1203
- log(` ⚠ ${r.reason}`);
1203
+ log(yellow(` ${r.reason}`));
1204
1204
  chat = "start-failed";
1205
1205
  }
1206
1206
  }
@@ -1224,12 +1224,12 @@ export async function runDoctor({ apiBase, workerKey, log, confirm, prompt, mode
1224
1224
  if (codeOk) {
1225
1225
  log(` ✓ code model server is up at ${modelRoot(codeUrl)}.`);
1226
1226
  } else if (codeIsLocal) {
1227
- log(` ⚠ local code model server (${modelRoot(codeUrl)}) isn't answering.`);
1227
+ log(yellow(` local code model server (${modelRoot(codeUrl)}) isn't answering.`));
1228
1228
  if (codeModel) log(` Start Ollama and pull it: ollama serve & ollama pull ${codeModel}`);
1229
1229
  } else {
1230
1230
  // Unreachable AND remote: this machine can't start it, and `ollama pull` is only
1231
1231
  // meaningful if that server is actually Ollama — don't assume it is.
1232
- log(` ⚠ your code model (${modelRoot(codeUrl)}) is not reachable from this machine.`);
1232
+ log(yellow(` your code model (${modelRoot(codeUrl)}) is not reachable from this machine.`));
1233
1233
  log(" It's remote, so start it there / check the network — this machine can't launch it.");
1234
1234
  }
1235
1235
  }
@@ -1369,11 +1369,11 @@ export async function ensureAgentDeps({ log, confirm, modelHost = canHostMlxMode
1369
1369
  const install = installPython ?? (await import("./python-windows.mjs")).installPythonWindows;
1370
1370
  const r = await install({});
1371
1371
  if (r.ok) log(" ✓ Python 3.12 installed");
1372
- else log(` ⚠ could not install Python: ${r.error ?? "unknown error"}`);
1372
+ else log(yellow(` could not install Python: ${r.error ?? "unknown error"}`));
1373
1373
  }
1374
1374
  }
1375
1375
  if (!(await pyHasModule("sys"))) {
1376
- log(` ⚠ ${python3Bin()} not found. Install Python 3 — ${pythonInstallHint()} —`);
1376
+ log(yellow(` ${python3Bin()} not found. Install Python 3 — ${pythonInstallHint()} —`));
1377
1377
  log(" then run `gu-cli doctor` again.");
1378
1378
  out.python3 = "missing";
1379
1379
  return out; // nothing else can be installed without python
@@ -1394,7 +1394,7 @@ export async function ensureAgentDeps({ log, confirm, modelHost = canHostMlxMode
1394
1394
  await pipInstall(pkgs, log);
1395
1395
  out[key] = "installed";
1396
1396
  } catch (e) {
1397
- log(` ⚠ ${e instanceof Error ? e.message : e}`);
1397
+ log(yellow(` ${e instanceof Error ? e.message : e}`));
1398
1398
  out[key] = "failed";
1399
1399
  }
1400
1400
  };
@@ -1425,7 +1425,7 @@ export async function ensureAgentDeps({ log, confirm, modelHost = canHostMlxMode
1425
1425
  "Install the agent framework (smolagents + openai) — required to run the agent?"
1426
1426
  );
1427
1427
  if (out.agentLibs === "skipped" || out.agentLibs === "failed") {
1428
- log(yellow(" ⚠ Without smolagents + openai the agent can't run — install them before asking a question."));
1428
+ log(yellow(" Without smolagents + openai the agent can't run — install them before asking a question."));
1429
1429
  }
1430
1430
  if (out.agentLibs === "installed") {
1431
1431
  // Make the daemon use the interpreter these packages actually landed in. ONLY after a
@@ -1504,7 +1504,7 @@ export async function ensureRag({
1504
1504
  await pipInstall(["boto3"], log);
1505
1505
  out.boto3 = "installed";
1506
1506
  } catch (e) {
1507
- log(` ⚠ ${e instanceof Error ? e.message : e}`);
1507
+ log(yellow(` ${e instanceof Error ? e.message : e}`));
1508
1508
  out.boto3 = "failed";
1509
1509
  }
1510
1510
  } else out.boto3 = "skipped";
@@ -1538,7 +1538,7 @@ export async function ensureRag({
1538
1538
  // A LOCAL embedder that is down is the opposite case and must still fall through: setting
1539
1539
  // one up is precisely what the doctor is for.
1540
1540
  if (configuredEmbedUrl && !isLocalModelUrl(configuredEmbedUrl)) {
1541
- log(` ⚠ your embeddings server (${modelRoot(configuredEmbedUrl)}) is remote and isn't`);
1541
+ log(yellow(` your embeddings server (${modelRoot(configuredEmbedUrl)}) is remote and isn't`));
1542
1542
  log(" answering. Start it there — this machine can't launch it.");
1543
1543
  log(" Leaving the setting alone — nothing here can serve a remote embedder.");
1544
1544
  out.embed = "remote";
@@ -1570,7 +1570,7 @@ export async function ensureRag({
1570
1570
  log(
1571
1571
  savedToAccount
1572
1572
  ? ` ✓ saved to your account — visible in the web app under Settings → Agent → RAG.`
1573
- : ` ⚠ couldn't save that path to your account (using it for this machine anyway).`
1573
+ : yellow(` couldn't save that path to your account (using it for this machine anyway).`)
1574
1574
  );
1575
1575
  }
1576
1576
  const embedDir = configuredDir || EMBED_DIR;
@@ -1589,7 +1589,7 @@ export async function ensureRag({
1589
1589
  // local, so probing embedUrl first let a locally-running server mask a dead remote one
1590
1590
  // and report "already running" for a machine the user can't even reach.
1591
1591
  if (configuredEmbed && !isLocalModelUrl(configuredEmbed)) {
1592
- log(` ⚠ your embeddings server (${modelRoot(configuredEmbed)}) is remote and isn't`);
1592
+ log(yellow(` your embeddings server (${modelRoot(configuredEmbed)}) is remote and isn't`));
1593
1593
  log(" answering. Start it there — this machine can't launch it.");
1594
1594
  out.embed = "remote";
1595
1595
  return out;
@@ -1619,7 +1619,7 @@ export async function ensureRag({
1619
1619
  // don't know what it is — so say what's wrong instead of silently serving a different model.
1620
1620
  if (!(await modelDirReady(embedDir))) {
1621
1621
  if (embedIsCustom) {
1622
- log(` ⚠ the embedding model folder you set in the web app doesn't exist here:`);
1622
+ log(yellow(` the embedding model folder you set in the web app doesn't exist here:`));
1623
1623
  log(` ${embedDir}`);
1624
1624
  log(" Fix the path in Settings → Agent → RAG, or download the model into it:");
1625
1625
  log(` hf download <repo> --local-dir ${embedDir}`);
@@ -1636,7 +1636,7 @@ export async function ensureRag({
1636
1636
  try {
1637
1637
  await downloadModel({ repo: EMBED_REPO, dir: embedDir, log });
1638
1638
  } catch (e) {
1639
- log(` ⚠ ${e instanceof Error ? e.message : e}`);
1639
+ log(yellow(` ${e instanceof Error ? e.message : e}`));
1640
1640
  out.embed = "download-failed";
1641
1641
  return out;
1642
1642
  }
@@ -1657,7 +1657,7 @@ export async function ensureRag({
1657
1657
  log(` … ${modelDisplayName(embedDir)} is loading in the background on port ${embedPort}.`);
1658
1658
  out.embed = "loading";
1659
1659
  } else {
1660
- log(` ⚠ ${r.reason}`);
1660
+ log(yellow(` ${r.reason}`));
1661
1661
  out.embed = "start-failed";
1662
1662
  }
1663
1663
  return out;
@@ -67,7 +67,7 @@ function parts(fb, { fromMax = Infinity, toMax = Infinity } = {}) {
67
67
  }
68
68
 
69
69
  const join = ({ subject, verb, outcome }, reason) =>
70
- `⚠ ${subject} ${verb}${reason ? ` (${reason})` : ""} — ${outcome}`;
70
+ `${subject} ${verb}${reason ? ` (${reason})` : ""} — ${outcome}`;
71
71
 
72
72
  /**
73
73
  * The bar's row for a fallback, FITTED to `width` columns (after the gutter): every bar row must
@@ -109,11 +109,11 @@ export function fallbackHistoryLine(fb) {
109
109
  export function keyMissingNotice({ coder = "", width = 80, gutter = " " } = {}) {
110
110
  const room = Math.max(20, (width || 80) - 1 - gutter.length);
111
111
  const tail = " — coding with the chat model · ctrl+k";
112
- const named = `⚠ no API key for coder ${text(coder)}${tail}`;
112
+ const named = `no API key for coder ${text(coder)}${tail}`;
113
113
  if (text(coder) && named.length <= room) return named;
114
- const plain = `⚠ no API key for the coding model${tail}`;
114
+ const plain = `no API key for the coding model${tail}`;
115
115
  if (plain.length <= room) return plain;
116
- return clipName("⚠ no coder API key — on the chat model · ctrl+k", room);
116
+ return clipName("no coder API key — on the chat model · ctrl+k", room);
117
117
  }
118
118
 
119
119
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.894",
3
+ "version": "1.0.895",
4
4
  "description": "gu: the coding agent in your terminal. It reads your repo, makes the change and runs the tests, with your own models (Ollama or any OpenAI-compatible provider). Free installs run entirely on your machine. macOS, Linux and Windows.",
5
5
  "keywords": [
6
6
  "ai",
package/preset-apply.mjs CHANGED
@@ -86,7 +86,7 @@ export async function applyTransfer(value, { api, io, fromLine = "", carriesSecr
86
86
  : plan.lines;
87
87
  for (const l of lines) say(l);
88
88
  if (summary && plan.replaces) say(`replaces the ${plan.replaces} coding provider${plan.replaces === 1 ? "" : "s"} on this account.`);
89
- for (const w of new Set(warnings)) say("⚠ " + w, "warn");
89
+ for (const w of new Set(warnings)) say(w, "warn");
90
90
  if (carriesSecrets) say("this file contains API keys in plain text — delete it when you are done.", "warn");
91
91
  const go = String((await io.ask("apply this? [y/N] ")) ?? "").trim().toLowerCase();
92
92
  if (go !== "y" && go !== "yes") {
package/quick-models.mjs CHANGED
@@ -55,9 +55,9 @@ export function quickWarning(quick, activeId, { width = 80, gutter = " " } = {
55
55
  const q = normalizeQuick(quick);
56
56
  if (q.length === 0 || isQuickActive(q, activeId)) return "";
57
57
  const full = q.length === QUICK_MAX
58
- ? `⚠ not on a quick model — ← → goes to ${q[0].model} · /quick-models`
59
- : "⚠ not on your quick model · pick 2 with /quick-models to use ← →";
60
- const short = "⚠ not on a quick model · /quick-models";
58
+ ? `not on a quick model — ← → goes to ${q[0].model} · /quick-models`
59
+ : "not on your quick model · pick 2 with /quick-models to use ← →";
60
+ const short = "not on a quick model · /quick-models";
61
61
  const room = Math.max(8, (width || 80) - 1 - gutter.length);
62
62
  const pick = full.length <= room ? full : short;
63
63
  return pick.length <= room ? pick : pick.slice(0, Math.max(1, room - 1)) + "…";
@@ -1253,6 +1253,16 @@ export function syncPrevRowsForResize(rl) {
1253
1253
  // signature — a tool's own line never contains it.
1254
1254
  export const isToolStepSummary = (line) => / \| → /.test(line);
1255
1255
 
1256
+ /**
1257
+ * A WARNING STEP (GH #378). The agent marks one with a leading "⚠" ("⚠ RAG unavailable …").
1258
+ * The terminal shows it as yellow text with NO icon: Windows 11 terminals draw "⚠" as a wide
1259
+ * colour emoji, which broke the row and looked amateur next to the plain marks. The "⚠" stays
1260
+ * in the protocol (it is how a warning is recognised, and the web app shows it); only the
1261
+ * terminal drops it, here.
1262
+ */
1263
+ export const WARN_MARK_RE = /^\s*⚠\uFE0F?\s*/;
1264
+ export const stripWarnMark = (text) => String(text ?? "").replace(WARN_MARK_RE, "");
1265
+
1256
1266
  // Glyph vocabulary for an action line: a FAILURE pops (✗, full-bright), a completed
1257
1267
  // action recedes (✓, dim), anything else is neutral (●, dim). Returns the KIND only —
1258
1268
  // the REPL owns the colours, so this stays pure and testable.
@@ -1302,6 +1312,7 @@ export function isSoftFailCommand(cmd) {
1302
1312
  }
1303
1313
 
1304
1314
  export function bulletKind(text) {
1315
+ if (WARN_MARK_RE.test(String(text ?? ""))) return "warn";
1305
1316
  const mFail = /^Command failed → (.+)$/.exec(String(text ?? "").trim());
1306
1317
  if (mFail && isSoftFailCommand(mFail[1])) return "soft";
1307
1318
  if (/\bfail(ed|s|ure)?\b|\berror(ed)?\b|→\s*(?:HTTP\s*)?[45]\d\d\b/i.test(text)) return "fail";
@@ -2826,7 +2837,7 @@ export function identityRow(
2826
2837
  const where = String(folder ?? "").trim();
2827
2838
  // The mark only means anything beside a named coder: with none shown there is nothing for it
2828
2839
  // to be about, and it would read as a fault in the folder.
2829
- const mark = name && keyUnchosen ? red(" ⚠ key not chosen") : "";
2840
+ const mark = name && keyUnchosen ? red(" key not chosen") : "";
2830
2841
  // API key tail: show the last 4 characters of the API key when available (e.g., "| abcd")
2831
2842
  const keyIndicator = apiKeyTail ? dim(" | ") + dim(apiKeyTail) : "";
2832
2843
  return (name ? orange(name) + mark + keyIndicator + dim(" | ") : "") + cyan(where);
package/theme.mjs CHANGED
@@ -7,8 +7,13 @@
7
7
  * Windows (win32) with no TERM/COLORTERM/WT_SESSION -> ask stdout.getColorDepth()
8
8
  * (conhost on Windows 11 supports 24-bit but advertises nothing).
9
9
  *
10
- * Variants: GU_THEME=dark (default) | light | mono. GU_ASCII=1 swaps the "⏺" glyph for "*"
11
- * (older Windows console fonts lack U+23FA).
10
+ * Variants: GU_THEME=dark (default) | light | mono.
11
+ *
12
+ * NO ICON BEFORE A CARD (GH #378). The header was "⏺ Update(path)"; Windows 11 terminals draw "⏺"
13
+ * as a wide colour emoji, which broke the row and looked amateur. The coloured verb — green
14
+ * Create, yellow Update, red Delete — already says what happened, so the glyph is a space that
15
+ * keeps the text column, on every platform. ("⏺" is still the line protocol from
16
+ * gonext_agent_chat.py: it is how a header is RECOGNISED, never what is drawn.)
12
17
  *
13
18
  * Rules every caller relies on:
14
19
  * - background tiers fill a row by PADDING SPACES (measured with visibleLen), never ESC[K,
@@ -102,7 +107,8 @@ const EMPTY_SIGN = { row: "", sign: "", signOff: "", wordOn: "", wordOff: "" };
102
107
 
103
108
  /** Every escape sequence the edit card uses, for one tier + variant. "" = nothing to emit. */
104
109
  export function makeTheme(tier = "16", variant = "dark", { ascii = false, syntax = false } = {}) {
105
- const glyph = ascii ? "*" : "⏺";
110
+ void ascii; // GU_ASCII used to swap "⏺" for "*"; nothing is drawn now (GH #378)
111
+ const glyph = " ";
106
112
  if (tier === "none") {
107
113
  return {
108
114
  tier, variant, glyph, fill: false, reset: "", lineNo: "", lineNoOff: "", grey: "",