@tiens.nguyen/gu-cli 1.0.893 → 1.0.895

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * PYTHON ON A NEW WINDOWS 11 (GH #363).
3
+ *
4
+ * A brand-new Windows 11 has no Python, and gu's agent runs on it. gu used to say "Install Python 3 — winget …"
5
+ * and then carry on through a long database setup that ended in a broken agent. Like the installer does for
6
+ * Node, gu now offers to do it: the official python.org installer, for THIS USER only (no administrator, no
7
+ * system-wide change), after checking that the file is signed by the Python Software Foundation.
8
+ *
9
+ * Pure parts (URL, folders, the PowerShell script) are separate; the net and the machine are injected so tests
10
+ * never download or install anything.
11
+ */
12
+ import { mkdir, rm, writeFile } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { execFile } from "node:child_process";
16
+
17
+ // Bump on purpose. 3.12 has prebuilt wheels for everything gu installs (see PYTHON_WHEELS_MAX).
18
+ export const PYTHON_WIN_VERSION = "3.12.8";
19
+
20
+ export function pythonWinInstallerUrl(version = PYTHON_WIN_VERSION) {
21
+ return `https://www.python.org/ftp/python/${version}/python-${version}-amd64.exe`;
22
+ }
23
+
24
+ /** Where a per-user install lands: %LOCALAPPDATA%\Programs\Python\Python312, its Scripts, and the `py` launcher. */
25
+ export function pythonWinFolders(version = PYTHON_WIN_VERSION, localAppData = process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local")) {
26
+ const [maj, min] = version.split(".");
27
+ const root = join(localAppData, "Programs", "Python", `Python${maj}${min}`);
28
+ return [root, join(root, "Scripts"), join(localAppData, "Programs", "Python", "Launcher")];
29
+ }
30
+
31
+ /** Put the new folders on THIS process's PATH: the installer only edits the saved PATH, which only new terminals read. */
32
+ export function withPythonOnPath(pathValue, folders) {
33
+ const have = String(pathValue ?? "").split(";").map((p) => p.trim().toLowerCase());
34
+ const add = folders.filter((f) => !have.includes(f.toLowerCase()));
35
+ return add.length ? add.join(";") + ";" + String(pathValue ?? "") : String(pathValue ?? "");
36
+ }
37
+
38
+ /** The PowerShell that checks the signature, then installs quietly for the current user. Exit 90 = not signed by the PSF. */
39
+ export function pythonInstallScript(file) {
40
+ const f = file.replace(/'/g, "''");
41
+ return [
42
+ `$f = '${f}'`,
43
+ `$s = Get-AuthenticodeSignature -FilePath $f`,
44
+ `if ($s.Status -ne 'Valid' -or "$($s.SignerCertificate.Subject)" -notmatch 'Python Software Foundation') { exit 90 }`,
45
+ `$p = Start-Process -FilePath $f -ArgumentList '/quiet','InstallAllUsers=0','PrependPath=1','Include_launcher=1','Include_test=0' -Wait -PassThru`,
46
+ `exit $p.ExitCode`,
47
+ ].join("; ");
48
+ }
49
+
50
+ /**
51
+ * The environment for the Python installer: the REAL profile. A test (or anyone) may point USERPROFILE/HOME at a scratch
52
+ * folder; the installer then fails with 0x80070003 ("path not found"). LOCALAPPDATA still names the real profile
53
+ * (<profile>\\AppData\\Local), so the profile is derived from it. Pure.
54
+ */
55
+ export function pythonInstallerEnv(env = process.env) {
56
+ const out = { ...env };
57
+ const m = /^(.*)[\\/]AppData[\\/]Local[\\/]?$/i.exec(String(env.LOCALAPPDATA ?? ""));
58
+ if (m && m[1]) {
59
+ out.USERPROFILE = m[1];
60
+ out.HOME = m[1];
61
+ }
62
+ return out;
63
+ }
64
+
65
+ /**
66
+ * Download, verify, install. → { ok, folders?, error? }. `fetchFn` and `run` (file → { code }) are injectable.
67
+ * On success the process PATH is updated so the very next `py -3` in this run finds it.
68
+ */
69
+ export async function installPythonWindows({ dir = join(homedir(), ".gonext"), version = PYTHON_WIN_VERSION, fetchFn = fetch, run = null, env = process.env } = {}) {
70
+ const file = join(dir, `python-${version}-amd64.exe`);
71
+ try {
72
+ await mkdir(dir, { recursive: true });
73
+ const res = await fetchFn(pythonWinInstallerUrl(version), { redirect: "follow" });
74
+ if (!res.ok) return { ok: false, error: `download failed (HTTP ${res.status})` };
75
+ await writeFile(file, Buffer.from(await res.arrayBuffer()));
76
+ } catch (e) {
77
+ return { ok: false, error: `download failed: ${e?.message ?? e}` };
78
+ }
79
+ const runner = run ?? ((f) => new Promise((resolve) => {
80
+ execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", pythonInstallScript(f)], { windowsHide: true, timeout: 900000, env: pythonInstallerEnv(env) }, (err) => {
81
+ resolve({ code: err ? (typeof err.code === "number" ? err.code : null) : 0 });
82
+ });
83
+ }));
84
+ const r = await runner(file);
85
+ await rm(file, { force: true }).catch(() => {});
86
+ if (r.code === 90) return { ok: false, error: "the downloaded file is not signed by the Python Software Foundation, so it was NOT installed" };
87
+ if (r.code !== 0) return { ok: false, error: `the Python installer stopped with code ${r.code}` };
88
+ const folders = pythonWinFolders(version, env.LOCALAPPDATA || undefined);
89
+ env.Path !== undefined ? (env.Path = withPythonOnPath(env.Path, folders)) : (env.PATH = withPythonOnPath(env.PATH, folders));
90
+ return { ok: true, folders };
91
+ }
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)) + "…";
@@ -451,9 +451,16 @@ export function promptBreakdownRows(b) {
451
451
  export function createMouseKeyWindow({ maxRun = 24 } = {}) {
452
452
  let run = 0;
453
453
  let current = false;
454
+ let prev = ""; // the previous key's text, for the split shape below
454
455
  return {
455
456
  track(str, key) {
456
- if (key?.code === "[<") {
457
+ const text = typeof str === "string" ? str : "";
458
+ // The joined shape ("[<" as one key) — or the SPLIT one (GH #365): an Esc node timed out
459
+ // on, then "[" and "<" as ordinary characters. The "[" has already gone by as a key by
460
+ // then; the run starts at the "<".
461
+ const split = text === "<" && prev === "[";
462
+ prev = key?.code === "[<" ? "" : text;
463
+ if (key?.code === "[<" || (split && !run)) {
457
464
  run = 1;
458
465
  return (current = true);
459
466
  }
@@ -466,6 +473,100 @@ export function createMouseKeyWindow({ maxRun = 24 } = {}) {
466
473
  };
467
474
  }
468
475
 
476
+ /**
477
+ * KEEP EVERY SHAPE OF A MOUSE REPORT OUT OF THE LINE (GH #365).
478
+ *
479
+ * The input filter used to recognise a report only by node's key code "[<" — the shape node
480
+ * produces when `ESC [ <` arrives together. When the event loop stalls for longer than
481
+ * readline's escape timeout (500ms) with an ESC at the end of a read, node gives the ESC up as a
482
+ * lone Esc key and the rest comes through as ORDINARY characters: "[", "<", "35;114;22M". Every
483
+ * one of those was typed into the line. Reported on Windows 11 after minimising and restoring
484
+ * the window (a full repaint, and Windows console writes block), then more with every hover,
485
+ * because the prompt reports mouse motion.
486
+ *
487
+ * Shapes, all consumed whole:
488
+ * key "[<", then "35;114;22M" ← the usual one
489
+ * Esc, "[", "<", "35;114;22M" ← the ESC timed out
490
+ * "[", "<", "35;114;22M" ← the Esc went elsewhere (plainLeads only)
491
+ * "^", "[", "[", "<", … ← ESC spelled in caret notation (plainLeads only)
492
+ *
493
+ * Like createProbeReplyReader it holds anything that still fits the grammar and REPLAYS it
494
+ * verbatim the moment something does not, so a keystroke is delayed, never lost. A plain "[" or
495
+ * "^" is only held while the terminal is reporting the mouse (`plainLeads`), so typing them is
496
+ * not slowed down otherwise.
497
+ */
498
+ const MOUSE_LEADS = ["\x1b[<", "^[[<", "[<"];
499
+ const MOUSE_BODY_DONE = /^(\d+;\d+;\d+[Mm])$/;
500
+ const MOUSE_BODY_PART = /^\d*(?:;\d*){0,2}$/;
501
+ function mouseTextVerdict(text, plainLeads) {
502
+ for (const lead of MOUSE_LEADS) {
503
+ if (!plainLeads && lead !== "\x1b[<") continue;
504
+ if (lead.startsWith(text)) return { kind: "partial" };
505
+ if (!text.startsWith(lead)) continue;
506
+ const body = text.slice(lead.length);
507
+ if (MOUSE_BODY_DONE.test(body)) return { kind: "done", report: body };
508
+ if (body.length <= 20 && MOUSE_BODY_PART.test(body)) return { kind: "partial" };
509
+ }
510
+ return { kind: "bad" };
511
+ }
512
+ export function createMouseReportReader() {
513
+ let held = null; // [str, key] pairs
514
+ const textOf = (str, key) =>
515
+ key?.code === "[<" ? "\x1b[<" : key?.name === "escape" ? "\x1b" : typeof str === "string" ? str : "";
516
+ const startsOne = (str, key, plainLeads) =>
517
+ key?.code === "[<" || key?.name === "escape" || (plainLeads && (str === "[" || str === "^"));
518
+ return {
519
+ holding: () => held !== null,
520
+ /**
521
+ * Feed one keypress. `plainLeads`: the terminal is reporting the mouse right now.
522
+ * { action: "pass" } — not ours; carry on
523
+ * { action: "hold" } — may be a report; arm the flush timer
524
+ * { action: "consume", report } — a whole report, e.g. "35;114;22M"
525
+ * { action: "replay", keys, hold? } — not a report: replay `keys`; when `hold` is
526
+ * set, the last key began a new candidate and
527
+ * is still held
528
+ */
529
+ feed(str, key, { plainLeads = false } = {}) {
530
+ if (!held) {
531
+ if (!startsOne(str, key, plainLeads)) return { action: "pass" };
532
+ held = [[str, key]];
533
+ const v = mouseTextVerdict(textOf(str, key), plainLeads);
534
+ if (v.kind === "partial") return { action: "hold" };
535
+ held = null;
536
+ return { action: "pass" };
537
+ }
538
+ held.push([str, key]);
539
+ const text = held.map(([s, k]) => textOf(s, k)).join("");
540
+ const v = mouseTextVerdict(text, plainLeads);
541
+ if (v.kind === "done") {
542
+ held = null;
543
+ return { action: "consume", report: v.report };
544
+ }
545
+ if (v.kind === "partial") return { action: "hold" };
546
+ // Not a report. If the key that broke it could START one ("[" after "[", an Esc), keep it.
547
+ const last = held.pop();
548
+ const keys = held;
549
+ held = null;
550
+ if (startsOne(last[0], last[1], plainLeads) && mouseTextVerdict(textOf(last[0], last[1]), plainLeads).kind === "partial") {
551
+ held = [last];
552
+ return { action: "replay", keys, hold: true };
553
+ }
554
+ return { action: "replay", keys: [...keys, last] };
555
+ },
556
+ /** Timer fired: hand back whatever is still held. */
557
+ flush() {
558
+ const keys = held ?? [];
559
+ held = null;
560
+ return keys;
561
+ },
562
+ };
563
+ }
564
+
565
+ /** Last line of defence at submit: a report that got past every reader is not a question. */
566
+ export function stripLeakedMouseReports(line) {
567
+ return String(line).replace(/(?:\x1b|\^\[)?\[<\d+;\d+;\d+[Mm]/g, "");
568
+ }
569
+
469
570
  /**
470
571
  * A failed turn's message, as something a person can act on.
471
572
  *
@@ -1152,6 +1253,16 @@ export function syncPrevRowsForResize(rl) {
1152
1253
  // signature — a tool's own line never contains it.
1153
1254
  export const isToolStepSummary = (line) => / \| → /.test(line);
1154
1255
 
1256
+ /**
1257
+ * A WARNING STEP (GH #378). The agent marks one with a leading "⚠" ("⚠ RAG unavailable …").
1258
+ * The terminal shows it as yellow text with NO icon: Windows 11 terminals draw "⚠" as a wide
1259
+ * colour emoji, which broke the row and looked amateur next to the plain marks. The "⚠" stays
1260
+ * in the protocol (it is how a warning is recognised, and the web app shows it); only the
1261
+ * terminal drops it, here.
1262
+ */
1263
+ export const WARN_MARK_RE = /^\s*⚠\uFE0F?\s*/;
1264
+ export const stripWarnMark = (text) => String(text ?? "").replace(WARN_MARK_RE, "");
1265
+
1155
1266
  // Glyph vocabulary for an action line: a FAILURE pops (✗, full-bright), a completed
1156
1267
  // action recedes (✓, dim), anything else is neutral (●, dim). Returns the KIND only —
1157
1268
  // the REPL owns the colours, so this stays pure and testable.
@@ -1201,6 +1312,7 @@ export function isSoftFailCommand(cmd) {
1201
1312
  }
1202
1313
 
1203
1314
  export function bulletKind(text) {
1315
+ if (WARN_MARK_RE.test(String(text ?? ""))) return "warn";
1204
1316
  const mFail = /^Command failed → (.+)$/.exec(String(text ?? "").trim());
1205
1317
  if (mFail && isSoftFailCommand(mFail[1])) return "soft";
1206
1318
  if (/\bfail(ed|s|ure)?\b|\berror(ed)?\b|→\s*(?:HTTP\s*)?[45]\d\d\b/i.test(text)) return "fail";
@@ -2725,7 +2837,7 @@ export function identityRow(
2725
2837
  const where = String(folder ?? "").trim();
2726
2838
  // The mark only means anything beside a named coder: with none shown there is nothing for it
2727
2839
  // to be about, and it would read as a fault in the folder.
2728
- const mark = name && keyUnchosen ? red(" ⚠ key not chosen") : "";
2840
+ const mark = name && keyUnchosen ? red(" key not chosen") : "";
2729
2841
  // API key tail: show the last 4 characters of the API key when available (e.g., "| abcd")
2730
2842
  const keyIndicator = apiKeyTail ? dim(" | ") + dim(apiKeyTail) : "";
2731
2843
  return (name ? orange(name) + mark + keyIndicator + dim(" | ") : "") + cyan(where);
package/theme.mjs CHANGED
@@ -7,8 +7,13 @@
7
7
  * Windows (win32) with no TERM/COLORTERM/WT_SESSION -> ask stdout.getColorDepth()
8
8
  * (conhost on Windows 11 supports 24-bit but advertises nothing).
9
9
  *
10
- * Variants: GU_THEME=dark (default) | light | mono. GU_ASCII=1 swaps the "⏺" glyph for "*"
11
- * (older Windows console fonts lack U+23FA).
10
+ * Variants: GU_THEME=dark (default) | light | mono.
11
+ *
12
+ * NO ICON BEFORE A CARD (GH #378). The header was "⏺ Update(path)"; Windows 11 terminals draw "⏺"
13
+ * as a wide colour emoji, which broke the row and looked amateur. The coloured verb — green
14
+ * Create, yellow Update, red Delete — already says what happened, so the glyph is a space that
15
+ * keeps the text column, on every platform. ("⏺" is still the line protocol from
16
+ * gonext_agent_chat.py: it is how a header is RECOGNISED, never what is drawn.)
12
17
  *
13
18
  * Rules every caller relies on:
14
19
  * - background tiers fill a row by PADDING SPACES (measured with visibleLen), never ESC[K,
@@ -102,7 +107,8 @@ const EMPTY_SIGN = { row: "", sign: "", signOff: "", wordOn: "", wordOff: "" };
102
107
 
103
108
  /** Every escape sequence the edit card uses, for one tier + variant. "" = nothing to emit. */
104
109
  export function makeTheme(tier = "16", variant = "dark", { ascii = false, syntax = false } = {}) {
105
- const glyph = ascii ? "*" : "⏺";
110
+ void ascii; // GU_ASCII used to swap "⏺" for "*"; nothing is drawn now (GH #378)
111
+ const glyph = " ";
106
112
  if (tier === "none") {
107
113
  return {
108
114
  tier, variant, glyph, fill: false, reset: "", lineNo: "", lineNoOff: "", grey: "",