@tiens.nguyen/gu-cli 1.0.811 → 1.0.813

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
@@ -4764,6 +4769,42 @@ def _collapse_inner_openers(text):
4764
4769
  return text[:body_from] + cleaned + "\n</code>"
4765
4770
 
4766
4771
 
4772
+ # DeepSeek DSML tool-call markup (issue #119). Some DeepSeek-family models (seen via
4773
+ # token-plan.maas.qwencloudapi.com) answer with their NATIVE tool-call text instead of our code
4774
+ # block: prose, then a DSML "calls" tag, a DSML invoke tag with name="code", then plain Python,
4775
+ # then closers that are often wrong (a DSML "parameter" close) or missing. The bars in the tags
4776
+ # are FULLWIDTH, so the patterns tolerate any short run of non-bracket characters around DSML.
4777
+ _DSML_INVOKE_CODE = re.compile(
4778
+ r"<[^<>\n]{0,8}DSML[^<>\n]{0,8}invoke\s+name\s*=\s*[\x22\x27]code[\x22\x27]\s*>[ \t]*\r?\n?"
4779
+ )
4780
+ _DSML_ANY = re.compile(r"<[^<>\n]{0,8}DSML")
4781
+ _DSML_CLOSER = re.compile(r"\n?[ \t]*</[^<>\n]{0,8}DSML")
4782
+
4783
+
4784
+ def _dsml_to_code_block(text):
4785
+ """Rewrite a DSML invoke name="code" body into a normal code block, or return the text
4786
+ unchanged. Fires only when there is NO code tag already and the body COMPILES (the same
4787
+ conservative contract as the other readings)."""
4788
+ if not text or "DSML" not in text or re.search(r"<\s*/?\s*code\b", text, re.I):
4789
+ return text
4790
+ m = _DSML_INVOKE_CODE.search(text)
4791
+ if not m:
4792
+ return text
4793
+ rest = text[m.end():]
4794
+ c = _DSML_CLOSER.search(rest)
4795
+ body = (rest[:c.start()] if c else rest).strip("\n")
4796
+ if not body.strip():
4797
+ return text
4798
+ try:
4799
+ compile(body, "<gu-code>", "exec")
4800
+ except Exception: # noqa: BLE001
4801
+ return text
4802
+ head = text[:m.start()]
4803
+ first = _DSML_ANY.search(head)
4804
+ prose = (head[:first.start()] if first else head).rstrip()
4805
+ return (prose + "\n" if prose else "") + "<code>\n" + body + "\n</code>"
4806
+
4807
+
4767
4808
  def _code_block_readings(text):
4768
4809
  """Every plausible reading of where the code block is, CHEAPEST FIRST.
4769
4810
 
@@ -4824,6 +4865,10 @@ def _canonicalise_code_reply(text):
4824
4865
  # reproduced, inside the fix, the very shape of the bug being fixed: a strict cheap check
4825
4866
  # guarding a tolerant repair. If a pre-check cannot be as permissive as what it guards, it
4826
4867
  # does not belong in front of it.
4868
+ _dsml = _dsml_to_code_block(text)
4869
+ if _dsml != text:
4870
+ _log("DSML tool-call markup converted to a code block (#119)")
4871
+ return _dsml
4827
4872
  if not text or not _CODE_ANY_TAGGISH.search(text):
4828
4873
  return text
4829
4874
  fallback = None
@@ -6853,9 +6898,11 @@ def _render_edit_card(action: str, path: str, summary: str,
6853
6898
  max_removed: int = 8, max_added: int = 12) -> str:
6854
6899
  """Multi-line terminal-friendly summary of a file edit, emitted as ONE step event.
6855
6900
 
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
6901
+ The line shapes are a small stable protocol the REPL colorizes (coloured verb,
6902
+ bold path, green/red tinted rows, grey line numbers) — keep them in sync with
6903
+ isEditCardHeader/EDIT_DIFF_RE in edit-card-render.mjs (task #29). Line numbers are
6904
+ right-aligned to ONE width per card so the sign column lines up; the summary ends
6905
+ with " · +A -R" counts that the REPL colours. The web thinking panel
6859
6906
  shows the same text as-is (the 4-space indent renders as a monospace block in
6860
6907
  markdown), so no web change is needed.
6861
6908
 
@@ -6865,7 +6912,14 @@ def _render_edit_card(action: str, path: str, summary: str,
6865
6912
  19 + ...new text...
6866
6913
  """
6867
6914
  import os
6868
- lines = [f"⏺ {action}({os.path.basename(path)})", f" └ {summary}"]
6915
+ removed = list(removed or [])
6916
+ added = list(added or [])
6917
+ counts = " ".join(c for c in (f"+{len(added)}" if added else "",
6918
+ f"-{len(removed)}" if removed else "") if c)
6919
+ head = f" └ {summary}" + (f" · {counts}" if counts else "")
6920
+ lines = [f"⏺ {action}({os.path.basename(path)})", head]
6921
+ shown = removed[:max_removed] + added[:max_added]
6922
+ width = max((len(str(no)) for no, _ in shown), default=1)
6869
6923
 
6870
6924
  def _fmt(entries, sign, cap):
6871
6925
  out = []
@@ -6878,7 +6932,8 @@ def _render_edit_card(action: str, path: str, summary: str,
6878
6932
  t = txt.rstrip()
6879
6933
  if len(t) > 160:
6880
6934
  t = t[:159] + "…"
6881
- out.append(f" {no} {sign} {t}")
6935
+ t = t.replace("\r", "") # CRLF sources: a bare CR would jump the cursor
6936
+ out.append(f" {str(no).rjust(width)} {sign} {t}")
6882
6937
  return out
6883
6938
 
6884
6939
  if removed:
@@ -11810,7 +11865,8 @@ def run_agent_chat(cfg):
11810
11865
  if _still_bad:
11811
11866
  self._parse_fail_streak += 1
11812
11867
  _log("tool-call code still unparseable after repair "
11813
- f"(streak {self._parse_fail_streak}/3)")
11868
+ f"(streak {self._parse_fail_streak}/3) model={getattr(self, 'model_id', '?')} "
11869
+ f"reply[:300]={(msg.content or '')[:300]!r}")
11814
11870
  if self._parse_fail_streak >= 3:
11815
11871
  _log("parse-fail streak hit limit → ending the turn honestly "
11816
11872
  "instead of looping on an unparseable tool call")
@@ -12789,7 +12845,7 @@ def run_agent_chat(cfg):
12789
12845
  _RAG_STATE["s"] = "fallback"
12790
12846
  _emit({"type": "step",
12791
12847
  "text": f"⚠ RAG unavailable ({why}) → falling back to keyword search"})
12792
- _log(f"RAG fallback: {why} — answering {query[:80]!r} with a lexical search")
12848
+ _log(f"RAG fallback: {why} — answering {_c(query, 80)} with a lexical search")
12793
12849
  try:
12794
12850
  root = _rag_read_allowed(_default_ws_root())
12795
12851
  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
@@ -61,6 +61,7 @@ import {
61
61
  thoughtFull,
62
62
  thoughtBlockRows,
63
63
  ragSegmentLabel,
64
+ ragBarNote,
64
65
  searchSegmentLabel,
65
66
  runningDetailRows,
66
67
  formatThinkTime,
@@ -175,6 +176,9 @@ import { ACCEPTED_EMBED_MODELS, REQUIRED_AGENT_MODEL, REQUIRED_AGENT_PREFIX } fr
175
176
  // plain SGR code: terminals disagree about colour, so accent.mjs picks 24-bit, 256 or the
176
177
  // sixteen-colour magenta from what this one actually claims to support.
177
178
  import { accent } from "./accent.mjs";
179
+ // Task #29: colour tiers that honour NO_COLOR (paint) and the colourful edit-card renderer.
180
+ import { paint, CARD_THEME } from "./theme.mjs";
181
+ import { EditCardStream, isEditCardHeader } from "./edit-card-render.mjs";
178
182
  // Task #240: what a failed agent-payload call means, and when a rejected key may be recovered
179
183
  // from in place instead of ending the session.
180
184
  import { payloadError, recoveryPlan } from "./credential-recovery.mjs";
@@ -216,10 +220,12 @@ const ENV_FILE = join(homedir(), ".gonext", "worker.env");
216
220
  const WS_FILE = join(homedir(), ".gonext", "workspaces.json");
217
221
  const SESSIONS_DIR = join(homedir(), ".gonext", "sessions");
218
222
 
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`;
223
+ // Through paint() so NO_COLOR / GU_ACCENT=off / TERM=dumb turn ALL of them off, not only the
224
+ // accent (task #29 bug B1 — red '-' rows stayed red under NO_COLOR).
225
+ const dim = paint(2);
226
+ const red = paint(31);
227
+ const yellow = paint(33);
228
+ const white = paint(37);
223
229
  // Left gutter so terminal output doesn't hug the edge (task #128, Phase 1). A terminal has
224
230
  // no CSS padding — a "left margin" is just a space prefix on every printed line, applied
225
231
  // consistently at the prompt, the ● bullets, plain replies, the banner and the live status
@@ -397,8 +403,8 @@ const LINE_SPACING = "\n";
397
403
  const STREAM_PLAIN_REPLY = false;
398
404
  // ANSI 90 ("bright black") — a reliable neutral grey across terminal themes, unlike
399
405
  // 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`;
406
+ const codeColor = paint(90);
407
+ const bold = paint(1);
402
408
  // Hover highlight for the clickable thinking line (task #96): just shift the color a little
403
409
  // toward white (no bold/underline/anchor) so it subtly signals "hovered" without looking like
404
410
  // a link. 97 = bright white.
@@ -434,19 +440,12 @@ const isRoutingLine = (line) =>
434
440
  /^(Routing your request…|Choosing a tool…|Composing a reply…|→ Agent mode.*|→ Chat reply)$/.test(
435
441
  line.trim()
436
442
  );
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);
443
+ // The edit tools (edit_lines/edit_file/create_file) emit a multi-line "edit card" as one
444
+ // step event — a small stable line protocol written by _render_edit_card in
445
+ // gonext_agent_chat.py and parsed/coloured by edit-card-render.mjs (task #29): coloured
446
+ // verb + bold path, grey summary with green/red counts, a padded grey line-number gutter,
447
+ // green/red tinted rows with brighter changed words (plain green/red text on 16 colours,
448
+ // no colour under NO_COLOR).
450
449
 
451
450
  /**
452
451
  * STILL MODE — no animated status block at all (GONEXT_STILL=1).
@@ -2278,9 +2277,13 @@ const ragSegmentText = () => {
2278
2277
  // RAG FAILED MID-TURN and searches were answered by the grep fallback: a WARNING (yellow),
2279
2278
  // always visible — the turn still got results, but not semantic ones.
2280
2279
  const liveFallback = b.ragState === "fallback" && !b.stale;
2281
- if (!REPL_VERBOSE && !liveDown && !liveFallback) return lexSeg;
2280
+ // Task #34: a LIVE grep fallback (embedder down, or RAG failed mid-turn) is no longer on this
2281
+ // row. In red with a "✗" it read as a serious fault while search kept working. It is now a soft
2282
+ // yellow note on the bottom bar's mode row (ragBarNote → footerRows' modeNote).
2283
+ if (liveDown || liveFallback) return lexSeg;
2284
+ if (!REPL_VERBOSE) return lexSeg;
2282
2285
  if (b.stale) return dim(" · ") + dim(`${label}·`) + lexSeg;
2283
- return dim(" · ") + (liveDown ? red(label) : liveFallback ? yellow(label) : dim(label)) + lexSeg;
2286
+ return dim(" · ") + dim(label) + lexSeg;
2284
2287
  };
2285
2288
  /**
2286
2289
  * IS ANYTHING NEWER? (task #181) — cached, never probed from a repaint.
@@ -2731,6 +2734,10 @@ const footerRows = () =>
2731
2734
  identity: identityLineText(),
2732
2735
  token: tokenLineText(),
2733
2736
  mode: modeLineText(),
2737
+ // Task #34: "rag: embedder down → grep fallback" as a soft yellow note beside the mode, not an
2738
+ // alarming red segment on the token row. yellow goes through paint(), so NO_COLOR turns it off.
2739
+ modeNote: ragBarNote(lastPromptBreakdown),
2740
+ notePaint: yellow,
2734
2741
  // How to paste here (issue #67): the platform's text key, plus gu's image key when the coder
2735
2742
  // can take an image.
2736
2743
  modeHint: pasteHint({ images: coderTakesImages() }),
@@ -3542,6 +3549,7 @@ function printThoughtBlock(raw, before, { gap = false } = {}) {
3542
3549
  collapsed: true,
3543
3550
  lines: THOUGHT_COLLAPSED_LINES,
3544
3551
  });
3552
+ const rowsBeforeMerge = screenRows().length;
3545
3553
  const newEnd = transcript.replaceRange(prev.from, prev.to, grown.map((r) => GUTTER + dim(r)));
3546
3554
  if (newEnd !== null) {
3547
3555
  prev.prose = merged;
@@ -3550,7 +3558,7 @@ function printThoughtBlock(raw, before, { gap = false } = {}) {
3550
3558
  // move under the pointer, so the cached hover row must be dropped — same reason
3551
3559
  // toggleThoughtBlock does it after a resize of the block.
3552
3560
  hoverCtl.reset();
3553
- paintLiveTail();
3561
+ repaintTailOrHoldView(rowsBeforeMerge); // #113/#35: a finished thought never pulls a reader down
3554
3562
  return true;
3555
3563
  }
3556
3564
  // Scrolled out of the buffer — fall through and print a new block rather than lose it.
@@ -3857,6 +3865,21 @@ function toggleNewestThought() {
3857
3865
  return toggleThoughtBlock(block);
3858
3866
  }
3859
3867
 
3868
+ /**
3869
+ * #113 / #35 — A BLOCK AT THE TAIL CHANGED SIZE (a thought merged, a step group grew). At the
3870
+ * live tail that is a paintLiveTail. Scrolled back, it is NOT: paintLiveTail sets scrollOffset
3871
+ * to 0, which is exactly the "it finished thinking and threw me back to the bottom" report. The
3872
+ * change happened below (or at the edge of) what the reader is looking at, so keep the same
3873
+ * lines on screen — the offset moves by the rows added — and let the jump notice count up.
3874
+ */
3875
+ function repaintTailOrHoldView(rowsBefore) {
3876
+ if (barPinned() && scrolledBack()) {
3877
+ scrollOffset = Math.max(1, scrollOffset + (screenRows().length - rowsBefore));
3878
+ renderScrollback(); // same slice as before; ends in paintBar (the "N lines below" count)
3879
+ return;
3880
+ }
3881
+ paintLiveTail();
3882
+ }
3860
3883
  /** Back to the live tail: repaint it from the transcript and hand the screen back. */
3861
3884
  function jumpToBottom() {
3862
3885
  if (!barPinned() || !scrollOffset) return; // already live — nothing to come back from
@@ -7887,14 +7910,15 @@ async function refreshAfterPresetApply(value, codingDefault, result) {
7887
7910
  defaultCodingUrl = codingDefault.url;
7888
7911
  }
7889
7912
  // 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 ?? {};
7913
+ // A mid-session refresh, NOT the startup probe: no sign-in offer here (that is probeWithRecovery's).
7914
+ const refreshed = await fetchAgentPayload([{ role: "user", content: "ping" }]).catch(() => null);
7915
+ const p = refreshed?.payload ?? {};
7892
7916
  seedDefaultCoder(p);
7893
7917
  // codingChoices lives on the envelope, never inside payload.
7894
- activeCoderWhere = whereOfActiveCoder(probe?.codingChoices, sessionCodingModel);
7918
+ activeCoderWhere = whereOfActiveCoder(refreshed?.codingChoices, sessionCodingModel);
7895
7919
  if (value?.agentModel?.name || p.agentMissing === false) agentMissing = false;
7896
7920
  else if (p.agentMissing === true) agentMissing = true;
7897
- if (probe?.payload) noModelAtAll = nothingToAnswerWith(p);
7921
+ if (refreshed?.payload) noModelAtAll = nothingToAnswerWith(p);
7898
7922
  await syncEmbedWarning({ repair: true });
7899
7923
  if (barPinned()) paintBar();
7900
7924
  } catch {
@@ -11820,7 +11844,10 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11820
11844
  // them prints in a distinct grey (not dim, so it stands out from "Thought:" prose);
11821
11845
  // blank lines keep spacing; everything else commits dim to scrollback.
11822
11846
  let inCode = false;
11823
- let inEditCard = false; // inside a colored edit-card block (see isEditCardHeader)
11847
+ let editCard = null; // the EditCardStream of the card being printed (see isEditCardHeader)
11848
+ const writeCardRows = (rows) => {
11849
+ for (const r of rows) process.stdout.write(r + "\n");
11850
+ };
11824
11851
  // The worker wraps the raw model token stream (Thought prose + code) in ~~~ fences;
11825
11852
  // the concrete ACTION events (tool steps, edit cards) are emitted OUTSIDE them. We
11826
11853
  // suppress everything IN a fence — the rambling Thought and redundant raw code (the
@@ -11874,13 +11901,16 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11874
11901
  if (stepGroupStillOpen()) {
11875
11902
  stepGroup.items.push(text);
11876
11903
  const painted = paintThoughtRows(stepGroup, hoverCtl.hovered === stepGroup);
11904
+ const rowsBeforeStep = screenRows().length;
11877
11905
  const newEnd = transcript.replaceRange(stepGroup.from, stepGroup.to, painted);
11878
11906
  if (newEnd !== null) {
11879
11907
  const delta = newEnd - stepGroup.to;
11880
11908
  stepGroup.to = newEnd;
11881
11909
  if (delta === 0) {
11882
11910
  // Collapsed: one row before, one row after — rewrite that row and nothing else.
11883
- ui(thoughtBlockRepaintSeq(stepGroup));
11911
+ // Scrolled back (#113/#35): those rows are live-tail coordinates, NOT the history the
11912
+ // reader is looking at — painting them would draw over it. The transcript has it.
11913
+ if (!scrolledBack()) ui(thoughtBlockRepaintSeq(stepGroup));
11884
11914
  paintBar();
11885
11915
  } else {
11886
11916
  // Open (the user clicked it mid-turn): it grew, and what is under it moved down.
@@ -11891,7 +11921,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11891
11921
  }
11892
11922
  }
11893
11923
  hoverCtl.reset();
11894
- paintLiveTail();
11924
+ repaintTailOrHoldView(rowsBeforeStep); // #113/#35: never pull a reader down
11895
11925
  }
11896
11926
  return;
11897
11927
  }
@@ -12037,36 +12067,30 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12037
12067
  // header is distinctive enough to never collide with generated Python.
12038
12068
  if (isEditCardHeader(line)) {
12039
12069
  onContent();
12040
- inEditCard = true;
12070
+ if (editCard) writeCardRows(editCard.flush()); // back-to-back cards
12071
+ editCard = new EditCardStream({
12072
+ theme: CARD_THEME,
12073
+ gutter: GUTTER,
12074
+ width: () => Math.max(20, process.stdout.columns || 80),
12075
+ });
12041
12076
  openBullet();
12042
- process.stdout.write(markLine(accent("⏺")) + bold(line.slice(1).replace(/^\s+/, "")) + "\n");
12077
+ process.stdout.write(markLine(accent(CARD_THEME.glyph)) + editCard.header(line) + "\n");
12043
12078
  lastWasBlank = false;
12044
12079
  lastContentAt = Date.now();
12045
12080
  continue;
12046
12081
  }
12047
- if (inEditCard) {
12048
- if (isEditCardBody(line)) {
12082
+ if (editCard) {
12083
+ // Diff rows are buffered inside the card (so a '-' run can be paired with its '+'
12084
+ // run for word highlights) and flushed at the card's end or this chunk's end.
12085
+ if (editCard.accept(line)) {
12049
12086
  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
- }
12087
+ writeCardRows(editCard.drain());
12065
12088
  lastWasBlank = false;
12066
12089
  lastContentAt = Date.now();
12067
12090
  continue;
12068
12091
  }
12069
- inEditCard = false; // card ended — fall through to normal handling
12092
+ writeCardRows(editCard.flush()); // card ended — fall through to normal handling
12093
+ editCard = null;
12070
12094
  }
12071
12095
  {
12072
12096
  const t = line.trim();
@@ -12212,6 +12236,8 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12212
12236
  }
12213
12237
  lastWasBlank = false;
12214
12238
  }
12239
+ // A card's buffered diff rows must not wait for a line that may never come.
12240
+ if (editCard) writeCardRows(editCard.flush());
12215
12241
  // Plain-reply answers often have NO internal newline at all (a short one-sentence
12216
12242
  // reply) and can take many seconds to generate — without this, nothing would print
12217
12243
  // 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.811",
3
+ "version": "1.0.813",
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",
@@ -40,6 +40,7 @@
40
40
  "test:cache-e2e": "GONEXT_OLLAMA1_LIVE=1 node --test --test-timeout=1800000 tests/prefix-cache-e2e.test.mjs",
41
41
  "test:tokens": "python3 -m unittest discover tests -p \"test_cached_tokens.py\" -v && node --test tests/prefix-cache.test.mjs tests/agent-turn-shape.test.mjs",
42
42
  "test:selection": "node --test tests/selection-scrollback.test.mjs tests/selection-while-thinking.test.mjs tests/terminal-selection.test.mjs tests/terminal-copy.test.mjs tests/terminal-viewport.test.mjs tests/hover-repaint.test.mjs",
43
+ "test:autoscroll": "node --test tests/no-autoscroll-hide-total.test.mjs tests/thought-spacing.test.mjs tests/terminal-viewport.test.mjs tests/selection-scrollback.test.mjs",
43
44
  "test:steps": "python3 scripts/py-tap.py tests/test_read_caps.py tests/test_trim_payback.py tests/test_explore.py tests/test_and_chains.py tests/test_edit_file_primary.py tests/test_bare_code_rescue.py tests/test_batching_rule.py tests/test_no_pdf_routing.py",
44
45
  "check:image": "node scripts/image-paste-check.mjs",
45
46
  "check:image-clipboard": "node scripts/image-paste-check.mjs --clipboard",
@@ -51,6 +52,9 @@
51
52
  },
52
53
  "files": [
53
54
  "accent.mjs",
55
+ "edit-card-render.mjs",
56
+ "diff-syntax.mjs",
57
+ "theme.mjs",
54
58
  "gu-cli.mjs",
55
59
  "gu-repl.mjs",
56
60
  "step-tokens.mjs",
@@ -146,8 +146,9 @@ export function ragSegmentLabel(b) {
146
146
  const rag = Number.isFinite(b.rag) ? b.rag : 0;
147
147
  if (rag > 0) return null; // …the caller renders the number + percent instead
148
148
  switch (b.ragState) {
149
+ // Task #34: no "✗" — search still works through grep, so this is a notice, not an error.
149
150
  case "unreachable":
150
- return "rag ✗ embedder down → grep fallback";
151
+ return "rag: embedder down → grep fallback";
151
152
  case "no-index":
152
153
  return "rag — not indexed";
153
154
  case "unavailable":
@@ -167,7 +168,7 @@ export function ragSegmentLabel(b) {
167
168
  // RAG FAILED MID-TURN (indexing or query embedding) and the search was answered by the
168
169
  // keyword fallback instead. A warning, not an error: the turn still got results.
169
170
  case "fallback":
170
- return "rag ⚠ unavailable → grep fallback";
171
+ return "rag: unavailable → grep fallback";
171
172
  // Nothing has been measured yet (the state before any turn has run). "0" is a claim we
172
173
  // cannot support; a dash says "no figure", which is the truth.
173
174
  case "unknown":
@@ -182,6 +183,34 @@ export function ragSegmentLabel(b) {
182
183
  }
183
184
  }
184
185
 
186
+ /**
187
+ * The RAG notice for the BOTTOM BAR's mode row (task #34), or "" when there is none.
188
+ *
189
+ * Only THIS session's live verdict that search fell back to grep: "unreachable" (the embedder is
190
+ * down) or "fallback" (RAG failed mid-turn). It used to sit in red with a "✗" in the middle of the
191
+ * token row and read as a serious fault, while every search was still answered by grep. It is
192
+ * now a soft note beside "accept edits on". A stale verdict reloaded from a saved session is
193
+ * not a live fault, so it gets no notice.
194
+ */
195
+ export function ragBarNote(b) {
196
+ if (!b || typeof b !== "object" || b.stale) return "";
197
+ if (b.ragState !== "unreachable" && b.ragState !== "fallback") return "";
198
+ return ragSegmentLabel(b) || "";
199
+ }
200
+
201
+ /** Cut plain text to `room` visible columns, ending with "…" when it was cut. */
202
+ function fitPlain(s, room) {
203
+ s = String(s ?? "");
204
+ if (visibleLen(s) <= room) return s;
205
+ if (room <= 1) return room === 1 ? "…" : "";
206
+ let out = "";
207
+ for (const ch of s) {
208
+ if (visibleLen(out + ch) > room - 1) break;
209
+ out += ch;
210
+ }
211
+ return out + "…";
212
+ }
213
+
185
214
  // The six origin buckets. They are scaled to sum to the server's prompt_tokens for ONE
186
215
  // request, so their sum IS the size of the newest context — the number to compare against
187
216
  // the model's window. Deliberately excludes ragFresh/ragCarried (shares OF rag, not extra)
@@ -544,11 +573,25 @@ export function buildFooterRows({
544
573
  // Right-aligned on the mode row (issue #67), dim. DROPPED WHOLE when it does not fit: every
545
574
  // footer row must stay one physical row (#109/#132/#138), and half a key name is worse than none.
546
575
  modeHint = "",
576
+ // Task #34: a soft notice (PLAIN text) after the mode text, painted with notePaint. It takes
577
+ // priority over the paste hint and is cut with "…" on a narrow window, never wrapped.
578
+ modeNote = "",
579
+ notePaint = dim,
547
580
  notice = "",
548
581
  missing = "",
549
582
  }) {
550
583
  const w = Math.max(20, width || 80);
551
- const modeLeft = gutter + mode;
584
+ let modeLeft = gutter + mode;
585
+ if (modeNote) {
586
+ const NOTE_GAP = " ";
587
+ const hintRoom = modeHint ? HINT_MIN_GAP + visibleLen(modeHint) : 0;
588
+ let room = w - 1 - visibleLen(modeLeft) - NOTE_GAP.length;
589
+ // Keep the paste hint only when the whole notice still fits beside it.
590
+ if (modeHint && visibleLen(modeNote) <= room - hintRoom) room -= hintRoom;
591
+ else modeHint = "";
592
+ // Too little room to say anything readable: leave the notice out.
593
+ if (room >= 8) modeLeft += NOTE_GAP + notePaint(fitPlain(modeNote, room));
594
+ }
552
595
  const hintGap = modeHint ? w - 1 - visibleLen(modeLeft) - visibleLen(modeHint) : -1;
553
596
  const modeRow =
554
597
  hintGap >= HINT_MIN_GAP
@@ -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 });