@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.
- package/diff-syntax.mjs +75 -0
- package/edit-card-render.mjs +334 -0
- package/gonext_agent_chat.py +63 -7
- package/gu-cli.mjs +5 -4
- package/gu-repl.mjs +75 -49
- package/package.json +5 -1
- package/terminal-layout.mjs +46 -3
- 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
|
|
@@ -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 (
|
|
6857
|
-
|
|
6858
|
-
isEditCardHeader/EDIT_DIFF_RE
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 = (
|
|
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
|
@@ -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
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
const
|
|
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 = (
|
|
401
|
-
const bold = (
|
|
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
|
-
//
|
|
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);
|
|
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
|
-
|
|
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(" · ") +
|
|
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
|
-
|
|
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
|
-
|
|
7891
|
-
const
|
|
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(
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
12048
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|
package/terminal-layout.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
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 });
|