@tiens.nguyen/gu-cli 1.0.894 → 1.0.896

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/README.md CHANGED
@@ -188,7 +188,7 @@ gu fix-path | fix-powershell repair PATH / remove PowerShell's `gu` alias
188
188
  | --- | --- | --- |
189
189
  | **Free trial** | 30 days, from the first run | nothing: the token is created for you |
190
190
  | **Free plan** | 3 more months on that computer | a free account at [gucli.com](https://gucli.com) |
191
- | **Pro** | **$4 a month** or **$40 a year** (+ VAT where it applies) | hosted models (no model server to run), up to 5 computers, provider budgets |
191
+ | **Pro** | **$4 a month** or **$40 a year** (+ VAT where it applies) | hosted models (no model server to run), any number of computers, provider budgets |
192
192
 
193
193
  On Pro, the hosted models and the hosted API run on gu's servers, so the prompts and code you
194
194
  send through them go there. A free install keeps everything on your computer.
@@ -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;
package/cli-token.mjs CHANGED
@@ -622,7 +622,8 @@ export function barNotice({ token, publicKeyB64, mode, now = new Date(), attempt
622
622
  // SHORT AND CALM (#292: "make it simpler … seems so serious"): the fact, then the one thing to
623
623
  // do. `urgent` is what the bar colours by — only these two need the user to act.
624
624
  if (ended) return { kind: "plan-ended", urgent: true, text: `Pro ended ${shortDate(p.paidUntil)} · renew at ${pricingUrl}` };
625
- if (Number.isInteger(p.activeDevices) && p.activeDevices > p.deviceLimit) {
625
+ // Pro has no computer limit (GH #371): an over-limit line is for the other plans only.
626
+ if (p.plan !== "pro" && Number.isInteger(p.activeDevices) && p.activeDevices > p.deviceLimit) {
626
627
  return {
627
628
  kind: "over-limit",
628
629
  urgent: true,
@@ -728,7 +729,10 @@ export function tokenSummary(payload, now = new Date()) {
728
729
  }
729
730
  if (Number.isInteger(payload.activeDevices)) {
730
731
  const n = payload.activeDevices;
731
- lines.push(`Used on ${computers(n)} in the last 30 days — the ${planName(payload.plan)} allows ${payload.deviceLimit}.`);
732
+ const paidPro = payload.plan === "pro" && payload.paidUntil && Date.parse(payload.paidUntil) > now.getTime();
733
+ lines.push(paidPro
734
+ ? `Used on ${computers(n)} in the last 30 days — Pro has no computer limit.`
735
+ : `Used on ${computers(n)} in the last 30 days — the ${planName(payload.plan)} allows ${payload.deviceLimit}.`);
732
736
  }
733
737
  lines.push(`Issued ${shortDate(payload.issuedAt)}.`);
734
738
  return lines;
@@ -827,7 +831,8 @@ export function enforcementDecision({ token, publicKeyB64, now = new Date(), sit
827
831
  );
828
832
  }
829
833
  }
830
- if (Number.isInteger(p.activeDevices) && p.activeDevices > p.deviceLimit) {
834
+ // PRO HAS NO COMPUTER LIMIT (GH #371) — not even with a token from a server that still sends a count.
835
+ if (!paid && Number.isInteger(p.activeDevices) && p.activeDevices > p.deviceLimit) {
831
836
  return {
832
837
  enforced: true,
833
838
  allowed: false,
@@ -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,164 @@ 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
+ env = os.environ if env is None else env
6573
+ want = (env.get("GU_RG") or "").strip()
6574
+ if want.lower() in ("0", "off", "false", "no"):
6575
+ return ""
6576
+ if want:
6577
+ return want if os.path.isfile(want) and os.access(want, os.X_OK) else ""
6578
+ # PATH by hand, not shutil.which: on Windows which() looks in the CURRENT folder first, and
6579
+ # gu runs inside the user's project — a repo that ships its own rg.exe would be executed.
6580
+ # Relative PATH entries ("", ".") are skipped for the same reason.
6581
+ exe = "rg.exe" if os.name == "nt" else "rg"
6582
+ for d in (env.get("PATH") or "").split(os.pathsep):
6583
+ d = d.strip().strip('"')
6584
+ if not d or not os.path.isabs(d):
6585
+ continue
6586
+ cand = os.path.join(d, exe)
6587
+ if os.path.isfile(cand) and os.access(cand, os.X_OK):
6588
+ return cand
6589
+ own = os.path.join(os.path.expanduser("~"), ".gonext", "bin", exe)
6590
+ return own if os.path.isfile(own) and os.access(own, os.X_OK) else ""
6591
+
6592
+
6593
+ def _grep_hit(path: str, line_no: int, line: str) -> str:
6594
+ """One hit, as both engines print it: ABSOLUTE path (read_file_lines resolves relative paths
6595
+ against the WORKSPACE root, not the search root), line number, the line cut at 200 chars."""
6596
+ return f"{path}:{line_no}: {line.rstrip()[:200]}"
6597
+
6598
+
6599
+ def _grep_hit_order(hit: str):
6600
+ """Sort key: path, then line number as a number (so :9 comes before :10)."""
6601
+ m = re.match(r"^(.*?):(\d+): ", hit)
6602
+ return (m.group(1), int(m.group(2))) if m else (hit, 0)
6603
+
6604
+
6605
+ def _grep_compile(pattern: str):
6606
+ """→ (compiled regex, literal?). An invalid regex is searched as plain text."""
6607
+ try:
6608
+ return re.compile(pattern), False
6609
+ except re.error:
6610
+ return re.compile(re.escape(pattern)), True
6611
+
6612
+
6613
+ def _grep_python(root: str, pattern: str, glob: str = "", limit: int = 100) -> list:
6614
+ import fnmatch
6615
+ import os
6616
+ rx, _ = _grep_compile(pattern)
6617
+ hits = []
6618
+ for abs_path, rel, _ext in _rag_iter_text_files(root):
6619
+ if glob and not fnmatch.fnmatch(os.path.basename(rel), glob) and not fnmatch.fnmatch(rel, glob):
6620
+ continue
6621
+ try:
6622
+ with _open_project_text(abs_path) as fh:
6623
+ for i, line in enumerate(fh, 1):
6624
+ if rx.search(line):
6625
+ hits.append(_grep_hit(abs_path, i, line))
6626
+ if len(hits) >= limit:
6627
+ return hits
6628
+ except OSError:
6629
+ continue
6630
+ return hits
6631
+
6632
+
6633
+ def _grep_rg_args(rg: str, root: str, pattern: str, literal: bool) -> list:
6634
+ """The ripgrep command line that selects the files the Python walk would. Pure."""
6635
+ args = [rg, "--no-config", "--no-ignore", "--hidden", "--no-messages", "--null", "--line-number",
6636
+ "--with-filename", "--no-heading", "--color=never", "--max-filesize", str(_RAG_MAX_FILE_BYTES)]
6637
+ # No --sort: it makes ripgrep single-threaded — measured 12 s instead of 2.3 s over 85 GB of
6638
+ # projects. The hits that come back are sorted instead; past the cap, which 100 is "first
6639
+ # found", as it always was with the Python walk (directory order).
6640
+ if literal:
6641
+ args.append("--fixed-strings")
6642
+ for d in sorted(_RAG_SKIP_DIRS):
6643
+ args += ["--glob", f"!{d}/"]
6644
+ for e in sorted(_RAG_TEXT_EXTS):
6645
+ args += ["--iglob", f"*{e}"]
6646
+ for n in ("dockerfile", "makefile", ".gitignore"):
6647
+ args += ["--iglob", n]
6648
+ return args + ["--regexp", pattern, "--", root]
6649
+
6650
+
6651
+ def _grep_rg(rg: str, root: str, pattern: str, glob: str = "", limit: int = 100, timeout: float = 60.0):
6652
+ """→ hits, or None when ripgrep could not do it (missing, crashed, or a regex it does not support)
6653
+ so the caller falls back to Python. The file list is filtered by the SAME rule as the walk."""
6654
+ import fnmatch
6655
+ import os
6656
+ import subprocess
6657
+ import time
6658
+ _rx, literal = _grep_compile(pattern)
6659
+ single_file = os.path.isfile(root)
6660
+ try:
6661
+ proc = subprocess.Popen(_grep_rg_args(rg, root, pattern, literal), stdout=subprocess.PIPE,
6662
+ stderr=subprocess.PIPE, stdin=subprocess.DEVNULL)
6663
+ except OSError:
6664
+ return None
6665
+ hits = []
6666
+ started = time.monotonic()
6667
+ try:
6668
+ for raw in proc.stdout:
6669
+ name, sep, rest = raw.partition(b"\0")
6670
+ if not sep:
6671
+ continue
6672
+ num, _, text = rest.partition(b":")
6673
+ path = os.path.normpath(name.decode("utf-8", "surrogateescape"))
6674
+ if not single_file:
6675
+ base = os.path.basename(path)
6676
+ if not _rag_wanted_name(base):
6677
+ continue
6678
+ if glob:
6679
+ rel = os.path.relpath(path, root).replace(os.sep, "/")
6680
+ if not fnmatch.fnmatch(base, glob) and not fnmatch.fnmatch(rel, glob):
6681
+ continue
6682
+ try:
6683
+ line_no = int(num)
6684
+ except ValueError:
6685
+ continue
6686
+ hits.append(_grep_hit(path, line_no, text.decode("utf-8", "replace")))
6687
+ if len(hits) >= limit or time.monotonic() - started > timeout:
6688
+ break
6689
+ finally:
6690
+ if proc.poll() is None:
6691
+ proc.kill()
6692
+ try:
6693
+ err = proc.stderr.read().decode("utf-8", "replace")
6694
+ except Exception: # noqa: BLE001
6695
+ err = ""
6696
+ proc.wait()
6697
+ proc.stdout.close()
6698
+ proc.stderr.close()
6699
+ # 0 = hits, 1 = none; 2 = an error. Killed on purpose after the cap → keep what we have.
6700
+ if proc.returncode == 2 and not hits:
6701
+ _log(f"grep: ripgrep could not search ({err.strip()[:200]}) — using the Python search")
6702
+ return None
6703
+ return sorted(hits, key=_grep_hit_order)
6704
+
6705
+
6706
+ def _grep_search(root: str, pattern: str, glob: str = "", limit: int = 100, env=None):
6707
+ """→ (hits, engine) with engine "ripgrep" or "python"."""
6708
+ rg = _rg_binary(env)
6709
+ if rg:
6710
+ hits = _grep_rg(rg, root, pattern, glob, limit)
6711
+ if hits is not None:
6712
+ return hits, "ripgrep"
6713
+ return _grep_python(root, pattern, glob, limit), "python"
6714
+
6715
+
6550
6716
  def _rag_posix_chunk(c: dict, sep: str = "") -> dict:
6551
6717
  """Move a chunk from an index built with `\\` paths (Windows, before #490) to `/` paths.
6552
6718
 
@@ -15246,33 +15412,9 @@ def run_agent_chat(cfg):
15246
15412
  try:
15247
15413
  _emit_running("Searching code",
15248
15414
  _fmt_call("grep_repo", pattern=pattern, path=path, glob=glob))
15249
- import fnmatch
15250
- import os
15251
15415
  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
15416
+ # ripgrep when there is one (as Claude Code does, #379/#380), else the Python walk.
15417
+ hits, _engine = _grep_search(root, pattern, glob, 100)
15276
15418
  _search_tally_bump("grep", len(hits), pattern)
15277
15419
  _emit({"type": "step", "text": f"Searching code → {pattern[:60]} ({len(hits)} hits)"})
15278
15420
  if not hits:
@@ -17060,7 +17202,7 @@ def run_agent_chat(cfg):
17060
17202
  # "the real attempt never got to run" (seen live: workspace summarize request →
17061
17203
  # coding model timed out 3x on a slow/unreachable remote Ollama box → silent
17062
17204
  # 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}"
17205
+ 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
17206
  # Append the workspace's ACTUAL top-level contents (computed locally, no model
17065
17207
  # call — see _workspace_overview) so the user sees their files are really there
17066
17208
  # 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
@@ -46,6 +46,7 @@ import dotenv from "dotenv";
46
46
  import {
47
47
  clipVisible,
48
48
  createMouseKeyWindow,
49
+ createTurnTypingDetector,
49
50
  promptBreakdownRows,
50
51
  promptTotal,
51
52
  resumeTask,
@@ -117,6 +118,7 @@ import {
117
118
  syncPrevRowsForResize,
118
119
  isToolStepSummary,
119
120
  bulletKind,
121
+ stripWarnMark,
120
122
  workspaceScopeWarning,
121
123
  isLocalApiBase,
122
124
  apiVersionSuffix,
@@ -599,6 +601,9 @@ const BULLET_STYLE = {
599
601
  // wait for approval — so its text is white. Look-ups and finished commands are activity and go
600
602
  // into the dim counted group instead (step-group.mjs); they never reach this style.
601
603
  info: { mark: BULLET, markColor: dim, textColor: brightText },
604
+ // GH #378: a warning is YELLOW TEXT and no icon — "⚠" draws as a broken wide emoji on Windows
605
+ // 11. The mark slot stays (a space), so the text column does not move.
606
+ warn: { mark: " ", markColor: (s) => s, textColor: yellow },
602
607
  };
603
608
  const classifyBullet = (text) => BULLET_STYLE[bulletKind(text)];
604
609
 
@@ -3030,7 +3035,7 @@ const footerBuilt = () =>
3030
3035
  modeHint: pasteHint({ images: coderTakesImages() }),
3031
3036
  // Its own row (#54), sized to the window so the fix it names is never the part cut off.
3032
3037
  notice: imageNotice
3033
- ? orange("⚠ " + fitImageNotice(imageNotice, (process.stdout.columns || 80) - visibleLen(GUTTER) - 1))
3038
+ ? orange(fitImageNotice(imageNotice, (process.stdout.columns || 80) - visibleLen(GUTTER) - 1))
3034
3039
  : stopNotice
3035
3040
  ? dim(clipVisible(stopNotice, (process.stdout.columns || 80) - visibleLen(GUTTER) - 1))
3036
3041
  : "",
@@ -3232,8 +3237,8 @@ function frozenZone() {
3232
3237
  // #258: typing during a turn is not sent — the row says so (an ask() label still comes first).
3233
3238
  prompt: secretInput && secretPrompt
3234
3239
  ? accent(secretPrompt)
3235
- : following && typedDuringTurn && !inlinePrompt
3236
- ? yellow(typedNotice(width))
3240
+ : turnRowNotice(width) && !inlinePrompt
3241
+ ? yellow(turnRowNotice(width))
3237
3242
  : inlinePrompt ? accent(inlinePrompt) : PROMPT,
3238
3243
  // WHILE A SECRET IS OPEN, THIS ROW IS THE PROMPT — "kimi-k3 API key: ••••" rather than
3239
3244
  // ">> ••••". Same row, same masking; what changes is that it now says what it wants. The
@@ -3242,8 +3247,8 @@ function frozenZone() {
3242
3247
  promptCols:
3243
3248
  secretInput && secretPrompt
3244
3249
  ? visibleLen(secretPrompt)
3245
- : following && typedDuringTurn && !inlinePrompt
3246
- ? visibleLen(typedNotice(width))
3250
+ : turnRowNotice(width) && !inlinePrompt
3251
+ ? visibleLen(turnRowNotice(width))
3247
3252
  : inlinePrompt
3248
3253
  ? visibleLen(inlinePrompt)
3249
3254
  : PROMPT_COLS,
@@ -4738,7 +4743,11 @@ if (process.stdin.isTTY) {
4738
4743
  // are part of a payload readline never sees. Without this, every pasted character also
4739
4744
  // re-rendered the slash overlay (the line still starts with "/" from before the paste) and
4740
4745
  // re-walked the paste store. Same for a mouse report's characters, which are not keys.
4741
- if (pasteBuffer !== null || inMouseKeyWindow()) return;
4746
+ if (pasteBuffer !== null) return;
4747
+ if (inMouseKeyWindow()) {
4748
+ turnTyping.reset(); // a held "[" was the start of that report (GH #382)
4749
+ return;
4750
+ }
4742
4751
  // Issue #120: compacting → every key is locked (Ctrl+C still arrives as SIGINT).
4743
4752
  if (compactLocked()) {
4744
4753
  rl.line = "";
@@ -4785,8 +4794,9 @@ if (process.stdin.isTTY) {
4785
4794
  if (key && key.name === "tab" && key.shift) switchEditMode();
4786
4795
  // A printable key (or Enter after one) means the user is typing a question that will not
4787
4796
  // be sent — say so in the input row now, not after they have typed the whole thing (#258).
4788
- const printable = typeof _ch === "string" && _ch.length > 0 && !/[\x00-\x1f\x7f]/.test(_ch) && !key?.ctrl && !key?.meta;
4789
- if (printable && !typedDuringTurn) {
4797
+ // Not every printable key is typing: a mouse report or cursor reply whose ESC came alone
4798
+ // arrives as "[", "^[[", "[12;5R" (Windows, GH #382) — the detector sits those out.
4799
+ if (turnTyping.feed(_ch, key) && !typedDuringTurn) {
4790
4800
  typedDuringTurn = true;
4791
4801
  if (barPinned()) paintBar();
4792
4802
  }
@@ -7035,7 +7045,7 @@ async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locali
7035
7045
  ])
7036
7046
  );
7037
7047
  if (lost.length) {
7038
- console.log(yellow(` ⚠ this API dropped ${lost.length === 1 ? "a provider" : `${lost.length} providers`} when saving:`));
7048
+ console.log(yellow(` this API dropped ${lost.length === 1 ? "a provider" : `${lost.length} providers`} when saving:`));
7039
7049
  for (const u of lost) console.log(GUTTER + dim(` ${u}`));
7040
7050
  if (ps.apiKeysBackendsByKind(after?.codingChoices)) {
7041
7051
  console.log(GUTTER + dim("this API identifies a backend by its KIND, so it holds ONE"));
@@ -8865,8 +8875,13 @@ async function refreshLogsSize() {
8865
8875
  // What is collected and how it is sent is support-bundle.mjs; this is the terminal half only.
8866
8876
  let supportBusy = false;
8867
8877
  async function runSupport() {
8878
+ // DURING A TURN (GH #382): it used to print one dim line into the turn's output — easy to
8879
+ // miss, then nothing happened, and a stuck turn is exactly when someone wants support. Now it
8880
+ // is QUEUED: the input row says so (with the way out, Ctrl-C) and it opens when the turn ends.
8868
8881
  if (following) {
8869
- console.log("\n" + GUTTER + dim("A turn is running - /support is available when it finishes (Ctrl-C stops it)."));
8882
+ supportAfterTurn = true;
8883
+ if (barPinned()) paintBar();
8884
+ else console.log("\n" + GUTTER + yellow(SUPPORT_AFTER_TURN_NOTICE.trim()));
8870
8885
  return;
8871
8886
  }
8872
8887
  if (supportBusy || listPickerActive || approvalActive || askWaiting) return;
@@ -10194,7 +10209,7 @@ async function showBrowser() {
10194
10209
  const st = await bridgeStatus().catch(() => ({ running: false, connected: false }));
10195
10210
  const lines = browserReport(st, { allowed: allowedIn(resolve(process.cwd())) });
10196
10211
  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)));
10212
+ 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
10213
  console.log("");
10199
10214
  paintBar();
10200
10215
  }
@@ -11144,11 +11159,28 @@ let following = false;
11144
11159
  // typed while the previous one was still queued simply vanished. Now the input row says so the
11145
11160
  // moment a key lands, and keeps saying it until the turn ends. Nothing is queued or sent.
11146
11161
  let typedDuringTurn = false;
11162
+ const turnTyping = createTurnTypingDetector(); // what counts as typing during a turn (GH #382)
11147
11163
  const TYPED_DURING_TURN_NOTICE = "(a turn is running — typing is not sent; type it again when it finishes) ";
11148
11164
  const TYPED_DURING_TURN_SHORT = "(turn running — not sent) ";
11165
+ // "? support" clicked during a turn (GH #382): it opens when the turn ends; Ctrl-C ends it now.
11166
+ let supportAfterTurn = false;
11167
+ const SUPPORT_AFTER_TURN_NOTICE = "(support opens when this turn ends — Ctrl-C stops the turn now) ";
11168
+ const SUPPORT_AFTER_TURN_SHORT = "(support after this turn — Ctrl-C) ";
11169
+ // After the first Ctrl-C the turn is stopping; a worker that never answers needs a second one.
11170
+ const SUPPORT_AFTER_STOP_NOTICE = "(support opens when this turn ends — Ctrl-C again to stop waiting) ";
11171
+ const SUPPORT_AFTER_STOP_SHORT = "(support after this turn — Ctrl-C again) ";
11149
11172
  /** The notice that fits: the input row must never wrap because of its label (#109/#132). */
11150
11173
  const typedNotice = (width) =>
11151
11174
  visibleLen(TYPED_DURING_TURN_NOTICE) + 12 <= width ? TYPED_DURING_TURN_NOTICE : TYPED_DURING_TURN_SHORT;
11175
+ /** What the input row says during a turn instead of ">>", or "" — queued support comes first. */
11176
+ const turnRowNotice = (width) =>
11177
+ !following
11178
+ ? ""
11179
+ : supportAfterTurn
11180
+ ? cancelRequested
11181
+ ? visibleLen(SUPPORT_AFTER_STOP_NOTICE) + 12 <= width ? SUPPORT_AFTER_STOP_NOTICE : SUPPORT_AFTER_STOP_SHORT
11182
+ : visibleLen(SUPPORT_AFTER_TURN_NOTICE) + 12 <= width ? SUPPORT_AFTER_TURN_NOTICE : SUPPORT_AFTER_TURN_SHORT
11183
+ : typedDuringTurn ? typedNotice(width) : "";
11152
11184
  // Is the turn we are following one this terminal STARTED, or another terminal's that we merely
11153
11185
  // attached to (task #211)? `attached` is local to runAgentTurn, and onInterrupt — which lives in
11154
11186
  // main() — had no way to ask. So Ctrl+C while watching somebody else's agent took the "cancel my
@@ -11214,7 +11246,7 @@ function drawApprovalOptions(redraw) {
11214
11246
  /** #204: the command block of the open Yes/No prompt — Ctrl+O toggles it (a click does too). */
11215
11247
  let approvalCommandBlock = null;
11216
11248
  /**
11217
- * Print "⚠ Allow running this command?" and the command (#204): every line indented, long lines
11249
+ * Print "Allow running this command?" (yellow, no icon — GH #378) and the command (#204): every line indented, long lines
11218
11250
  * wrapped under that indent instead of at column 0, and a tall command collapsed to its first
11219
11251
  * rows with "click to expand". Registered as a clickable block only when it has two forms and
11220
11252
  * `toggle` is set (a list picker below cannot survive the repaint); otherwise shown whole.
@@ -11223,7 +11255,7 @@ function printApprovalCommand(shown, { toggle = true } = {}) {
11223
11255
  const canToggle = toggle && barPinned();
11224
11256
  const block = { kind: "command", from: 0, to: 0, text: shown, collapsed: canToggle };
11225
11257
  const painted = paintThoughtRows(block, false);
11226
- process.stdout.write("\n" + markLine(yellow("⚠")) + yellow("Allow running this command?") + "\n" + painted.join("\n") + "\n");
11258
+ process.stdout.write("\n" + markLine(" ") + yellow("Allow running this command?") + "\n" + painted.join("\n") + "\n");
11227
11259
  if (!canToggle || !blockCanToggle(block)) return null;
11228
11260
  block.to = transcript.dropped() + transcript.length() - 2;
11229
11261
  block.from = block.to - painted.length + 1;
@@ -12265,7 +12297,7 @@ function announceScaffold() {
12265
12297
  // ONE yellow line (#222): the steps are the token question's own, behind "?" there.
12266
12298
  console.log(GUTTER + yellow("! " + text));
12267
12299
  } else {
12268
- console.log(GUTTER + (kind === "ok" ? accent("✓ " + text) : kind === "warn" ? yellow("⚠ " + text) : red("✗ " + text)));
12300
+ console.log(GUTTER + (kind === "ok" ? accent("✓ " + text) : kind === "warn" ? yellow(text) : red("✗ " + text)));
12269
12301
  }
12270
12302
  if (barPinned()) paintBar();
12271
12303
  }
@@ -12528,7 +12560,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12528
12560
  // warn nobody, which is the bug agent-payload-shape.test.mjs exists to catch.
12529
12561
  if (!skipAsk && codingKeyMissingKnown === true) {
12530
12562
  console.log(
12531
- GUTTER + orange("⚠ no API key for the coding model") +
12563
+ GUTTER + orange("no API key for the coding model") +
12532
12564
  dim(" — coding is running on your chat model. ctrl+k to set it.\n")
12533
12565
  );
12534
12566
  }
@@ -12542,6 +12574,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12542
12574
  following = true;
12543
12575
  followingAttached = attached;
12544
12576
  typedDuringTurn = false;
12577
+ turnTyping.reset();
12545
12578
  compactSeenPasses = 0; // issue #120: python counts passes per turn
12546
12579
  followAborted = false;
12547
12580
  currentJobId = jobId;
@@ -13471,15 +13504,18 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
13471
13504
  // hangs in the gutter (markLine); a long bullet wraps with every continuation row
13472
13505
  // re-indented to the SAME gutter, so it shares one text column with the prompt/answer.
13473
13506
  // Paths under this folder are shown relative to it (#326): the folder is on the bar.
13474
- const stepText = relativizePaths(line.trim(), turnCwd);
13507
+ const rawStep = relativizePaths(line.trim(), turnCwd);
13508
+ // GH #378: the agent's "⚠" marks a warning; it is shown as colour, never as the icon.
13509
+ const stepWarn = bulletKind(rawStep) === "warn";
13510
+ const stepText = stepWarn ? stripWarnMark(rawStep) : rawStep;
13475
13511
  // A LOOK-UP joins the turn's open group — one counted row, a click away from the lines
13476
13512
  // it stands for — instead of printing a line of its own.
13477
- if (isGroupableStep(stepText, bulletKind(stepText))) {
13513
+ if (!stepWarn && isGroupableStep(stepText, bulletKind(stepText))) {
13478
13514
  addLookupStep(stepText);
13479
13515
  lastWasBlank = false;
13480
13516
  continue;
13481
13517
  }
13482
- const st = classifyBullet(stepText);
13518
+ const st = stepWarn ? BULLET_STYLE.warn : classifyBullet(stepText);
13483
13519
  // A finished/failed command fits ONE row, trimmed at a word boundary with "…" (task #20).
13484
13520
  const mCmd = /^(Command (?:finished|failed) → )(.+)$/.exec(stepText);
13485
13521
  const shown = mCmd
@@ -13873,7 +13909,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
13873
13909
  localDaemonRunning
13874
13910
  ? GUTTER +
13875
13911
  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")
13912
+ : red("\nno worker has claimed the job — is `gu-cli` running?\n")
13877
13913
  );
13878
13914
  }
13879
13915
  await sleep(700);
@@ -13894,6 +13930,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
13894
13930
  following = false;
13895
13931
  followingAttached = false;
13896
13932
  typedDuringTurn = false;
13933
+ turnTyping.reset();
13897
13934
  endCompactLock(); // issue #120: never leave the terminal locked after a turn
13898
13935
  currentJobId = null;
13899
13936
  // Drop anything typed (but not submitted) during the turn — echo was muted, so it's
@@ -14469,7 +14506,7 @@ async function main() {
14469
14506
  agreed: ["agentLibs"],
14470
14507
  });
14471
14508
  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"));
14509
+ 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
14510
  }
14474
14511
  // The worker reads worker.env ONCE at startup, so a daemon that is already
14475
14512
  // running still has the OLD python — the packages would look "not installed"
@@ -14868,6 +14905,7 @@ async function main() {
14868
14905
  // not just detach. The worker sees the "cancelled" status and kills the Python
14869
14906
  // agent; the poll loop then returns cleanly with the "(cancelled)" note.
14870
14907
  cancelRequested = true;
14908
+ if (supportAfterTurn && barPinned()) paintBar(); // its row now says "Ctrl-C again" (GH #382)
14871
14909
  const jobId = currentJobId;
14872
14910
  // #307: while the question is still held (nothing printed yet), nothing goes to the
14873
14911
  // transcript either — a quick change of mind leaves no trace.
@@ -14974,6 +15012,11 @@ async function main() {
14974
15012
  // the turn ends; a queued line carries no Enter keypress, so its re-prompt overwrites
14975
15013
  // in place (\r + refresh) rather than stacking ">> >> >> " on one row.
14976
15014
  let lineRaw;
15015
+ // "? support" was clicked during the turn that just ended (GH #382) — however it ended.
15016
+ if (supportAfterTurn) {
15017
+ supportAfterTurn = false;
15018
+ _lineQueue.unshift("/support");
15019
+ }
14977
15020
  mainPromptWaiting = true; // a finished background shell may start a recap turn now (#362)
14978
15021
  for (;;) {
14979
15022
  lineRaw = await nextLine();
@@ -15015,7 +15058,7 @@ async function main() {
15015
15058
  if (!entries.length) continue;
15016
15059
  shellRecap = true;
15017
15060
  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`));
15061
+ 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
15062
  lineRaw = recapQuestion(entries);
15020
15063
  }
15021
15064
  // /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.896",
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)) + "…";
@@ -434,6 +434,68 @@ export function promptBreakdownRows(b) {
434
434
  }));
435
435
  }
436
436
 
437
+ /**
438
+ * DID THE USER TYPE DURING THE TURN? (GH #382) — for the "(a turn is running — typing is not
439
+ * sent …)" notice. A printable key used to be enough, but the terminal also sends text nobody
440
+ * typed: a mouse report or a cursor-position reply already on its way when Enter started the
441
+ * turn. On Windows its ESC often comes alone (node gives it up after 500ms) and the rest arrives
442
+ * as ordinary characters — "[", "^[[", "[12;5R" — which showed the notice with no key pressed.
443
+ *
444
+ * A lone Esc, "[" or "^" starts a held run. It stays a reply while the characters fit a mouse
445
+ * report or cursor reply ("[", "<", "^", digits, ";") and ends silently on its "M", "m" or "R".
446
+ * Anything else means it was typing after all: `feed` → true. Enter after a held "[" too.
447
+ * The joined mouse shape never gets here (createMouseKeyWindow); the caller calls `reset()` for
448
+ * the keys that window takes, which includes the rest of a split report after its "[".
449
+ */
450
+ export function createTurnTypingDetector({ maxRun = 32 } = {}) {
451
+ let active = false;
452
+ let held = "";
453
+ const reset = () => {
454
+ active = false;
455
+ held = "";
456
+ };
457
+ return {
458
+ /** → true when this key shows the user typed. Feed every key of the turn, in order. */
459
+ feed(ch, key) {
460
+ if (key?.name === "escape") {
461
+ active = true;
462
+ held = "";
463
+ return false;
464
+ }
465
+ if (key && (key.name === "return" || key.name === "enter")) {
466
+ const typed = active && held !== "";
467
+ reset();
468
+ return typed;
469
+ }
470
+ const printable =
471
+ typeof ch === "string" && ch.length > 0 && !/[\x00-\x1f\x7f]/.test(ch) && !key?.ctrl && !key?.meta;
472
+ if (!printable) {
473
+ reset();
474
+ return false;
475
+ }
476
+ if (!active) {
477
+ if (ch === "[" || ch === "^") {
478
+ active = true;
479
+ held = ch;
480
+ return false;
481
+ }
482
+ return true;
483
+ }
484
+ if (/^[MmR]$/.test(ch) && held.includes("[")) {
485
+ reset();
486
+ return false;
487
+ }
488
+ if (/^[[<^\d;]$/.test(ch) && held.length < maxRun && (held !== "" || ch === "[" || ch === "^")) {
489
+ held += ch;
490
+ return false;
491
+ }
492
+ reset();
493
+ return true;
494
+ },
495
+ reset,
496
+ };
497
+ }
498
+
437
499
  /**
438
500
  * A MOUSE REPORT IS NOT TYPING — the window in which keypress events belong to the mouse.
439
501
  *
@@ -1253,6 +1315,16 @@ export function syncPrevRowsForResize(rl) {
1253
1315
  // signature — a tool's own line never contains it.
1254
1316
  export const isToolStepSummary = (line) => / \| → /.test(line);
1255
1317
 
1318
+ /**
1319
+ * A WARNING STEP (GH #378). The agent marks one with a leading "⚠" ("⚠ RAG unavailable …").
1320
+ * The terminal shows it as yellow text with NO icon: Windows 11 terminals draw "⚠" as a wide
1321
+ * colour emoji, which broke the row and looked amateur next to the plain marks. The "⚠" stays
1322
+ * in the protocol (it is how a warning is recognised, and the web app shows it); only the
1323
+ * terminal drops it, here.
1324
+ */
1325
+ export const WARN_MARK_RE = /^\s*⚠\uFE0F?\s*/;
1326
+ export const stripWarnMark = (text) => String(text ?? "").replace(WARN_MARK_RE, "");
1327
+
1256
1328
  // Glyph vocabulary for an action line: a FAILURE pops (✗, full-bright), a completed
1257
1329
  // action recedes (✓, dim), anything else is neutral (●, dim). Returns the KIND only —
1258
1330
  // the REPL owns the colours, so this stays pure and testable.
@@ -1302,6 +1374,7 @@ export function isSoftFailCommand(cmd) {
1302
1374
  }
1303
1375
 
1304
1376
  export function bulletKind(text) {
1377
+ if (WARN_MARK_RE.test(String(text ?? ""))) return "warn";
1305
1378
  const mFail = /^Command failed → (.+)$/.exec(String(text ?? "").trim());
1306
1379
  if (mFail && isSoftFailCommand(mFail[1])) return "soft";
1307
1380
  if (/\bfail(ed|s|ure)?\b|\berror(ed)?\b|→\s*(?:HTTP\s*)?[45]\d\d\b/i.test(text)) return "fail";
@@ -2826,7 +2899,7 @@ export function identityRow(
2826
2899
  const where = String(folder ?? "").trim();
2827
2900
  // The mark only means anything beside a named coder: with none shown there is nothing for it
2828
2901
  // to be about, and it would read as a fault in the folder.
2829
- const mark = name && keyUnchosen ? red(" ⚠ key not chosen") : "";
2902
+ const mark = name && keyUnchosen ? red(" key not chosen") : "";
2830
2903
  // API key tail: show the last 4 characters of the API key when available (e.g., "| abcd")
2831
2904
  const keyIndicator = apiKeyTail ? dim(" | ") + dim(apiKeyTail) : "";
2832
2905
  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: "",