@tiens.nguyen/gu-cli 1.0.811 → 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.
- package/diff-syntax.mjs +75 -0
- package/edit-card-render.mjs +334 -0
- package/gonext_agent_chat.py +21 -6
- package/gu-cli.mjs +5 -4
- package/gu-repl.mjs +42 -44
- package/package.json +4 -1
- package/terminal-viewport.mjs +2 -2
- package/theme.mjs +159 -0
package/diff-syntax.mjs
ADDED
|
@@ -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
|
+
}
|
package/gonext_agent_chat.py
CHANGED
|
@@ -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 (
|
|
6857
|
-
|
|
6858
|
-
isEditCardHeader/EDIT_DIFF_RE
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 = (
|
|
1402
|
-
const dim = (
|
|
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 = (
|
|
1433
|
-
const dim = (
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
const
|
|
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 = (
|
|
401
|
-
const bold = (
|
|
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
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
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).
|
|
@@ -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
|
-
|
|
7891
|
-
const
|
|
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(
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
12048
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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/terminal-viewport.mjs
CHANGED
|
@@ -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
|
|
598
|
-
//
|
|
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 });
|