@tiens.nguyen/gu-cli 1.0.810 → 1.0.812

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,75 @@
1
+ /**
2
+ * OPT-IN SYNTAX COLOUR FOR EDIT CARDS (task #30) — enabled only with GU_DIFF_SYNTAX=1, and only
3
+ * on background tiers (truecolor/256). The 16-colour tier draws whole rows in green/red text,
4
+ * so token colours there would erase the add/delete signal; NO_COLOR never gets any.
5
+ *
6
+ * A deliberately small, SINGLE-LINE tokenizer: diff rows arrive one at a time, so a block
7
+ * comment or a multi-line string that started on an earlier row is not tracked. That is
8
+ * accepted: a wrong colour on one row is cheap, a stateful lexer per language is not.
9
+ *
10
+ * highlightSyntax(text, lang) -> [[start, end, role]] with role in keyword|string|comment|number.
11
+ */
12
+
13
+ const JS_KW = "await break case catch class const continue debugger default delete do else export extends false finally for from function if import in instanceof let new null of return static super switch this throw true try typeof undefined var void while with yield async interface type enum implements private protected public readonly as";
14
+ const PY_KW = "and as assert async await break class continue def del elif else except False finally for from global if import in is lambda None nonlocal not or pass raise return self True try while with yield match case";
15
+ const SH_KW = "if then else elif fi for while until do done case esac in function return local export readonly set unset echo exit source";
16
+ const JSON_KW = "true false null";
17
+
18
+ const words = (s) => s.split(/\s+/).join("|");
19
+ const NUM = String.raw`\b(?:0[xX][0-9a-fA-F_]+|\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?)\b`;
20
+ const DQ = String.raw`"(?:[^"\\]|\\.)*"?`;
21
+ const SQ = String.raw`'(?:[^'\\]|\\.)*'?`;
22
+ const BQ = String.raw`\x60(?:[^\x60\\]|\\.)*\x60?`;
23
+
24
+ // Each grammar: ordered [role, source] pairs, joined into one alternation (earliest match wins,
25
+ // so a "#" inside a string stays part of the string).
26
+ const GRAMMARS = {
27
+ js: [["comment", String.raw`\/\/.*|\/\*.*?(?:\*\/|$)`], ["string", `${DQ}|${SQ}|${BQ}`], ["number", NUM], ["keyword", String.raw`\b(?:${words(JS_KW)})\b`]],
28
+ py: [["comment", "#.*"], ["string", `[rRbBfFuU]{0,2}(?:${DQ}|${SQ})`], ["number", NUM], ["keyword", String.raw`\b(?:${words(PY_KW)})\b`]],
29
+ sh: [["comment", String.raw`(?:^|(?<=\s))#.*`], ["string", `${DQ}|${SQ}`], ["number", NUM], ["keyword", String.raw`\b(?:${words(SH_KW)})\b`]],
30
+ json: [["string", DQ], ["number", String.raw`-?${NUM}`], ["keyword", String.raw`\b(?:${words(JSON_KW)})\b`]],
31
+ md: [["keyword", String.raw`^\s{0,3}#{1,6}\s.*`], ["string", String.raw`\x60[^\x60]*\x60?`], ["comment", String.raw`^\s*>.*`]],
32
+ };
33
+
34
+ const COMPILED = Object.fromEntries(
35
+ Object.entries(GRAMMARS).map(([lang, g]) => [
36
+ lang,
37
+ { roles: g.map(([r]) => r), re: new RegExp(g.map(([, s]) => `(${s})`).join("|"), "g") },
38
+ ])
39
+ );
40
+
41
+ const EXT = {
42
+ js: "js", mjs: "js", cjs: "js", jsx: "js", ts: "js", tsx: "js", mts: "js", cts: "js",
43
+ py: "py", pyw: "py",
44
+ json: "json", jsonc: "json",
45
+ sh: "sh", bash: "sh", zsh: "sh",
46
+ md: "md", markdown: "md",
47
+ };
48
+
49
+ /** "src/app.ts" -> "js"; unknown extensions -> null (no syntax colour). */
50
+ export function langFromPath(path) {
51
+ const m = /\.([A-Za-z0-9]+)\s*$/.exec(String(path ?? "").replace(/\r/g, ""));
52
+ return m ? EXT[m[1].toLowerCase()] || null : null;
53
+ }
54
+
55
+ export const MAX_SYNTAX_LINE = 500;
56
+
57
+ export function highlightSyntax(text, lang) {
58
+ const g = COMPILED[lang];
59
+ const s = String(text ?? "");
60
+ if (!g || !s || s.length > MAX_SYNTAX_LINE) return [];
61
+ const out = [];
62
+ g.re.lastIndex = 0;
63
+ let m;
64
+ while ((m = g.re.exec(s))) {
65
+ if (!m[0]) { g.re.lastIndex++; continue; }
66
+ const k = m.slice(1).findIndex((x) => x !== undefined);
67
+ out.push([m.index, m.index + m[0].length, g.roles[k]]);
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /** true when the env asks for syntax colour (GU_DIFF_SYNTAX=1|true|yes|on). */
73
+ export function syntaxEnabled(env = process.env) {
74
+ return /^(1|true|yes|on)$/i.test(String(env.GU_DIFF_SYNTAX ?? "").trim());
75
+ }
@@ -0,0 +1,334 @@
1
+ /**
2
+ * EDIT CARD RENDERER (task #29) — pure, no I/O. Turns the line protocol written by
3
+ * _render_edit_card (gonext_agent_chat.py) into coloured terminal rows:
4
+ *
5
+ * ⏺ Update(src/app.js) <- coloured verb, bold path
6
+ * └ Replaced lines 98-100 with 3 lines · +3 -3 <- grey summary, green/red counts
7
+ * 98 - old text <- line-number gutter (padded by Python),
8
+ * 99 + new text tinted row background, changed words brighter
9
+ * … (+3 more) <- grey
10
+ *
11
+ * Keep the shapes in sync with _render_edit_card. Robustness rules:
12
+ * - a trailing "\r" (Windows CRLF pipes) is ignored, and "\r" inside a row's text (CRLF
13
+ * source files) is dropped, so it cannot move the cursor back over the row;
14
+ * - the card ENDS on the first line that cannot belong to it: a blank line, a second
15
+ * "└" summary, a diff row whose number width differs from the card's, a "-" row after
16
+ * "+" rows, or a line number that does not increase. This stops normal output such as
17
+ * " 3 + 4 = 7" right after a card being painted as a diff row.
18
+ */
19
+ import { visibleLen, wrapDiffText } from "./terminal-layout.mjs";
20
+ import { highlightSyntax, langFromPath } from "./diff-syntax.mjs";
21
+
22
+ export const EDIT_CARD_ACTIONS = ["Update", "Create", "Delete", "Write"];
23
+ const HEADER_RE = /^⏺ (Update|Create|Delete|Write)\((.+)\)/;
24
+ /** " 98 - text": 4-space indent, a (space-padded) line number, the sign, the text. */
25
+ export const EDIT_DIFF_RE = /^(\s{4})( *\d+) ([-+]) (.*)$/;
26
+ const SUMMARY_RE = /^(\s*)└ (.*)$/;
27
+ const MORE_RE = /^\s{4}… /;
28
+
29
+ // Every CR goes: a trailing one (Windows pipes) and inner ones (CRLF source files) alike —
30
+ // `.` in EDIT_DIFF_RE does not match "\r", and a drawn CR jumps the cursor to column 0.
31
+ const clean = (line) => String(line ?? "").replace(/\r/g, "");
32
+
33
+ export const isEditCardHeader = (line) => HEADER_RE.test(clean(line));
34
+ export const isEditCardBody = (line) => {
35
+ const c = clean(line);
36
+ return SUMMARY_RE.test(c) || EDIT_DIFF_RE.test(c) || MORE_RE.test(c);
37
+ };
38
+
39
+ // ---------------------------------------------------------------- word-level diff
40
+
41
+ /** Words, runs of whitespace, and single punctuation marks. */
42
+ export function tokenize(s) {
43
+ return String(s ?? "").match(/[\p{L}\p{N}_]+|\s+|[^\p{L}\p{N}_\s]/gu) || [];
44
+ }
45
+
46
+ const MAX_LINE = 500;
47
+ const MAX_CELLS = 40000;
48
+
49
+ /**
50
+ * Changed character ranges ([start, end) in JS string indices) of `a` vs `b`, by a token LCS.
51
+ * Returns null when skipped (lines too long). Returns empty ranges when the two lines share no
52
+ * real token — highlighting a whole line inside an already-tinted row only adds noise.
53
+ */
54
+ export function wordDiff(a, b) {
55
+ a = String(a ?? "");
56
+ b = String(b ?? "");
57
+ if (a.length > MAX_LINE || b.length > MAX_LINE) return null;
58
+ const ta = tokenize(a);
59
+ const tb = tokenize(b);
60
+ const n = ta.length;
61
+ const m = tb.length;
62
+ if (n * m > MAX_CELLS) return null;
63
+ const dp = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1));
64
+ for (let i = n - 1; i >= 0; i--) {
65
+ for (let j = m - 1; j >= 0; j--) {
66
+ dp[i][j] = ta[i] === tb[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
67
+ }
68
+ }
69
+ const keepA = new Array(n).fill(false);
70
+ const keepB = new Array(m).fill(false);
71
+ let i = 0;
72
+ let j = 0;
73
+ while (i < n && j < m) {
74
+ if (ta[i] === tb[j]) { keepA[i] = keepB[j] = true; i++; j++; }
75
+ else if (dp[i + 1][j] >= dp[i][j + 1]) i++;
76
+ else j++;
77
+ }
78
+ const sharesWord = ta.some((t, k) => keepA[k] && /\S/.test(t));
79
+ if (!sharesWord) return { a: [], b: [] };
80
+ return { a: changedRanges(ta, keepA), b: changedRanges(tb, keepB) };
81
+ }
82
+
83
+ function changedRanges(tokens, keep) {
84
+ const out = [];
85
+ let pos = 0;
86
+ for (let k = 0; k < tokens.length; k++) {
87
+ const len = tokens[k].length;
88
+ if (!keep[k]) {
89
+ const last = out[out.length - 1];
90
+ if (last && last[1] === pos) last[1] = pos + len;
91
+ else out.push([pos, pos + len]);
92
+ }
93
+ pos += len;
94
+ }
95
+ return out;
96
+ }
97
+
98
+ /**
99
+ * Word highlights (a BACKGROUND, on/off) and syntax spans (a FOREGROUND per span, [s, e, sgr],
100
+ * ended with fgOff) over one piece of a row. Both use positions in the row's full text.
101
+ */
102
+ function styleText(text, offset, ranges, on, off, spans = null, fgOff = "\x1b[39m") {
103
+ const useWords = !!(ranges && ranges.length && on);
104
+ const useSyntax = !!(spans && spans.length);
105
+ if (!useWords && !useSyntax) return text;
106
+ let out = "";
107
+ let inWord = false;
108
+ let curFg = "";
109
+ for (let k = 0; k < text.length; k++) {
110
+ const p = offset + k;
111
+ if (useWords) {
112
+ const w = ranges.some(([s, e]) => p >= s && p < e);
113
+ if (w !== inWord) { out += w ? on : off; inWord = w; }
114
+ }
115
+ if (useSyntax) {
116
+ const sp = spans.find(([s, e]) => p >= s && p < e);
117
+ const f = sp ? sp[2] : "";
118
+ if (f !== curFg) { out += f || fgOff; curFg = f; }
119
+ }
120
+ out += text[k];
121
+ }
122
+ if (curFg) out += fgOff;
123
+ if (inWord) out += off;
124
+ return out;
125
+ }
126
+
127
+ // ---------------------------------------------------------------- rows
128
+
129
+ /**
130
+ * One diff row → one or more physical rows, none wider than width-1 columns. Continuation
131
+ * rows hang under the text column; with a background tier every row is padded to width-1
132
+ * in the row colour and ends with a full reset.
133
+ */
134
+ export function renderDiffRow(row, { theme, gutter = "", width = 80, words = null, lang = null } = {}) {
135
+ const t = theme;
136
+ const W = Math.max(1, Math.floor(width) || 80);
137
+ const prefixW = visibleLen(gutter) + row.indent.length + row.no.length + 1 + 2;
138
+ const room = Math.max(2, W - 1 - prefixW);
139
+ const text = String(row.text ?? "").replace(/\r/g, "").replace(/\t/g, " ");
140
+ const pieces = wrapDiffText(text, room);
141
+ let cursor = 0;
142
+ const offsets = pieces.map((p) => {
143
+ const at = p ? text.indexOf(p, cursor) : cursor;
144
+ const o = at < 0 ? cursor : at;
145
+ cursor = o + p.length;
146
+ return o;
147
+ });
148
+ const s = row.sign === "+" ? t.add : t.del;
149
+ // Opt-in syntax colour (task #30): only when the theme carries a syntax palette.
150
+ const spans = t.syntax && lang
151
+ ? highlightSyntax(text, lang).map(([a, b, role]) => [a, b, t.syntax[role] || ""]).filter((x) => x[2])
152
+ : null;
153
+ return pieces.map((p, k) => {
154
+ const lead = k === 0 ? `${t.lineNo}${row.no}${t.lineNoOff}` : " ".repeat(row.no.length);
155
+ const sign = k === 0 ? `${s.sign}${row.sign}${s.signOff}` : " ";
156
+ const body = styleText(p, offsets[k], words, s.wordOn, s.wordOff, spans, t.fgOff);
157
+ const pad = t.fill ? " ".repeat(Math.max(0, W - 1 - prefixW - visibleLen(p))) : "";
158
+ return `${gutter}${row.indent}${lead} ${s.row}${sign} ${body}${pad}${t.reset}`;
159
+ });
160
+ }
161
+
162
+ export function renderHeader(action, path, theme) {
163
+ const t = theme;
164
+ return `${t.verb[action] || ""}${action}${t.verb[action] ? t.fgOff : ""}(${t.bold}${path}${t.boldOff})`;
165
+ }
166
+
167
+ /** Cut plain text to `room` visible columns, ending with "…" when it was cut (task #31 / B6). */
168
+ export function fitText(s, room) {
169
+ s = String(s ?? "");
170
+ if (visibleLen(s) <= room) return s;
171
+ if (room <= 1) return room === 1 ? "…" : "";
172
+ let out = "";
173
+ for (const ch of s) {
174
+ if (visibleLen(out + ch) > room - 1) break;
175
+ out += ch;
176
+ }
177
+ return out + "…";
178
+ }
179
+
180
+ export function renderSummary(line, { theme, gutter = "", width = Infinity } = {}) {
181
+ const t = theme;
182
+ const m = SUMMARY_RE.exec(clean(line));
183
+ if (!m) return gutter + clean(line);
184
+ const [, lead, body] = m;
185
+ let main = body;
186
+ let tail = "";
187
+ const k = body.lastIndexOf(" · ");
188
+ if (k >= 0) {
189
+ const cand = body.slice(k + 3).trim();
190
+ if (cand && /^(\+\d+)?\s?(-\d+)?$/.test(cand)) { main = body.slice(0, k); tail = cand; }
191
+ }
192
+ // Never wider than width-1: shorten the text first, drop the counts only if even they don't fit.
193
+ const room = Math.floor(width) - 1 - visibleLen(gutter) - lead.length - 2;
194
+ if (Number.isFinite(room)) {
195
+ if (tail && room - (3 + tail.length) < 4) tail = "";
196
+ main = fitText(main, Math.max(1, room - (tail ? 3 + tail.length : 0)));
197
+ }
198
+ let out = `${gutter}${lead}${t.grey}└ ${main}${t.fgOff}`;
199
+ if (tail) {
200
+ const parts = tail.split(/\s+/).map((p) =>
201
+ p.startsWith("+") ? `${t.countAdd}${p}${t.fgOff}` : `${t.countDel}${p}${t.fgOff}`
202
+ );
203
+ out += `${t.grey} · ${t.fgOff}${parts.join(" ")}`;
204
+ }
205
+ return out;
206
+ }
207
+
208
+ export function renderMore(line, { theme, gutter = "", width = Infinity } = {}) {
209
+ const room = Math.floor(width) - 1 - visibleLen(gutter);
210
+ const c = Number.isFinite(room) ? fitText(clean(line), Math.max(1, room)) : clean(line);
211
+ return `${gutter}${theme.grey}${c}${theme.fgOff}`;
212
+ }
213
+
214
+ // ---------------------------------------------------------------- streaming state
215
+
216
+ const MAX_PAIR_RUN = 50;
217
+
218
+ /**
219
+ * The card as it streams in, one line at a time. Diff rows are BUFFERED (so each "-" run can
220
+ * be paired with its "+" run for word highlights) until flush(), the card's end, or the end of
221
+ * the current chunk — the caller flushes at chunk end, so nothing waits for a newline that is
222
+ * never coming.
223
+ */
224
+ export class EditCardStream {
225
+ constructor({ theme, gutter = "", width = () => 80 } = {}) {
226
+ this.theme = theme;
227
+ this.gutter = gutter;
228
+ this.width = typeof width === "function" ? width : () => width;
229
+ this.rows = [];
230
+ this.pending = [];
231
+ this.lastSign = null;
232
+ this.lastNo = { "-": -Infinity, "+": -Infinity };
233
+ this.noWidth = null;
234
+ this.sawSummary = false;
235
+ this.sawDiff = false;
236
+ this.ended = false;
237
+ this.lang = null; // set by header() from the file extension (syntax colour, task #30)
238
+ }
239
+
240
+ /** The header text after the glyph (coloured verb + bold path). */
241
+ header(line) {
242
+ const c = clean(line);
243
+ const m = HEADER_RE.exec(c);
244
+ if (m) this.lang = langFromPath(m[2]);
245
+ return m ? renderHeader(m[1], m[2], this.theme) + c.slice(m[0].length) : c.slice(1).trim();
246
+ }
247
+
248
+ /** true = the line belonged to the card; false = the card has ENDED (render the line normally). */
249
+ accept(line) {
250
+ if (this.ended) return false;
251
+ const c = clean(line);
252
+ if (SUMMARY_RE.test(c)) {
253
+ if (this.sawSummary || this.sawDiff) return this.end();
254
+ this.sawSummary = true;
255
+ this.rows.push(renderSummary(c, { theme: this.theme, gutter: this.gutter, width: this.width() }));
256
+ return true;
257
+ }
258
+ const m = EDIT_DIFF_RE.exec(c);
259
+ if (m) {
260
+ const no = parseInt(m[2], 10);
261
+ const sign = m[3];
262
+ if (this.noWidth != null && m[2].length !== this.noWidth) return this.end();
263
+ if (this.lastSign === "+" && sign === "-") return this.end();
264
+ if (no <= this.lastNo[sign]) return this.end();
265
+ this.noWidth = m[2].length;
266
+ this.lastSign = sign;
267
+ this.lastNo[sign] = no;
268
+ this.sawDiff = true;
269
+ this.pending.push({ kind: "diff", indent: m[1], no: m[2], sign, text: m[4].replace(/\r/g, "") });
270
+ return true;
271
+ }
272
+ if (MORE_RE.test(c) && (this.sawDiff || this.sawSummary)) {
273
+ this.pending.push({ kind: "more", line: c });
274
+ return true;
275
+ }
276
+ return this.end();
277
+ }
278
+
279
+ end() {
280
+ this.ended = true;
281
+ this.flushPending();
282
+ return false;
283
+ }
284
+
285
+ flushPending() {
286
+ if (!this.pending.length) return;
287
+ const dels = this.pending.filter((r) => r.kind === "diff" && r.sign === "-");
288
+ const adds = this.pending.filter((r) => r.kind === "diff" && r.sign === "+");
289
+ const words = new Map();
290
+ if (dels.length && adds.length && dels.length <= MAX_PAIR_RUN && adds.length <= MAX_PAIR_RUN) {
291
+ for (let i = 0; i < Math.min(dels.length, adds.length); i++) {
292
+ const d = wordDiff(dels[i].text, adds[i].text);
293
+ if (d) { words.set(dels[i], d.a); words.set(adds[i], d.b); }
294
+ }
295
+ }
296
+ const opts = { theme: this.theme, gutter: this.gutter, width: this.width(), lang: this.lang };
297
+ for (const r of this.pending) {
298
+ if (r.kind === "more") this.rows.push(renderMore(r.line, opts));
299
+ else this.rows.push(...renderDiffRow(r, { ...opts, words: words.get(r) || null }));
300
+ }
301
+ this.pending = [];
302
+ }
303
+
304
+ /** Rows ready to print (buffered diff rows stay buffered). */
305
+ drain() {
306
+ const r = this.rows;
307
+ this.rows = [];
308
+ return r;
309
+ }
310
+
311
+ /** Everything, including buffered diff rows. */
312
+ flush() {
313
+ this.flushPending();
314
+ return this.drain();
315
+ }
316
+ }
317
+
318
+ /**
319
+ * A whole card → rows (header first). For tests and golden screens; gu-repl streams the same
320
+ * thing line by line through EditCardStream.
321
+ */
322
+ export function renderEditCard(lines, { theme, width = 80, gutter = "", glyph = (g) => g } = {}) {
323
+ const list = Array.isArray(lines) ? lines : String(lines).split("\n");
324
+ if (!list.length || !isEditCardHeader(list[0])) return list.map(clean);
325
+ const card = new EditCardStream({ theme, gutter, width });
326
+ const out = [`${gutter}${glyph(theme.glyph)} ${card.header(list[0])}`];
327
+ let i = 1;
328
+ for (; i < list.length; i++) {
329
+ if (!card.accept(list[i])) break;
330
+ }
331
+ out.push(...card.flush());
332
+ out.push(...list.slice(i).map(clean));
333
+ return out;
334
+ }
@@ -53,6 +53,11 @@ import urllib.error
53
53
  for _stream in (sys.stdin, sys.stdout, sys.stderr):
54
54
  with contextlib.suppress(Exception): # already-UTF-8, or replaced by a non-TextIOWrapper
55
55
  _stream.reconfigure(encoding="utf-8", errors="replace")
56
+ # Windows: a text stream turns "\n" into "\r\n" on write, and the REPL's line protocols
57
+ # (edit cards, NDJSON) are parsed per "\n" — a stray "\r" broke the diff-row regex (task #29).
58
+ for _stream in (sys.stdout, sys.stderr):
59
+ with contextlib.suppress(Exception):
60
+ _stream.reconfigure(newline="\n")
56
61
 
57
62
  # Capture stdout before anything can redirect it. _emit() must always write
58
63
  # to the real fd-1 so the Node worker's readline loop sees NDJSON even while
@@ -6853,9 +6858,11 @@ def _render_edit_card(action: str, path: str, summary: str,
6853
6858
  max_removed: int = 8, max_added: int = 12) -> str:
6854
6859
  """Multi-line terminal-friendly summary of a file edit, emitted as ONE step event.
6855
6860
 
6856
- The line shapes are a small stable protocol the REPL colorizes (bold header,
6857
- red '-' lines, green '+' lines, dim line numbers) — keep them in sync with the
6858
- isEditCardHeader/EDIT_DIFF_RE regexes in gu-repl.mjs. The web thinking panel
6861
+ The line shapes are a small stable protocol the REPL colorizes (coloured verb,
6862
+ bold path, green/red tinted rows, grey line numbers) — keep them in sync with
6863
+ isEditCardHeader/EDIT_DIFF_RE in edit-card-render.mjs (task #29). Line numbers are
6864
+ right-aligned to ONE width per card so the sign column lines up; the summary ends
6865
+ with " · +A -R" counts that the REPL colours. The web thinking panel
6859
6866
  shows the same text as-is (the 4-space indent renders as a monospace block in
6860
6867
  markdown), so no web change is needed.
6861
6868
 
@@ -6865,7 +6872,14 @@ def _render_edit_card(action: str, path: str, summary: str,
6865
6872
  19 + ...new text...
6866
6873
  """
6867
6874
  import os
6868
- lines = [f"⏺ {action}({os.path.basename(path)})", f" └ {summary}"]
6875
+ removed = list(removed or [])
6876
+ added = list(added or [])
6877
+ counts = " ".join(c for c in (f"+{len(added)}" if added else "",
6878
+ f"-{len(removed)}" if removed else "") if c)
6879
+ head = f" └ {summary}" + (f" · {counts}" if counts else "")
6880
+ lines = [f"⏺ {action}({os.path.basename(path)})", head]
6881
+ shown = removed[:max_removed] + added[:max_added]
6882
+ width = max((len(str(no)) for no, _ in shown), default=1)
6869
6883
 
6870
6884
  def _fmt(entries, sign, cap):
6871
6885
  out = []
@@ -6878,7 +6892,8 @@ def _render_edit_card(action: str, path: str, summary: str,
6878
6892
  t = txt.rstrip()
6879
6893
  if len(t) > 160:
6880
6894
  t = t[:159] + "…"
6881
- out.append(f" {no} {sign} {t}")
6895
+ t = t.replace("\r", "") # CRLF sources: a bare CR would jump the cursor
6896
+ out.append(f" {str(no).rjust(width)} {sign} {t}")
6882
6897
  return out
6883
6898
 
6884
6899
  if removed:
@@ -12789,7 +12804,7 @@ def run_agent_chat(cfg):
12789
12804
  _RAG_STATE["s"] = "fallback"
12790
12805
  _emit({"type": "step",
12791
12806
  "text": f"⚠ RAG unavailable ({why}) → falling back to keyword search"})
12792
- _log(f"RAG fallback: {why} — answering {query[:80]!r} with a lexical search")
12807
+ _log(f"RAG fallback: {why} — answering {_c(query, 80)} with a lexical search")
12793
12808
  try:
12794
12809
  root = _rag_read_allowed(_default_ws_root())
12795
12810
  res = _lexical_fallback_search(
package/gu-cli.mjs CHANGED
@@ -14,6 +14,7 @@
14
14
  import { requireSupportedNode } from "./node-version.mjs";
15
15
  // The brand accent, shared with the REPL — see accent.mjs.
16
16
  import { accent } from "./accent.mjs";
17
+ import { paint } from "./theme.mjs";
17
18
  requireSupportedNode();
18
19
  import { mkdir, mkdtemp, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
19
20
  import { execFile as execFileCallback, spawn } from "node:child_process";
@@ -1398,8 +1399,8 @@ if (
1398
1399
  // resolver. That precise shape has crashed this CLI before.
1399
1400
  const bold = (t) => `\x1b[1m${t}\x1b[0m`;
1400
1401
  const cyan = (t) => accent(t);
1401
- const red = (t) => `\x1b[31m${t}\x1b[0m`;
1402
- const dim = (t) => `\x1b[2m${t}\x1b[0m`;
1402
+ const red = paint(31); // honours NO_COLOR like accent (task #29 B1)
1403
+ const dim = paint(2);
1403
1404
  console.log("\n" + bold("proxy") + dim(" — can this machine route model calls through a proxy?\n"));
1404
1405
  const { ok, checks } = await runProxySelfTest({
1405
1406
  onStep: ({ title, ok: good, detail }) =>
@@ -1429,8 +1430,8 @@ if (args[0] === "rag" && (args.includes("--test") || args[1] === "test")) {
1429
1430
  // crashed this CLI before.
1430
1431
  const bold = (t) => `\x1b[1m${t}\x1b[0m`;
1431
1432
  const cyan = (t) => accent(t);
1432
- const red = (t) => `\x1b[31m${t}\x1b[0m`;
1433
- const dim = (t) => `\x1b[2m${t}\x1b[0m`;
1433
+ const red = paint(31); // honours NO_COLOR like accent (task #29 B1)
1434
+ const dim = paint(2);
1434
1435
  console.log("\n" + bold("rag") + dim(" — can this machine embed and retrieve?\n"));
1435
1436
  const { ok, checks } = await runRagSelfTest({
1436
1437
  onStep: ({ title, ok: good, detail }) =>
package/gu-repl.mjs CHANGED
@@ -175,6 +175,9 @@ import { ACCEPTED_EMBED_MODELS, REQUIRED_AGENT_MODEL, REQUIRED_AGENT_PREFIX } fr
175
175
  // plain SGR code: terminals disagree about colour, so accent.mjs picks 24-bit, 256 or the
176
176
  // sixteen-colour magenta from what this one actually claims to support.
177
177
  import { accent } from "./accent.mjs";
178
+ // Task #29: colour tiers that honour NO_COLOR (paint) and the colourful edit-card renderer.
179
+ import { paint, CARD_THEME } from "./theme.mjs";
180
+ import { EditCardStream, isEditCardHeader } from "./edit-card-render.mjs";
178
181
  // Task #240: what a failed agent-payload call means, and when a rejected key may be recovered
179
182
  // from in place instead of ending the session.
180
183
  import { payloadError, recoveryPlan } from "./credential-recovery.mjs";
@@ -216,10 +219,12 @@ const ENV_FILE = join(homedir(), ".gonext", "worker.env");
216
219
  const WS_FILE = join(homedir(), ".gonext", "workspaces.json");
217
220
  const SESSIONS_DIR = join(homedir(), ".gonext", "sessions");
218
221
 
219
- const dim = (s) => `\x1b[2m${s}\x1b[0m`;
220
- const red = (s) => `\x1b[31m${s}\x1b[0m`;
221
- const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
222
- const white = (s) => `\x1b[37m${s}\x1b[0m`;
222
+ // Through paint() so NO_COLOR / GU_ACCENT=off / TERM=dumb turn ALL of them off, not only the
223
+ // accent (task #29 bug B1 — red '-' rows stayed red under NO_COLOR).
224
+ const dim = paint(2);
225
+ const red = paint(31);
226
+ const yellow = paint(33);
227
+ const white = paint(37);
223
228
  // Left gutter so terminal output doesn't hug the edge (task #128, Phase 1). A terminal has
224
229
  // no CSS padding — a "left margin" is just a space prefix on every printed line, applied
225
230
  // consistently at the prompt, the ● bullets, plain replies, the banner and the live status
@@ -397,8 +402,8 @@ const LINE_SPACING = "\n";
397
402
  const STREAM_PLAIN_REPLY = false;
398
403
  // ANSI 90 ("bright black") — a reliable neutral grey across terminal themes, unlike
399
404
  // code 34 (blue) which many dark-theme palettes render as purple/indigo instead.
400
- const codeColor = (s) => `\x1b[90m${s}\x1b[0m`;
401
- const bold = (s) => `\x1b[1m${s}\x1b[0m`;
405
+ const codeColor = paint(90);
406
+ const bold = paint(1);
402
407
  // Hover highlight for the clickable thinking line (task #96): just shift the color a little
403
408
  // toward white (no bold/underline/anchor) so it subtly signals "hovered" without looking like
404
409
  // a link. 97 = bright white.
@@ -434,19 +439,12 @@ const isRoutingLine = (line) =>
434
439
  /^(Routing your request…|Choosing a tool…|Composing a reply…|→ Agent mode.*|→ Chat reply)$/.test(
435
440
  line.trim()
436
441
  );
437
- // The edit tools (edit_lines/edit_file/create_file) emit a multi-line "edit card"
438
- // as one step event — a small stable line protocol rendered here as a colored diff
439
- // (bold header, red '-' / cyan '+' lines, dim line numbers). Shapes must stay in
440
- // sync with _render_edit_card in gonext_agent_chat.py:
441
- // ⏺ Update(XeroForwardService.java)
442
- // └ Replaced lines 19-19 with 1 line
443
- // 19 - ...old...
444
- // 19 + ...new...
445
- // … (+3 more)
446
- const isEditCardHeader = (line) => /^⏺ (Update|Create)\(.+\)/.test(line);
447
- const EDIT_DIFF_RE = /^(\s{4})(\d+) ([-+]) (.*)$/;
448
- const isEditCardBody = (line) =>
449
- /^\s*└ /.test(line) || EDIT_DIFF_RE.test(line) || /^\s{4}… /.test(line);
442
+ // The edit tools (edit_lines/edit_file/create_file) emit a multi-line "edit card" as one
443
+ // step event — a small stable line protocol written by _render_edit_card in
444
+ // gonext_agent_chat.py and parsed/coloured by edit-card-render.mjs (task #29): coloured
445
+ // verb + bold path, grey summary with green/red counts, a padded grey line-number gutter,
446
+ // green/red tinted rows with brighter changed words (plain green/red text on 16 colours,
447
+ // no colour under NO_COLOR).
450
448
 
451
449
  /**
452
450
  * STILL MODE — no animated status block at all (GONEXT_STILL=1).
@@ -972,7 +970,7 @@ function currentSkills() {
972
970
  }
973
971
  const skillCommands = () =>
974
972
  currentSkills()
975
- .filter((s) => s.slash)
973
+ .filter((s) => s.slash && !s.hidden) // hidden = internal skill: still typeable, not offered (#118)
976
974
  .map((s) => ({ name: `/${s.name}`, usage: `/${s.name} [request]`, desc: `skill — ${firstSentence(s.description)}`, skill: true }));
977
975
  /** A skill's description is written for the MODEL and often runs a paragraph; /help shows one line. */
978
976
  function firstSentence(text, max = 100) {
@@ -7887,14 +7885,15 @@ async function refreshAfterPresetApply(value, codingDefault, result) {
7887
7885
  defaultCodingUrl = codingDefault.url;
7888
7886
  }
7889
7887
  // What the API now answers with is authoritative — read it back rather than assume.
7890
- const probe = await fetchAgentPayload([{ role: "user", content: "ping" }]).catch(() => null);
7891
- const p = probe?.payload ?? {};
7888
+ // A mid-session refresh, NOT the startup probe: no sign-in offer here (that is probeWithRecovery's).
7889
+ const refreshed = await fetchAgentPayload([{ role: "user", content: "ping" }]).catch(() => null);
7890
+ const p = refreshed?.payload ?? {};
7892
7891
  seedDefaultCoder(p);
7893
7892
  // codingChoices lives on the envelope, never inside payload.
7894
- activeCoderWhere = whereOfActiveCoder(probe?.codingChoices, sessionCodingModel);
7893
+ activeCoderWhere = whereOfActiveCoder(refreshed?.codingChoices, sessionCodingModel);
7895
7894
  if (value?.agentModel?.name || p.agentMissing === false) agentMissing = false;
7896
7895
  else if (p.agentMissing === true) agentMissing = true;
7897
- if (probe?.payload) noModelAtAll = nothingToAnswerWith(p);
7896
+ if (refreshed?.payload) noModelAtAll = nothingToAnswerWith(p);
7898
7897
  await syncEmbedWarning({ repair: true });
7899
7898
  if (barPinned()) paintBar();
7900
7899
  } catch {
@@ -11820,7 +11819,10 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11820
11819
  // them prints in a distinct grey (not dim, so it stands out from "Thought:" prose);
11821
11820
  // blank lines keep spacing; everything else commits dim to scrollback.
11822
11821
  let inCode = false;
11823
- let inEditCard = false; // inside a colored edit-card block (see isEditCardHeader)
11822
+ let editCard = null; // the EditCardStream of the card being printed (see isEditCardHeader)
11823
+ const writeCardRows = (rows) => {
11824
+ for (const r of rows) process.stdout.write(r + "\n");
11825
+ };
11824
11826
  // The worker wraps the raw model token stream (Thought prose + code) in ~~~ fences;
11825
11827
  // the concrete ACTION events (tool steps, edit cards) are emitted OUTSIDE them. We
11826
11828
  // suppress everything IN a fence — the rambling Thought and redundant raw code (the
@@ -12037,36 +12039,30 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12037
12039
  // header is distinctive enough to never collide with generated Python.
12038
12040
  if (isEditCardHeader(line)) {
12039
12041
  onContent();
12040
- inEditCard = true;
12042
+ if (editCard) writeCardRows(editCard.flush()); // back-to-back cards
12043
+ editCard = new EditCardStream({
12044
+ theme: CARD_THEME,
12045
+ gutter: GUTTER,
12046
+ width: () => Math.max(20, process.stdout.columns || 80),
12047
+ });
12041
12048
  openBullet();
12042
- process.stdout.write(markLine(accent("⏺")) + bold(line.slice(1).replace(/^\s+/, "")) + "\n");
12049
+ process.stdout.write(markLine(accent(CARD_THEME.glyph)) + editCard.header(line) + "\n");
12043
12050
  lastWasBlank = false;
12044
12051
  lastContentAt = Date.now();
12045
12052
  continue;
12046
12053
  }
12047
- if (inEditCard) {
12048
- if (isEditCardBody(line)) {
12054
+ if (editCard) {
12055
+ // Diff rows are buffered inside the card (so a '-' run can be paired with its '+'
12056
+ // run for word highlights) and flushed at the card's end or this chunk's end.
12057
+ if (editCard.accept(line)) {
12049
12058
  onContent();
12050
- const m = EDIT_DIFF_RE.exec(line);
12051
- if (m) {
12052
- // " 19 - old text" → dim line number, red/cyan signed content.
12053
- const paintDiff = m[3] === "+" ? accent : red;
12054
- // Wrap to the terminal width: continuation rows hang under the text column
12055
- // (no line number, blank sign) instead of soft-wrapping back to column 0.
12056
- const room = Math.max(8, Math.max(20, process.stdout.columns || 80) - 1 - visibleLen(GUTTER) - m[1].length - m[2].length - 1 - 2);
12057
- const pieces = wrapDiffText(m[4], room);
12058
- process.stdout.write(`${GUTTER}${m[1]}${dim(m[2])} ${paintDiff(`${m[3]} ${pieces[0]}`)}\n`);
12059
- for (const p of pieces.slice(1)) {
12060
- process.stdout.write(`${GUTTER}${m[1]}${" ".repeat(m[2].length)} ${paintDiff(` ${p}`)}\n`);
12061
- }
12062
- } else {
12063
- process.stdout.write(GUTTER + dim(line) + "\n"); // "└ summary" / "… (+N more)"
12064
- }
12059
+ writeCardRows(editCard.drain());
12065
12060
  lastWasBlank = false;
12066
12061
  lastContentAt = Date.now();
12067
12062
  continue;
12068
12063
  }
12069
- inEditCard = false; // card ended — fall through to normal handling
12064
+ writeCardRows(editCard.flush()); // card ended — fall through to normal handling
12065
+ editCard = null;
12070
12066
  }
12071
12067
  {
12072
12068
  const t = line.trim();
@@ -12212,6 +12208,8 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12212
12208
  }
12213
12209
  lastWasBlank = false;
12214
12210
  }
12211
+ // A card's buffered diff rows must not wait for a line that may never come.
12212
+ if (editCard) writeCardRows(editCard.flush());
12215
12213
  // Plain-reply answers often have NO internal newline at all (a short one-sentence
12216
12214
  // reply) and can take many seconds to generate — without this, nothing would print
12217
12215
  // until the whole thing finally lands, right back to the "wait then dump" problem
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.810",
3
+ "version": "1.0.812",
4
4
  "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -51,6 +51,9 @@
51
51
  },
52
52
  "files": [
53
53
  "accent.mjs",
54
+ "edit-card-render.mjs",
55
+ "diff-syntax.mjs",
56
+ "theme.mjs",
54
57
  "gu-cli.mjs",
55
58
  "gu-repl.mjs",
56
59
  "step-tokens.mjs",
package/skills.mjs CHANGED
@@ -138,6 +138,12 @@ function scanDir(dir, scope) {
138
138
  return { skills, warnings };
139
139
  }
140
140
 
141
+ /**
142
+ * Internal skills the agent loads itself through use_skill. They are NOT offered in the `/` menu,
143
+ * Tab-completion or /help (hidden), but typing /<name> still works (issue #118).
144
+ */
145
+ export const HIDDEN_SKILLS = ["gu-session-memory-orchestration"];
146
+
141
147
  /**
142
148
  * Every skill that exists right now. `reserved` = names that are already commands (/help, /mcp…):
143
149
  * the built-in wins, and the skill is still usable by the agent, just not as a slash command —
@@ -161,7 +167,7 @@ export function discoverSkills({ cwd = process.cwd(), home = homedir(), reserved
161
167
  const taken = new Set(reserved.map((r) => r.replace(/^\//, "")));
162
168
  const skills = [...byName.values()]
163
169
  .sort((a, b) => a.name.localeCompare(b.name))
164
- .map((s) => ({ ...s, slash: !taken.has(s.name), source: src }));
170
+ .map((s) => ({ ...s, slash: !taken.has(s.name), hidden: HIDDEN_SKILLS.includes(s.name), source: src }));
165
171
  return { skills, warnings: [...user.warnings, ...project.warnings], source: src };
166
172
  }
167
173
 
@@ -594,8 +594,8 @@ export function dragEventKind(btn, press) {
594
594
  // 105 is bright magenta, the sixteen-colour palette's purple, following the accent from cyan to
595
595
  // indigo when the brand moved (accent.mjs). Still a PALETTE colour, not the exact #a5b4fc: a
596
596
  // background inherits the user's theme this way, where a 256-colour or truecolor background would
597
- // fight every custom scheme and every light terminal — the same argument that kept syntax colour
598
- // out of the edit cards. The bright variant rather than 45, because black text on a dark magenta
597
+ // fight every custom scheme and every light terminal — the same argument that keeps syntax colour
598
+ // in the edit cards OFF by default (opt-in GU_DIFF_SYNTAX=1, task #30). The bright variant rather than 45, because black text on a dark magenta
599
599
  // is where this stops being legible.
600
600
  //
601
601
  // A BACKGROUND, because the cyan FOREGROUND is already spoken for: the ">> " prompt, where it
package/theme.mjs ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * SEMANTIC TERMINAL COLOURS (task #29) — one place that decides HOW MUCH colour this terminal
3
+ * gets, and what each role (added row, deleted row, gutter, header verb…) looks like in it.
4
+ *
5
+ * Tiers: "truecolor" (24-bit), "256", "16", "none". Detection mirrors accent.mjs, plus:
6
+ * NO_COLOR / GU_ACCENT=off / GU_THEME=mono / TERM=dumb -> "none" (plain text)
7
+ * Windows (win32) with no TERM/COLORTERM/WT_SESSION -> ask stdout.getColorDepth()
8
+ * (conhost on Windows 11 supports 24-bit but advertises nothing).
9
+ *
10
+ * Variants: GU_THEME=dark (default) | light | mono. GU_ASCII=1 swaps the "⏺" glyph for "*"
11
+ * (older Windows console fonts lack U+23FA).
12
+ *
13
+ * Rules every caller relies on:
14
+ * - background tiers fill a row by PADDING SPACES (measured with visibleLen), never ESC[K,
15
+ * which conhost and Windows Terminal treat differently;
16
+ * - every row ends with a FULL reset (ESC[0m) so a background never bleeds into the next row;
17
+ * - the 16-colour tier uses grey (90) instead of dim (SGR 2), which old conhost ignores.
18
+ */
19
+
20
+ const fgRgb = ([r, g, b]) => `\x1b[38;2;${r};${g};${b}m`;
21
+ const bgRgb = ([r, g, b]) => `\x1b[48;2;${r};${g};${b}m`;
22
+ const fg256 = (n) => `\x1b[38;5;${n}m`;
23
+ const bg256 = (n) => `\x1b[48;5;${n}m`;
24
+
25
+ /** [truecolor rgb, xterm-256 index] per role. */
26
+ export const PALETTE = {
27
+ dark: {
28
+ addBg: [[22, 52, 30], 22],
29
+ addWord: [[38, 100, 52], 28],
30
+ delBg: [[66, 24, 30], 52],
31
+ delWord: [[128, 38, 50], 88],
32
+ addFg: [[110, 220, 130], 114],
33
+ delFg: [[245, 120, 120], 210],
34
+ update: [[229, 192, 123], 180],
35
+ // Syntax roles (task #30, GU_DIFF_SYNTAX=1): foregrounds bright enough on the dark tints.
36
+ keyword: [[198, 160, 246], 183],
37
+ string: [[230, 200, 140], 186],
38
+ comment: [[140, 150, 160], 246],
39
+ number: [[120, 200, 230], 117],
40
+ },
41
+ light: {
42
+ addBg: [[218, 250, 222], 194],
43
+ addWord: [[160, 232, 170], 157],
44
+ delBg: [[255, 226, 226], 224],
45
+ delWord: [[255, 180, 182], 217],
46
+ addFg: [[26, 127, 55], 28],
47
+ delFg: [[190, 30, 40], 124],
48
+ update: [[154, 103, 0], 136],
49
+ keyword: [[111, 66, 193], 91],
50
+ string: [[3, 47, 98], 24],
51
+ comment: [[106, 115, 125], 243],
52
+ number: [[0, 92, 197], 26],
53
+ },
54
+ };
55
+
56
+ /**
57
+ * Which colour tier this environment gets. Pure: pass env and (for Windows) the platform and
58
+ * the stream's colour depth, so every branch is testable on any OS.
59
+ */
60
+ export function colorTier(env = process.env, { platform = process.platform, colorDepth } = {}) {
61
+ const pref = String(env.GU_ACCENT ?? "").trim().toLowerCase();
62
+ if (pref === "off" || pref === "none") return "none";
63
+ if (env.NO_COLOR != null && String(env.NO_COLOR) !== "") return "none";
64
+ if (String(env.GU_THEME ?? "").trim().toLowerCase() === "mono") return "none";
65
+ const term = String(env.TERM ?? "").toLowerCase();
66
+ if (term === "dumb") return "none";
67
+ const colorterm = String(env.COLORTERM ?? "").trim().toLowerCase();
68
+ if (colorterm === "truecolor" || colorterm === "24bit") return "truecolor";
69
+ if (env.WT_SESSION) return "truecolor";
70
+ if (/truecolor|direct/.test(term)) return "truecolor";
71
+ if (term.includes("256color")) return "256";
72
+ if (platform === "win32" && Number(colorDepth) > 0) {
73
+ if (colorDepth >= 24) return "truecolor";
74
+ if (colorDepth >= 8) return "256";
75
+ }
76
+ return "16";
77
+ }
78
+
79
+ /** The colour depth Node reports for stdout — only consulted on Windows. */
80
+ export function stdoutColorDepth() {
81
+ try {
82
+ if (process.platform !== "win32") return undefined;
83
+ return process.stdout?.getColorDepth?.();
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ }
88
+
89
+ /** Resolved once at module load — the environment cannot change mid-session. */
90
+ export const TIER = colorTier(process.env, { colorDepth: stdoutColorDepth() });
91
+ export const COLOR_ON = TIER !== "none";
92
+
93
+ /**
94
+ * A plain SGR helper (`paint(31)("x")`) that honours NO_COLOR & co. Replaces the hard-coded
95
+ * `\x1b[31m…` helpers, which ignored NO_COLOR while the accent honoured it (task #29 bug B1).
96
+ */
97
+ export function paint(code, on = COLOR_ON) {
98
+ return on ? (s) => `\x1b[${code}m${s}\x1b[0m` : (s) => String(s);
99
+ }
100
+
101
+ const EMPTY_SIGN = { row: "", sign: "", signOff: "", wordOn: "", wordOff: "" };
102
+
103
+ /** Every escape sequence the edit card uses, for one tier + variant. "" = nothing to emit. */
104
+ export function makeTheme(tier = "16", variant = "dark", { ascii = false, syntax = false } = {}) {
105
+ const glyph = ascii ? "*" : "⏺";
106
+ if (tier === "none") {
107
+ return {
108
+ tier, variant, glyph, fill: false, reset: "", lineNo: "", lineNoOff: "", grey: "",
109
+ fgOff: "", bold: "", boldOff: "", countAdd: "", countDel: "", syntax: null,
110
+ verb: { Update: "", Create: "", Delete: "", Write: "" },
111
+ add: { ...EMPTY_SIGN }, del: { ...EMPTY_SIGN },
112
+ };
113
+ }
114
+ const base = {
115
+ tier, variant, glyph, reset: "\x1b[0m", lineNo: "\x1b[90m", lineNoOff: "\x1b[39m",
116
+ grey: "\x1b[90m", fgOff: "\x1b[39m", bold: "\x1b[1m", boldOff: "\x1b[22m",
117
+ };
118
+ if (tier === "16") {
119
+ return {
120
+ ...base,
121
+ fill: false,
122
+ syntax: null, // rows are green/red TEXT here; token colours would erase that signal
123
+ countAdd: "\x1b[32m",
124
+ countDel: "\x1b[31m",
125
+ verb: { Update: "\x1b[33m", Create: "\x1b[32m", Delete: "\x1b[31m", Write: "\x1b[33m" },
126
+ // No background on 16 colours: the whole row is green/red text; changed words are
127
+ // reverse-video so they still stand out.
128
+ add: { row: "\x1b[32m", sign: "", signOff: "", wordOn: "\x1b[7m", wordOff: "\x1b[27m" },
129
+ del: { row: "\x1b[31m", sign: "", signOff: "", wordOn: "\x1b[7m", wordOff: "\x1b[27m" },
130
+ };
131
+ }
132
+ const P = PALETTE[variant === "light" ? "light" : "dark"];
133
+ const fg = tier === "truecolor" ? (c) => fgRgb(c[0]) : (c) => fg256(c[1]);
134
+ const bg = tier === "truecolor" ? (c) => bgRgb(c[0]) : (c) => bg256(c[1]);
135
+ return {
136
+ ...base,
137
+ fill: true,
138
+ // Opt-in only (GU_DIFF_SYNTAX=1): foreground per token role, layered over the row tint.
139
+ syntax: syntax
140
+ ? { keyword: fg(P.keyword), string: fg(P.string), comment: fg(P.comment), number: fg(P.number) }
141
+ : null,
142
+ countAdd: fg(P.addFg),
143
+ countDel: fg(P.delFg),
144
+ verb: { Update: fg(P.update), Create: fg(P.addFg), Delete: fg(P.delFg), Write: fg(P.update) },
145
+ add: { row: bg(P.addBg), sign: fg(P.addFg), signOff: "\x1b[39m", wordOn: bg(P.addWord), wordOff: bg(P.addBg) },
146
+ del: { row: bg(P.delBg), sign: fg(P.delFg), signOff: "\x1b[39m", wordOn: bg(P.delWord), wordOff: bg(P.delBg) },
147
+ };
148
+ }
149
+
150
+ /** The theme for this environment (GU_THEME, GU_ASCII, detected tier). */
151
+ export function themeFromEnv(env = process.env, opts = {}) {
152
+ const tier = opts.tier ?? colorTier(env, opts);
153
+ const variant = String(env.GU_THEME ?? "").trim().toLowerCase() === "light" ? "light" : "dark";
154
+ const ascii = /^(1|true|yes)$/i.test(String(env.GU_ASCII ?? ""));
155
+ const syntax = /^(1|true|yes|on)$/i.test(String(env.GU_DIFF_SYNTAX ?? "").trim());
156
+ return makeTheme(tier, variant, { ascii, syntax });
157
+ }
158
+
159
+ export const CARD_THEME = themeFromEnv(process.env, { tier: TIER });