z0gcode 0.2.0

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/src/ui.mjs ADDED
@@ -0,0 +1,798 @@
1
+ // z0gcode terminal UI. Dependency-free ANSI. One visual system, degrades
2
+ // cleanly under NO_COLOR and when piped (non-TTY). No em-dashes anywhere.
3
+ import { fmtCtx, orderChatModels, mediaModels } from "./models-info.mjs";
4
+
5
+ // ---- environment probes (computed once) ---------------------------------
6
+ export const useColor = !!process.stdout.isTTY && process.env.NO_COLOR === undefined;
7
+ export const uiTTY = !!process.stdout.isTTY;
8
+ export const interactive = !!(process.stdin.isTTY && process.stdout.isTTY);
9
+ const truecolor = /truecolor|24bit/i.test(process.env.COLORTERM || "");
10
+ export const cols = Math.max(40, Math.min(process.stdout.columns || 80, 100));
11
+ const RULE_W = Math.min(cols, 78);
12
+
13
+ // ---- palette roles (semantic; never emit a raw color at a call site) -----
14
+ const sgr = (open, close) => (s) => (useColor ? `\x1b[${open}m${s}\x1b[${close}m` : String(s));
15
+ export const strong = sgr("1", "22"); // bold, background-agnostic
16
+ export const ok = sgr("32", "39");
17
+ export const warn = sgr("33", "39");
18
+ export const err = sgr("31", "39");
19
+ export const accent = truecolor ? sgr("38;2;167;139;255", "39") : sgr("35", "39"); // brand violet
20
+ export const muted = truecolor ? sgr("38;2;107;112;128", "39") : sgr("2", "22"); // chrome
21
+ export const body = (s) => String(s);
22
+ export const italic = sgr("3", "23");
23
+ const underlineFn = sgr("4", "24");
24
+ const mdCyan = sgr("36", "39"); // inline code / code blocks
25
+ export const reverse = (s) => (useColor ? `\x1b[7m${s}\x1b[27m` : String(s));
26
+
27
+ // Blinking block cursor at the prompt (DECSCUSR), matching the README demo.
28
+ // on -> blinking block; off -> restore the terminal default. TTY only.
29
+ export function cursorBlink(on) {
30
+ if (uiTTY) process.stdout.write(on ? "\x1b[1 q" : "\x1b[0 q");
31
+ }
32
+
33
+ // Backward-compatible color map (older call sites).
34
+ export const color = {
35
+ dim: muted, bold: strong, cyan: accent, green: ok, yellow: warn,
36
+ red: err, magenta: accent, gray: muted, blue: sgr("34", "39"),
37
+ };
38
+
39
+ // ---- glyph vocabulary (unicode on a TTY, ascii when piped) ---------------
40
+ export const GLYPH = uiTTY
41
+ ? { tick: "▍", chevron: "›", seal: "◈", priv: "◆", ver: "◇", open: "·",
42
+ ok: "✓", no: "✗", run: "▶", pending: "○", current: "●", point: "»",
43
+ ellipsis: "…", rule: "─", up: "↑", down: "↓" }
44
+ : { tick: "", chevron: ">", seal: "*", priv: "", ver: "", open: ".",
45
+ ok: "ok", no: "x", run: ">", pending: "o", current: "*", point: ">",
46
+ ellipsis: "...", rule: "-", up: "^", down: "v" };
47
+
48
+ // ---- measuring / padding / formatters ------------------------------------
49
+ const STRIP = /\x1b\[[0-9;]*m/g;
50
+ export const vlen = (s) => String(s).replace(STRIP, "").length;
51
+ export function pad(s, w, align = "left") {
52
+ const l = vlen(s);
53
+ if (l >= w) return s;
54
+ const sp = " ".repeat(w - l);
55
+ return align === "right" ? sp + s : s + sp;
56
+ }
57
+ export function trunc(s, w) {
58
+ s = String(s);
59
+ return s.length <= w ? s : s.slice(0, w - 1) + GLYPH.ellipsis;
60
+ }
61
+ // ANSI-aware clip: truncate to w visible columns, preserving color codes.
62
+ export function clip(s, w) {
63
+ s = String(s);
64
+ if (vlen(s) <= w) return s;
65
+ let out = "";
66
+ let vis = 0;
67
+ const re = /(\x1b\[[0-9;]*m)|([\s\S])/g;
68
+ let mch;
69
+ while ((mch = re.exec(s))) {
70
+ if (mch[1]) {
71
+ out += mch[1];
72
+ continue;
73
+ }
74
+ if (vis >= w - 1) break;
75
+ out += mch[2];
76
+ vis++;
77
+ }
78
+ return out + GLYPH.ellipsis + (useColor ? "\x1b[0m" : "");
79
+ }
80
+ export function fmtUsd(perM) {
81
+ if (perM == null) return "";
82
+ return "$" + (perM >= 10 ? perM.toFixed(2) : perM.toFixed(3));
83
+ }
84
+ export function fmtSave(pct) {
85
+ return pct == null ? "" : "-" + pct + "%";
86
+ }
87
+ export function host(url) {
88
+ return String(url || "").replace(/^https?:\/\//, "").replace(/\/v1\/?$/, "");
89
+ }
90
+
91
+ // ---- layout grammar ------------------------------------------------------
92
+ export function rule(w = RULE_W) {
93
+ return " " + muted(GLYPH.rule.repeat(Math.max(0, w - 2)));
94
+ }
95
+ // A titled section header: blank line, "▍ Title · meta", hairline rule.
96
+ export function section(title, meta) {
97
+ const tick = GLYPH.tick ? accent(GLYPH.tick + " ") : "";
98
+ const head = tick + strong(title) + (meta ? " " + muted("· " + meta) : "");
99
+ return "\n" + head + "\n" + rule();
100
+ }
101
+ export function field(label, value) {
102
+ return " " + muted(String(label).padEnd(16)) + value;
103
+ }
104
+
105
+ // ---- banner --------------------------------------------------------------
106
+ export function banner(model, baseURL) {
107
+ if (useColor && uiTTY) {
108
+ const V = "\x1b[38;2;167;139;255m"; // Violet
109
+ const W = "\x1b[38;2;242;241;236m"; // Foam (banner only)
110
+ const M = "\x1b[38;2;107;112;128m"; // Muted
111
+ const R = "\x1b[0m";
112
+ console.log(`${W}█████ ${V}███${W} ████${R}`);
113
+ console.log(`${W} ██ ${V}█ /█${W} █${R}`);
114
+ console.log(`${W} ██ ${V}█ / █${W} █ ██${R} ${M}z0gcode v0.2${R}`);
115
+ console.log(`${W} ██ ${V}█/ █${W} █ █${R} ${M}coding agent on 0G${R}`);
116
+ console.log(`${W}█████ ${V}███${W} ████${R}`);
117
+ } else {
118
+ console.log("z0gcode v0.2 · coding agent on 0G");
119
+ }
120
+ console.log(
121
+ muted(" model ") + accent(model) + muted(" · " + host(baseURL) + " ") +
122
+ accent(GLYPH.seal) + muted(" TEE") + "\n"
123
+ );
124
+ }
125
+
126
+ // ---- animated intro ------------------------------------------------------
127
+ // The barred-zero logo as a per-cell mask so we can fade + shine it. Each row
128
+ // is [text, role] segments; role W = foam, V = violet. Labels sit to the right.
129
+ const VIOLET_RGB = [167, 139, 255];
130
+ const FOAM_RGB = [242, 241, 236];
131
+ const SHINE_RGB = [255, 255, 255];
132
+ const LOGO = [
133
+ [["█████ ", "W"], ["███", "V"], [" ████", "W"]],
134
+ [[" ██ ", "W"], ["█ /█", "V"], [" █", "W"]],
135
+ [[" ██ ", "W"], ["█ / █", "V"], [" █ ██", "W"]],
136
+ [[" ██ ", "W"], ["█/ █", "V"], [" █ █", "W"]],
137
+ [["█████ ", "W"], ["███", "V"], [" ████", "W"]],
138
+ ];
139
+ const LOGO_LABELS = ["", "", "z0gcode v0.2", "coding agent on 0G", ""];
140
+ const LOGO_H = LOGO.length;
141
+
142
+ const scaleRgb = ([r, g, b], f) => [Math.round(r * f), Math.round(g * f), Math.round(b * f)];
143
+ const tcRgb = ([r, g, b]) => `\x1b[38;2;${r};${g};${b}m`;
144
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
145
+ const animDisabled = () =>
146
+ !!process.env.Z0G_NO_ANIM || cols < 50 || !truecolor;
147
+
148
+ function renderLogoLine(rowIdx, brightness, sweepCol) {
149
+ let out = "";
150
+ let col = 0;
151
+ for (const [text, role] of LOGO[rowIdx]) {
152
+ for (const ch of text) {
153
+ if (ch === " ") {
154
+ out += " ";
155
+ col++;
156
+ continue;
157
+ }
158
+ const lit = sweepCol != null && (col === sweepCol || col === sweepCol - 1);
159
+ const rgb = lit ? SHINE_RGB : scaleRgb(role === "V" ? VIOLET_RGB : FOAM_RGB, brightness);
160
+ out += tcRgb(rgb) + ch;
161
+ col++;
162
+ }
163
+ }
164
+ out += "\x1b[0m";
165
+ const label = LOGO_LABELS[rowIdx];
166
+ if (label) out += " " + muted(label);
167
+ return out + "\x1b[K";
168
+ }
169
+
170
+ // Play the intro, then leave the finished banner + strapline on screen. Falls
171
+ // back to the static banner when animation is off (piped, NO_COLOR, narrow,
172
+ // no truecolor, or Z0G_NO_ANIM). Never leaves the cursor hidden.
173
+ export async function bannerAnimated(model, baseURL) {
174
+ if (!useColor || !uiTTY || animDisabled()) {
175
+ banner(model, baseURL);
176
+ return;
177
+ }
178
+ const drawFrame = (brightness, sweepCol) => {
179
+ let buf = "";
180
+ for (let i = 0; i < LOGO_H; i++) buf += renderLogoLine(i, brightness, sweepCol) + "\n";
181
+ process.stdout.write(buf);
182
+ };
183
+ const redraw = (brightness, sweepCol) => {
184
+ process.stdout.write(`\x1b[${LOGO_H}A`);
185
+ drawFrame(brightness, sweepCol);
186
+ };
187
+ try {
188
+ process.stdout.write("\x1b[?25l"); // hide cursor
189
+ const fades = [0.28, 0.5, 0.74, 1.0];
190
+ drawFrame(fades[0], null);
191
+ for (let k = 1; k < fades.length; k++) {
192
+ await sleep(45);
193
+ redraw(fades[k], null);
194
+ }
195
+ for (let c = 0; c <= 22; c++) {
196
+ await sleep(15);
197
+ redraw(1.0, c);
198
+ }
199
+ redraw(1.0, null);
200
+ } catch {
201
+ // fall through to a clean final state
202
+ } finally {
203
+ process.stdout.write("\x1b[?25h"); // always restore cursor
204
+ }
205
+ console.log(
206
+ muted(" model ") + accent(model) + muted(" · " + host(baseURL) + " ") +
207
+ accent(GLYPH.seal) + muted(" TEE") + "\n"
208
+ );
209
+ }
210
+
211
+ // ---- agent-run feedback --------------------------------------------------
212
+ export function toolCall(name, summary) {
213
+ const room = Math.max(12, cols - 8 - vlen(name));
214
+ const tail = summary ? " " + muted(trunc(summary, room)) : "";
215
+ console.log(" " + accent(GLYPH.chevron) + " " + strong(name) + tail);
216
+ }
217
+ export function toolResult(success, summary) {
218
+ const mark = success ? ok(GLYPH.ok) : err(GLYPH.no);
219
+ console.log(" " + mark + " " + muted(summary || ""));
220
+ }
221
+
222
+ // Subagent fan-out status (compact; one line per subagent as it finishes).
223
+ export function subagentsStart(n, mode) {
224
+ console.log(" " + accent(GLYPH.chevron) + " " + strong(mode ? "spawn_write_subagents" : "spawn_subagents") + " " + muted(n + " parallel, " + (mode || "read-only")));
225
+ }
226
+ export function subagentOne(r) {
227
+ const mark = r.ok ? ok(GLYPH.ok) : err(GLYPH.no);
228
+ console.log(" " + mark + " " + muted((r.label || "subagent") + " · " + (r.tokens || 0) + " tok"));
229
+ }
230
+ export function subagentsSummary(results) {
231
+ const tot = results.reduce((a, r) => a + (r.tokens || 0), 0);
232
+ console.log(" " + muted(results.length + " subagents · " + tot + " tokens · ") + accent(GLYPH.seal) + muted(" 0G Compute (TEE)"));
233
+ }
234
+
235
+ let spinTimer = null;
236
+ let spinFrame = 0;
237
+ const SPIN = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
238
+ export function thinking(model) {
239
+ if (!uiTTY) return;
240
+ clearThinking();
241
+ spinTimer = setInterval(() => {
242
+ const f = SPIN[spinFrame % SPIN.length];
243
+ const dots = ".".repeat(1 + ((spinFrame >> 1) % 3)); // . .. ... cycling
244
+ spinFrame++;
245
+ process.stdout.write("\r\x1b[K" + accent(f) + " " + muted("thinking on 0G · " + model) + accent(dots));
246
+ }, 80);
247
+ if (spinTimer.unref) spinTimer.unref();
248
+ }
249
+ export function clearThinking() {
250
+ if (spinTimer) {
251
+ clearInterval(spinTimer);
252
+ spinTimer = null;
253
+ }
254
+ if (uiTTY) process.stdout.write("\r\x1b[2K");
255
+ }
256
+
257
+ // ---- markdown rendering (terminal) ---------------------------------------
258
+ // Inline spans: code, links, bold, italic, strikethrough. Code is protected
259
+ // first so its contents are not re-styled.
260
+ function styleText(s) {
261
+ s = s.replace(/!?\[([^\]]+)\]\(([^)]+)\)/g, (_, t, u) => underlineFn(t) + muted(" (" + u + ")"));
262
+ s = s.replace(/\*\*([^*]+?)\*\*/g, (_, x) => strong(x));
263
+ s = s.replace(/__([^_]+?)__/g, (_, x) => strong(x));
264
+ s = s.replace(/\*([^\s*][^*]*?)\*/g, (_, x) => italic(x));
265
+ s = s.replace(/(^|[^\w])_([^\s_][^_]*?)_/g, (_, p, x) => p + italic(x));
266
+ s = s.replace(/~~([^~]+?)~~/g, (_, x) => muted(x));
267
+ return s;
268
+ }
269
+
270
+ function mdInline(s) {
271
+ // Split on backtick code spans (odd segments are code, protected from styling).
272
+ const parts = String(s).split("`");
273
+ let out = "";
274
+ for (let i = 0; i < parts.length; i++) {
275
+ if (i % 2 === 1 && i < parts.length - 1) out += mdCyan(parts[i]);
276
+ else if (i % 2 === 1) out += "`" + styleText(parts[i]);
277
+ else out += styleText(parts[i]);
278
+ }
279
+ return out;
280
+ }
281
+
282
+ function mdBlockLine(raw) {
283
+ const h = /^(#{1,6})\s+(.*)$/.exec(raw);
284
+ if (h) return accent(strong(mdInline(h[2])));
285
+ if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(raw)) return rule();
286
+ const bq = /^\s*>\s?(.*)$/.exec(raw);
287
+ if (bq) return muted("│ ") + mdInline(bq[1]);
288
+ const li = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(raw);
289
+ if (li) {
290
+ const bullet = /\d/.test(li[2]) ? accent(li[2] + " ") : accent("• ");
291
+ return (li[1] || "") + " " + bullet + mdInline(li[3]);
292
+ }
293
+ return mdInline(raw);
294
+ }
295
+
296
+ // ---- lightweight, dependency-free syntax highlighting for code blocks -----
297
+ const KW_JS = "const let var function return if else for while do switch case break continue default new delete typeof instanceof void yield await async import export from as class extends super this try catch finally throw of in null true false undefined static get set";
298
+ const KW_TS = KW_JS + " interface type enum implements public private protected readonly namespace keyof declare abstract override";
299
+ const KW_PY = "def return if elif else for while import from as class try except finally raise with lambda pass break continue global nonlocal yield async await and or not in is None True False self print len range";
300
+ const KW_SOL = "pragma solidity contract function returns return public private external internal view pure payable memory storage calldata mapping address uint uint256 int int256 bool string bytes bytes32 struct enum event emit modifier require revert assert constructor if else for while new import using library interface is override virtual constant immutable indexed msg block tx now this abstract receive fallback";
301
+ const KW_SH = "if then else elif fi for while do done case esac function return in export local echo cd source set unset read declare";
302
+ const KW_GO = "package import func var const type struct interface map chan go defer return if else for range switch case break continue default nil true false string int int64 error bool";
303
+ const KW_RS = "fn let mut const struct enum impl trait pub use mod match if else for while loop return self Self where async await move ref dyn Box Vec Option Result Some None Ok Err true false";
304
+ const KW_JSON = "true false null";
305
+ const toSet = (s) => new Set(s.split(/\s+/).filter(Boolean));
306
+ const KEYWORDS = {
307
+ js: toSet(KW_JS), jsx: toSet(KW_JS), mjs: toSet(KW_JS), cjs: toSet(KW_JS),
308
+ ts: toSet(KW_TS), tsx: toSet(KW_TS),
309
+ py: toSet(KW_PY), python: toSet(KW_PY),
310
+ sol: toSet(KW_SOL), solidity: toSet(KW_SOL),
311
+ sh: toSet(KW_SH), bash: toSet(KW_SH), shell: toSet(KW_SH), zsh: toSet(KW_SH),
312
+ go: toSet(KW_GO), golang: toSet(KW_GO),
313
+ rs: toSet(KW_RS), rust: toSet(KW_RS),
314
+ json: toSet(KW_JSON),
315
+ };
316
+ const LINE_COMMENT = {
317
+ js: "//", jsx: "//", mjs: "//", cjs: "//", ts: "//", tsx: "//",
318
+ sol: "//", solidity: "//", go: "//", rs: "//", rust: "//", c: "//", cpp: "//", java: "//",
319
+ py: "#", python: "#", sh: "#", bash: "#", shell: "#", zsh: "#", yaml: "#", yml: "#", toml: "#", rb: "#", ruby: "#",
320
+ };
321
+ const CLIKE = new Set(["js", "jsx", "mjs", "cjs", "ts", "tsx", "sol", "solidity", "go", "rs", "rust", "c", "cpp", "java"]);
322
+ const langKey = (lang) => String(lang || "").toLowerCase().replace(/^\./, "").trim();
323
+
324
+ // Tokenize one line of code into [{ t, v }] spans (t: kw|str|num|com|text).
325
+ // Pure and color-independent (no cross-line state, so it is safe for streaming).
326
+ export function tokenizeCode(line, lang) {
327
+ const key = langKey(lang);
328
+ const kw = KEYWORDS[key];
329
+ const lc = LINE_COMMENT[key];
330
+ const clike = CLIKE.has(key);
331
+ const toks = [];
332
+ const push = (t, v) => { if (v) toks.push({ t, v }); };
333
+ const n = line.length;
334
+ let i = 0, text = "";
335
+ const flush = () => { if (text) { push("text", text); text = ""; } };
336
+ while (i < n) {
337
+ const ch = line[i];
338
+ if (lc && line.startsWith(lc, i)) { flush(); push("com", line.slice(i)); i = n; break; }
339
+ if (clike && line.startsWith("/*", i)) {
340
+ const end = line.indexOf("*/", i + 2);
341
+ const stop = end === -1 ? n : end + 2;
342
+ flush(); push("com", line.slice(i, stop)); i = stop; continue;
343
+ }
344
+ if (ch === '"' || ch === "'" || ch === "`") {
345
+ let j = i + 1;
346
+ while (j < n && line[j] !== ch) { if (line[j] === "\\") j++; j++; }
347
+ flush(); push("str", line.slice(i, Math.min(j + 1, n))); i = j + 1; continue;
348
+ }
349
+ if (/[0-9]/.test(ch) && (i === 0 || /[^A-Za-z0-9_$]/.test(line[i - 1]))) {
350
+ let j = i; while (j < n && /[0-9a-fA-FxXoObB._]/.test(line[j])) j++;
351
+ flush(); push("num", line.slice(i, j)); i = j; continue;
352
+ }
353
+ if (/[A-Za-z_$@]/.test(ch)) {
354
+ let j = i; while (j < n && /[A-Za-z0-9_$]/.test(line[j])) j++;
355
+ const word = line.slice(i, j);
356
+ if (kw && kw.has(word)) { flush(); push("kw", word); } else text += word;
357
+ i = j; continue;
358
+ }
359
+ text += ch; i++;
360
+ }
361
+ flush();
362
+ return toks;
363
+ }
364
+
365
+ // Highlight one line of code for a TTY. Piped output stays raw.
366
+ export function highlightLine(line, lang) {
367
+ if (!useColor) return line;
368
+ const paint = { kw: accent, str: ok, num: warn, com: muted, text: (s) => s };
369
+ return tokenizeCode(line, lang).map((tk) => paint[tk.t](tk.v)).join("");
370
+ }
371
+
372
+ function renderMdTable(rows) {
373
+ const split = (r) => r.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
374
+ const all = rows.map(split);
375
+ const isSep = (cells) => cells.length && cells.every((c) => /^:?-{2,}:?$/.test(c) || c === "");
376
+ let header = null;
377
+ let bodyRows;
378
+ if (all.length >= 2 && isSep(all[1])) {
379
+ header = all[0];
380
+ bodyRows = all.slice(2);
381
+ } else if (all.length && isSep(all[0])) {
382
+ bodyRows = all.slice(1);
383
+ } else {
384
+ bodyRows = all;
385
+ }
386
+ const ncol = Math.max(...all.map((r) => r.length));
387
+ const disp = header ? [header, ...bodyRows] : bodyRows;
388
+ const rendered = disp.map((r) => Array.from({ length: ncol }, (_, c) => mdInline(r[c] || "")));
389
+ const widths = Array.from({ length: ncol }, (_, c) => Math.max(3, ...rendered.map((r) => vlen(r[c]))));
390
+ const out = [];
391
+ rendered.forEach((r, i) => {
392
+ const isHead = header && i === 0;
393
+ out.push(" " + r.map((cell, c) => pad(isHead ? strong(cell) : cell, widths[c])).join(" "));
394
+ if (isHead) out.push(" " + widths.map((w) => muted(GLYPH.rule.repeat(w))).join(" "));
395
+ });
396
+ return out.map((l) => l.replace(/[ \t]+$/, "")).join("\n") + "\n";
397
+ }
398
+
399
+ // Streaming, line-buffered markdown renderer. Complete lines render as they
400
+ // arrive (so it still streams); inline spans and tables need only one line /
401
+ // block of context, so chunk boundaries mid-token are handled correctly.
402
+ function makeMdRenderer(write) {
403
+ let buffer = "";
404
+ let inFence = false;
405
+ let fenceLang = "";
406
+ let table = [];
407
+ const flushTable = () => {
408
+ if (table.length) {
409
+ write(renderMdTable(table));
410
+ table = [];
411
+ }
412
+ };
413
+ const doLine = (raw) => {
414
+ if (/^\s*```/.test(raw)) {
415
+ flushTable();
416
+ if (!inFence) {
417
+ inFence = true;
418
+ const info = raw.replace(/^\s*```/, "").trim();
419
+ fenceLang = info.split(/\s+/)[0] || "";
420
+ write(muted(" " + (info || "code")) + "\n");
421
+ } else {
422
+ inFence = false;
423
+ fenceLang = "";
424
+ }
425
+ return;
426
+ }
427
+ if (inFence) {
428
+ write(" " + muted("│ ") + highlightLine(raw, fenceLang) + "\n");
429
+ return;
430
+ }
431
+ if (/^\s*\|.*\|/.test(raw)) {
432
+ table.push(raw);
433
+ return;
434
+ }
435
+ flushTable();
436
+ write(mdBlockLine(raw) + "\n");
437
+ };
438
+ return {
439
+ push(text) {
440
+ buffer += String(text);
441
+ let i;
442
+ while ((i = buffer.indexOf("\n")) !== -1) {
443
+ doLine(buffer.slice(0, i));
444
+ buffer = buffer.slice(i + 1);
445
+ }
446
+ },
447
+ end() {
448
+ if (buffer.length) {
449
+ doLine(buffer);
450
+ buffer = "";
451
+ }
452
+ flushTable();
453
+ inFence = false;
454
+ },
455
+ };
456
+ }
457
+
458
+ // Full-string render (piped output stays raw, so it remains greppable).
459
+ export function renderMarkdown(text) {
460
+ if (!uiTTY) return String(text);
461
+ let out = "";
462
+ const r = makeMdRenderer((s) => {
463
+ out += s;
464
+ });
465
+ r.push(String(text));
466
+ r.end();
467
+ return out.replace(/\n+$/, "");
468
+ }
469
+
470
+ // Live stream for the agent's answer: renders each completed line as markdown.
471
+ export function assistantStream() {
472
+ if (!uiTTY) return { push: (t) => process.stdout.write(t), end() {} };
473
+ process.stdout.write("\n");
474
+ return makeMdRenderer((s) => process.stdout.write(s));
475
+ }
476
+
477
+ export function assistant(text) {
478
+ console.log("\n" + renderMarkdown(text) + "\n");
479
+ }
480
+ export function info(text) {
481
+ console.log(muted(text));
482
+ }
483
+ export function error(text) {
484
+ console.log(err(text));
485
+ }
486
+ export function streamChunk(s) {
487
+ process.stdout.write(s);
488
+ }
489
+
490
+ export function hud(model, usage, effort) {
491
+ const i = usage?.prompt_tokens ?? usage?.input_tokens;
492
+ const o = usage?.completion_tokens ?? usage?.output_tokens;
493
+ const toks = i != null || o != null ? `${i ?? "?"} in / ${o ?? "?"} out · ` : "";
494
+ const eff = effort ? "effort " + effort + " · " : "";
495
+ console.log(
496
+ muted(" · " + toks + model + " · " + eff) + accent(GLYPH.seal) + muted(" 0G Compute (TEE)")
497
+ );
498
+ }
499
+
500
+ export function fmtTok(n) {
501
+ n = Number(n) || 0;
502
+ return n >= 1000 ? (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k" : String(n);
503
+ }
504
+
505
+ // A divider shown just above the input prompt: a rule that separates each turn,
506
+ // with the session status (model, effort, cumulative tokens, estimated cost)
507
+ // right-aligned on it. The `z0g ›` prompt stays below it.
508
+ export function sessionBar({ model, effort, inTok, outTok, cost }) {
509
+ const total = (Number(inTok) || 0) + (Number(outTok) || 0);
510
+ const parts = [];
511
+ if (model) parts.push(model);
512
+ if (effort) parts.push("effort " + effort);
513
+ parts.push(fmtTok(total) + " tok");
514
+ if (cost != null) parts.push("~$" + cost.toFixed(cost < 0.01 ? 4 : 3));
515
+ const status = parts.join(" · ");
516
+ const ruleW = Math.max(2, cols - vlen(status) - 4);
517
+ return muted(GLYPH.rule.repeat(ruleW) + " " + status + " ");
518
+ }
519
+
520
+ // Compact plan header (printed repeatedly mid-run, so no rule).
521
+ export function renderPlan(plan) {
522
+ if (!Array.isArray(plan) || plan.length === 0) return;
523
+ const done = plan.filter((p) => p.status === "completed").length;
524
+ const tick = GLYPH.tick ? accent(GLYPH.tick + " ") : "";
525
+ console.log(" " + tick + strong("Plan") + " " + muted(done + "/" + plan.length));
526
+ for (const p of plan) {
527
+ let mark;
528
+ let label;
529
+ if (p.status === "completed") {
530
+ mark = ok(GLYPH.ok);
531
+ label = muted(p.step);
532
+ } else if (p.status === "in_progress") {
533
+ mark = accent(GLYPH.run);
534
+ label = strong(p.step);
535
+ } else {
536
+ mark = muted(GLYPH.pending);
537
+ label = p.step;
538
+ }
539
+ console.log(" " + mark + " " + label);
540
+ }
541
+ }
542
+
543
+ // ---- trust taxonomy (accurate to the API) --------------------------------
544
+ // private = TeeML (0G's own + glm-5.2). verifiable = TeeTLS attested. open = none.
545
+ export function trustTier(m) {
546
+ if (m.private) return { glyph: GLYPH.priv, short: "priv", long: "private", role: accent };
547
+ if (m.verifiable) return { glyph: GLYPH.ver, short: "ver", long: "verifiable", role: ok };
548
+ return { glyph: GLYPH.open, short: "open", long: "open", role: muted };
549
+ }
550
+ function trustCell(m) {
551
+ const t = trustTier(m);
552
+ return t.role((t.glyph ? t.glyph + " " : "") + t.short);
553
+ }
554
+
555
+ // ---- `z0g models` table --------------------------------------------------
556
+ function bandOf(m) {
557
+ if (String(m.id).startsWith("0gm")) return "0G native";
558
+ if (m.private || m.verifiable) return "Verifiable (TEE)";
559
+ return "Open (proxied)";
560
+ }
561
+ function modelRow(m, currentId) {
562
+ const cur = m.id === currentId;
563
+ const gutter = cur ? accent(GLYPH.chevron + " ") : " ";
564
+ const id = trunc(m.id, 20);
565
+ const idCell = pad(cur ? accent(strong(id)) : id, 20);
566
+ const ctx = muted(pad(fmtCtx(m.ctx), 5, "right"));
567
+ const out = muted(pad(m.maxOut ? fmtCtx(m.maxOut) : "-", 5, "right"));
568
+ const pin = muted(pad(fmtUsd(m.inPerM), 7, "right"));
569
+ const pout = muted(pad(fmtUsd(m.outPerM), 7, "right"));
570
+ const save = pad(m.discount != null ? ok(fmtSave(m.discount)) : "", 6, "right");
571
+ const trust = trustCell(m); // last column, no trailing pad
572
+ return gutter + idCell + " " + ctx + " " + out + " " + pin + " " + pout + " " + save + " " + trust;
573
+ }
574
+ function colHeader() {
575
+ return (
576
+ " " + pad("MODEL", 20) + " " + pad("CTX", 5, "right") + " " + pad("MAX", 5, "right") +
577
+ " " + pad("$IN", 7, "right") + " " + pad("$OUT", 7, "right") + " " + pad("SAVE", 6, "right") +
578
+ " " + "TRUST"
579
+ );
580
+ }
581
+ export function renderModelsTable(models, { currentId } = {}) {
582
+ const chat = orderChatModels(models, currentId);
583
+ const media = mediaModels(models);
584
+ const out = [];
585
+ out.push(section("Models", "0G Router · " + models.length));
586
+ out.push(muted(colHeader()));
587
+ let band = null;
588
+ for (const m of chat) {
589
+ const b = bandOf(m);
590
+ if (b !== band) {
591
+ band = b;
592
+ out.push(" " + muted(band));
593
+ }
594
+ out.push(modelRow(m, currentId));
595
+ }
596
+ if (media.length) {
597
+ out.push("");
598
+ out.push(" " + muted("Media models"));
599
+ for (const m of media) {
600
+ const kind = m.type === "speech-to-text" ? "speech" : m.type === "text-to-image" ? "image" : m.type;
601
+ out.push(" " + pad(trunc(m.id, 20), 20) + " " + pad(trustCell(m), 8) + " " + muted(kind));
602
+ }
603
+ }
604
+ out.push("");
605
+ out.push(rule());
606
+ out.push(
607
+ " " + accent(GLYPH.priv) + " " + muted("private (TEE) ") + ok(GLYPH.ver) + " " +
608
+ muted("verifiable ") + muted(GLYPH.open + " open ") +
609
+ muted("SAVE vs official API · prices per 1M tokens")
610
+ );
611
+ out.push(" " + accent(GLYPH.seal) + " " + muted("all inference runs in a TEE on 0G Compute. Switch: ") + accent("z0g --model <id>") + muted(" or ") + accent("/model"));
612
+ return out.join("\n");
613
+ }
614
+
615
+ // Numbered fallback list (non-TTY / piped) for the model picker.
616
+ export function renderModelsPickList(models, currentId) {
617
+ const out = [strong("Select a 0G model:")];
618
+ models.forEach((m, i) => {
619
+ const cur = m.id === currentId ? muted(" (current)") : "";
620
+ out.push(
621
+ " " + pad(String(i + 1) + ")", 4) + " " + pad(trunc(m.id, 20), 20) + " " +
622
+ pad(fmtCtx(m.ctx), 5, "right") + " " + fmtUsd(m.inPerM) + "/" + fmtUsd(m.outPerM) +
623
+ " " + trustCell(m) + cur
624
+ );
625
+ });
626
+ out.push(muted(" Type a number or a model id. Anything else cancels."));
627
+ return out.join("\n");
628
+ }
629
+
630
+ // ---- `/model` arrow-key picker frame -------------------------------------
631
+ export function modelPickerFrame(items, index, currentId) {
632
+ const W = Math.max(24, Math.min(process.stdout.columns || 80, 100));
633
+ const termRows = process.stdout.rows || 24;
634
+ const lines = [];
635
+ const tick = GLYPH.tick ? accent(GLYPH.tick + " ") : "";
636
+ lines.push(clip(tick + strong("Select model") + muted(" " + GLYPH.up + "/" + GLYPH.down + " move · enter select · esc cancel"), W));
637
+ lines.push("");
638
+ lines.push(clip(" " + muted(pad("MODEL", 20) + " " + pad("TRUST", 8) + " PRICE $/1M in·out"), W));
639
+
640
+ // Window to the terminal height: the frame must never exceed the viewport,
641
+ // or arrowSelect's in-place rewind (moveCursor up) desyncs and corrupts it.
642
+ const CHROME = 8; // hint + blank + colheader + 2 "more" rows + blank + 2 detail lines
643
+ const win = Math.max(1, termRows - CHROME - 1); // -1 for arrowSelect's trailing newline
644
+ let start = 0;
645
+ if (items.length > win) start = Math.min(Math.max(0, index - (win >> 1)), items.length - win);
646
+ const end = Math.min(items.length, start + win);
647
+
648
+ lines.push(start > 0 ? " " + muted(GLYPH.up + " " + start + " more") : "");
649
+ for (let i = start; i < end; i++) {
650
+ const m = items[i];
651
+ const sel = i === index;
652
+ const isCur = m.id === currentId;
653
+ let gutter;
654
+ if (sel) gutter = useColor ? accent(GLYPH.chevron + " ") : GLYPH.point + " ";
655
+ else if (isCur) gutter = ok(GLYPH.current + " ");
656
+ else gutter = " ";
657
+ const t = trustTier(m);
658
+ const price = fmtUsd(m.inPerM) + "/" + fmtUsd(m.outPerM);
659
+ const rowText = pad(trunc(m.id, 20), 20) + " " + pad((t.glyph ? t.glyph + " " : "") + t.short, 8) + " " + price;
660
+ let painted;
661
+ if (sel) painted = useColor ? reverse(rowText + " ") : rowText;
662
+ else if (isCur) painted = strong(rowText);
663
+ else painted = rowText;
664
+ lines.push(clip(gutter + painted, W));
665
+ }
666
+ lines.push(end < items.length ? " " + muted(GLYPH.down + " " + (items.length - end) + " more") : "");
667
+ lines.push("");
668
+
669
+ const m = items[index] || items[0];
670
+ const t = trustTier(m);
671
+ const price = "in " + fmtUsd(m.inPerM) + " out " + fmtUsd(m.outPerM);
672
+ const disc = m.discount != null ? " · " + m.discount + "% off" : "";
673
+ const idShown = trunc(m.id, Math.max(0, W - 2));
674
+ const nameStr = m.name ? " · " + m.name : "";
675
+ lines.push(clip(" " + strong(idShown) + muted(nameStr), W));
676
+ const plain2 = " " + price + " /1M · ctx " + fmtCtx(m.ctx) + " · " + t.long + disc;
677
+ lines.push(
678
+ vlen(plain2) <= W
679
+ ? " " + muted(price + " /1M · ctx " + fmtCtx(m.ctx) + " · ") + t.role(t.long) + muted(disc)
680
+ : clip(muted(plain2), W)
681
+ );
682
+ return lines.join("\n");
683
+ }
684
+ export function pickConfirm(id) {
685
+ return " " + ok(GLYPH.ok) + " model set to " + strong(id) + muted(" (saved)");
686
+ }
687
+
688
+ // ---- session picker ------------------------------------------------------
689
+ export function relTime(iso) {
690
+ const t = Date.parse(iso);
691
+ if (!t) return "";
692
+ const s = Math.max(0, (Date.now() - t) / 1000);
693
+ if (s < 60) return "just now";
694
+ const m = Math.floor(s / 60);
695
+ if (m < 60) return m + "m ago";
696
+ const h = Math.floor(m / 60);
697
+ if (h < 24) return h + "h ago";
698
+ const d = Math.floor(h / 24);
699
+ if (d < 7) return d + "d ago";
700
+ const w = Math.floor(d / 7);
701
+ if (w < 5) return w + "w ago";
702
+ return Math.floor(d / 30) + "mo ago";
703
+ }
704
+
705
+ // Items are [{ __new: true }, ...sessions{ id, title, updated, messageCount }].
706
+ export function sessionPickerFrame(items, index, ctx = {}) {
707
+ const { filter = "", filtering = false } = ctx;
708
+ const W = Math.max(24, Math.min(process.stdout.columns || 80, 100));
709
+ const termRows = process.stdout.rows || 24;
710
+ const lines = [];
711
+ const tick = GLYPH.tick ? accent(GLYPH.tick + " ") : "";
712
+ lines.push(clip(tick + strong("Sessions") + muted(" " + GLYPH.up + "/" + GLYPH.down + " move · enter open · type to search · ctrl-r rename · ctrl-x delete · esc"), W));
713
+ lines.push("");
714
+
715
+ const CHROME = 6; // header + blank + 2 "more" rows + blank + footer
716
+ const win = Math.max(1, termRows - CHROME - 1); // -1 for arrowSelect's trailing newline
717
+ let start = 0;
718
+ if (items.length > win) start = Math.min(Math.max(0, index - (win >> 1)), items.length - win);
719
+ const end = Math.min(items.length, start + win);
720
+
721
+ lines.push(start > 0 ? " " + muted(GLYPH.up + " " + start + " more") : "");
722
+ for (let i = start; i < end; i++) {
723
+ const it = items[i];
724
+ const sel = i === index;
725
+ const gutter = sel ? (useColor ? accent(GLYPH.chevron + " ") : GLYPH.point + " ") : " ";
726
+ let painted;
727
+ if (it.__new) {
728
+ const t = "+ New chat";
729
+ painted = sel ? (useColor ? reverse(t + " ") : t) : accent(t);
730
+ } else {
731
+ const title = pad(trunc(it.title || "New chat", 40), 40);
732
+ const n = it.messageCount || 0;
733
+ const meta = relTime(it.updated) + " · " + n + " msg" + (n === 1 ? "" : "s");
734
+ painted = sel ? (useColor ? reverse(title + " " + meta + " ") : title + " " + meta) : title + muted(" " + meta);
735
+ }
736
+ lines.push(clip(gutter + painted, W));
737
+ }
738
+ lines.push(end < items.length ? " " + muted(GLYPH.down + " " + (items.length - end) + " more") : "");
739
+ lines.push("");
740
+
741
+ const nSessions = items.filter((it) => !it.__new).length;
742
+ const footer = filtering && filter
743
+ ? 'search: "' + filter + '" · ' + nSessions + " match" + (nSessions === 1 ? "" : "es")
744
+ : nSessions + " session" + (nSessions === 1 ? "" : "s");
745
+ lines.push(" " + muted(footer));
746
+ return lines.join("\n");
747
+ }
748
+
749
+ // ---- diff (unchanged behavior, role colors) ------------------------------
750
+ export function renderDiff(oldText, newText, { maxLines = 80 } = {}) {
751
+ const a = (oldText || "").split("\n");
752
+ const b = (newText || "").split("\n");
753
+ if (a.length > 2000 || b.length > 2000) {
754
+ return muted(` (diff omitted: ${a.length} to ${b.length} lines)`);
755
+ }
756
+ const ops = diffLines(a, b);
757
+ const out = [];
758
+ for (const op of ops) {
759
+ if (op.t === " ") continue;
760
+ const paint = op.t === "+" ? ok : err;
761
+ out.push(" " + paint(op.t + " " + op.s));
762
+ if (out.length >= maxLines) {
763
+ out.push(muted(" ... (diff truncated)"));
764
+ break;
765
+ }
766
+ }
767
+ return out.length ? out.join("\n") : muted(" (no textual change)");
768
+ }
769
+
770
+ function diffLines(a, b) {
771
+ const n = a.length;
772
+ const m = b.length;
773
+ const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
774
+ for (let i = n - 1; i >= 0; i--) {
775
+ for (let j = m - 1; j >= 0; j--) {
776
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
777
+ }
778
+ }
779
+ const ops = [];
780
+ let i = 0;
781
+ let j = 0;
782
+ while (i < n && j < m) {
783
+ if (a[i] === b[j]) {
784
+ ops.push({ t: " ", s: a[i] });
785
+ i++;
786
+ j++;
787
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
788
+ ops.push({ t: "-", s: a[i] });
789
+ i++;
790
+ } else {
791
+ ops.push({ t: "+", s: b[j] });
792
+ j++;
793
+ }
794
+ }
795
+ while (i < n) ops.push({ t: "-", s: a[i++] });
796
+ while (j < m) ops.push({ t: "+", s: b[j++] });
797
+ return ops;
798
+ }