@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 +1 -1
- package/agent-deps-preflight.mjs +5 -2
- package/browser-command.mjs +21 -11
- package/cli-token.mjs +8 -3
- package/client-expert-run.mjs +4 -4
- package/embed-model-command.mjs +5 -5
- package/gonext_agent_chat.py +173 -31
- package/gu-cli.mjs +3 -3
- package/gu-repl.mjs +64 -21
- package/model-doctor.mjs +21 -21
- package/model-fallback.mjs +4 -4
- package/package.json +1 -1
- package/preset-apply.mjs +1 -1
- package/quick-models.mjs +3 -3
- package/terminal-layout.mjs +74 -1
- package/theme.mjs +9 -3
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),
|
|
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.
|
package/agent-deps-preflight.mjs
CHANGED
|
@@ -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("
|
|
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(`
|
|
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
|
}
|
package/browser-command.mjs
CHANGED
|
@@ -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
|
-
|
|
148
|
-
"
|
|
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
|
-
? [
|
|
156
|
-
"
|
|
157
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/client-expert-run.mjs
CHANGED
|
@@ -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("
|
|
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("
|
|
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("
|
|
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("
|
|
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 });
|
package/embed-model-command.mjs
CHANGED
|
@@ -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
|
|
224
|
-
return "
|
|
223
|
+
if (state === "off" && model) return `RAG is switched off — ${model} is set · /embed-model turns it on`;
|
|
224
|
+
return "no embed model — RAG is off · /embed-model";
|
|
225
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 ["
|
|
236
|
+
if (noModel) return ["coding model not set · /model"];
|
|
237
237
|
return [
|
|
238
|
-
...(agentMissing ? ["
|
|
239
|
-
...(embedMissing && embedNeeded ? [embedNotice(embedWhy) || "
|
|
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
|
}
|
package/gonext_agent_chat.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
15253
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
:
|
|
3236
|
-
? yellow(
|
|
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
|
-
:
|
|
3246
|
-
? visibleLen(
|
|
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
|
|
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
|
-
|
|
4789
|
-
|
|
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(`
|
|
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
|
-
|
|
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) :
|
|
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 "
|
|
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(
|
|
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(
|
|
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("
|
|
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
|
|
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("\
|
|
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("
|
|
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(
|
|
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("
|
|
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("
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
1372
|
+
else log(yellow(` could not install Python: ${r.error ?? "unknown error"}`));
|
|
1373
1373
|
}
|
|
1374
1374
|
}
|
|
1375
1375
|
if (!(await pyHasModule("sys"))) {
|
|
1376
|
-
log(`
|
|
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(`
|
|
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("
|
|
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(`
|
|
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(`
|
|
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
|
-
: `
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
1660
|
+
log(yellow(` ${r.reason}`));
|
|
1661
1661
|
out.embed = "start-failed";
|
|
1662
1662
|
}
|
|
1663
1663
|
return out;
|
package/model-fallback.mjs
CHANGED
|
@@ -67,7 +67,7 @@ function parts(fb, { fromMax = Infinity, toMax = Infinity } = {}) {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
const join = ({ subject, verb, outcome }, reason) =>
|
|
70
|
-
|
|
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 =
|
|
112
|
+
const named = `no API key for coder ${text(coder)}${tail}`;
|
|
113
113
|
if (text(coder) && named.length <= room) return named;
|
|
114
|
-
const plain =
|
|
114
|
+
const plain = `no API key for the coding model${tail}`;
|
|
115
115
|
if (plain.length <= room) return plain;
|
|
116
|
-
return clipName("
|
|
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.
|
|
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(
|
|
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
|
-
?
|
|
59
|
-
: "
|
|
60
|
-
const short = "
|
|
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)) + "…";
|
package/terminal-layout.mjs
CHANGED
|
@@ -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("
|
|
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.
|
|
11
|
-
*
|
|
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
|
-
|
|
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: "",
|