sigrank 0.0.212 → 0.0.214

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/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://raw.githubusercontent.com/anthropics/mcpb/main/schemas/mcpb-manifest-v0.4.schema.json",
3
3
  "manifest_version": "0.4",
4
4
  "name": "sigrank",
5
- "version": "0.0.211",
5
+ "version": "0.0.213",
6
6
  "description": "Ranks AI coding operators by token cascade efficiency (Yield = cache_read \u00d7 output / input\u00b2). 23 tools for yield calculation, local token usage pulls, signed submission, and tokscale-powered analytics (market share, developer profiles, model trends, cost analysis, device profiles, MCP usage, competitive intelligence). Privacy-first: runs locally, only four token counts leave the machine.",
7
7
  "author": {
8
8
  "name": "SunrisesIllNeverSee",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigrank",
3
- "version": "0.0.212",
3
+ "version": "0.0.214",
4
4
  "author": "SunrisesIllNeverSee (https://github.com/SunrisesIllNeverSee)",
5
5
  "description": "SigRank MCP server — AI operator evaluation and token cascade efficiency tools. The yield cascade + live leaderboard as MCP tools any agent can call.",
6
6
  "mcpName": "io.github.SunrisesIllNeverSee/sigrank-mcp",
@@ -82,7 +82,9 @@
82
82
  ],
83
83
  "dependencies": {
84
84
  "@modelcontextprotocol/sdk": "^1.0.0",
85
+ "@resvg/resvg-js": "^2.6.2",
85
86
  "ccusage": "^20.0.19",
87
+ "gifenc": "^1.0.3",
86
88
  "tokscale": "^4.7.0"
87
89
  },
88
90
  "overrides": {
@@ -0,0 +1,122 @@
1
+ // tui-themes.mjs — centralized ANSI color palette with multiple themes
2
+ //
3
+ // Tokscale-inspired: all colors live in one module, render code references
4
+ // them through `currentTheme`. Supports dark (default), light, high-contrast,
5
+ // and monochrome themes for accessibility.
6
+ //
7
+ // Usage in tui.mjs:
8
+ // import { currentTheme as c, setTheme } from "./tui-themes.mjs";
9
+ // // c.gold, c.cyan, c.reset, etc. — same interface as the old `c` object
10
+ //
11
+ // CLI:
12
+ // node tui.mjs --theme dark # default
13
+ // node tui.mjs --theme light # light terminal
14
+ // node tui.mjs --theme high-contrast # visually impaired
15
+ // node tui.mjs --theme monochrome # colorblind
16
+
17
+ const ESC = "\x1b[";
18
+
19
+ export const THEMES = {
20
+ // ── Dark (default) — current palette, designed for dark terminals ──
21
+ dark: {
22
+ name: "dark",
23
+ reset: `${ESC}0m`,
24
+ bold: `${ESC}1m`,
25
+ dim: `${ESC}2m`,
26
+ gold: `${ESC}33m`,
27
+ boldGold: `${ESC}1;33m`,
28
+ cyan: `${ESC}36m`,
29
+ boldCyan: `${ESC}1;36m`,
30
+ green: `${ESC}32m`,
31
+ red: `${ESC}31m`,
32
+ white: `${ESC}97m`,
33
+ boldWhite: `${ESC}1;97m`,
34
+ magenta: `${ESC}35m`,
35
+ blue: `${ESC}34m`,
36
+ bgDim: `${ESC}48;5;236m`,
37
+ bgCyan: `${ESC}48;5;23m`,
38
+ // Medal backgrounds (256-color) — gold/silver/bronze podium tints
39
+ medalBg: { 1: 220, 2: 250, 3: 130 },
40
+ medalFg: `${ESC}38;5;232m`, // dark text on medal bg
41
+ },
42
+
43
+ // ── Light — dark text on light/white terminals ──
44
+ // Colors adjusted for contrast against white/light backgrounds.
45
+ light: {
46
+ name: "light",
47
+ reset: `${ESC}0m`,
48
+ bold: `${ESC}1m`,
49
+ dim: `${ESC}2m`,
50
+ gold: `${ESC}33m`, // yellow is readable on white
51
+ boldGold: `${ESC}1;33m`,
52
+ cyan: `${ESC}36m`, // cyan is readable on white
53
+ boldCyan: `${ESC}1;36m`,
54
+ green: `${ESC}32m`, // standard green (darker shade would be better but ANSI 32 is the option)
55
+ red: `${ESC}31m`, // standard red
56
+ white: `${ESC}30m`, // BLACK text on light bg (was bright white)
57
+ boldWhite: `${ESC}1;30m`, // bold black
58
+ magenta: `${ESC}35m`,
59
+ blue: `${ESC}34m`, // blue is readable on white
60
+ bgDim: `${ESC}48;5;250m`, // light grey bg for active tab
61
+ bgCyan: `${ESC}48;5;194m`,// light teal bg for active tab
62
+ medalBg: { 1: 220, 2: 250, 3: 130 },
63
+ medalFg: `${ESC}38;5;232m`,
64
+ },
65
+
66
+ // ── High contrast — bold everything, no dim, maximum readability ──
67
+ highContrast: {
68
+ name: "high-contrast",
69
+ reset: `${ESC}0m`,
70
+ bold: `${ESC}1m`,
71
+ dim: `${ESC}0;37m`, // NO dim — full brightness white instead
72
+ gold: `${ESC}1;33m`, // bold gold
73
+ boldGold: `${ESC}1;33m`,
74
+ cyan: `${ESC}1;36m`, // bold cyan
75
+ boldCyan: `${ESC}1;36m`,
76
+ green: `${ESC}1;32m`, // bold green
77
+ red: `${ESC}1;31m`, // bold red
78
+ white: `${ESC}1;97m`, // bold bright white
79
+ boldWhite: `${ESC}1;97m`,
80
+ magenta: `${ESC}1;35m`, // bold magenta
81
+ blue: `${ESC}1;34m`, // bold blue
82
+ bgDim: `${ESC}48;5;238m`, // slightly lighter dark bg
83
+ bgCyan: `${ESC}48;5;24m`, // slightly lighter teal bg
84
+ medalBg: { 1: 220, 2: 250, 3: 130 },
85
+ medalFg: `${ESC}1;38;5;232m`,
86
+ },
87
+
88
+ // ── Monochrome — bold/normal only, no color (colorblind-safe) ──
89
+ monochrome: {
90
+ name: "monochrome",
91
+ reset: `${ESC}0m`,
92
+ bold: `${ESC}1m`,
93
+ dim: `${ESC}2m`, // dim is fine in monochrome (it's weight, not color)
94
+ gold: `${ESC}1m`, // bold only (no color)
95
+ boldGold: `${ESC}1m`,
96
+ cyan: `${ESC}0m`, // normal weight
97
+ boldCyan: `${ESC}1m`, // bold
98
+ green: `${ESC}0m`, // normal
99
+ red: `${ESC}1m`, // bold (errors stand out by weight)
100
+ white: `${ESC}0m`, // normal
101
+ boldWhite: `${ESC}1m`, // bold
102
+ magenta: `${ESC}0m`, // normal
103
+ blue: `${ESC}0m`, // normal
104
+ bgDim: `${ESC}7m`, // reverse video for active tab (no color)
105
+ bgCyan: `${ESC}7m`, // reverse video
106
+ medalBg: {}, // no medal colors in monochrome
107
+ medalFg: `${ESC}1m`, // bold for medal winners
108
+ },
109
+ };
110
+
111
+ export let currentTheme = THEMES.dark;
112
+
113
+ export function setTheme(name) {
114
+ if (THEMES[name]) {
115
+ currentTheme = THEMES[name];
116
+ }
117
+ return currentTheme;
118
+ }
119
+
120
+ export function getThemeNames() {
121
+ return Object.keys(THEMES);
122
+ }
@@ -22,6 +22,7 @@ import { execFile } from "child_process";
22
22
  import { existsSync, readFileSync } from "fs";
23
23
  import os from "os";
24
24
  import path from "path";
25
+ import { pathToFileURL } from "node:url";
25
26
  import { pkgVersion } from "../lib/pkg-version.mjs";
26
27
  import { LEADERBOARD_METRIC } from "../lib/constants.mjs";
27
28
 
@@ -49,24 +50,20 @@ export function dashboardEnabledPlatforms({
49
50
  }
50
51
 
51
52
  // ── ANSI ───────────────────────────────────────────────────────────────────
53
+ // Theme system: colors are centralized in tui-themes.mjs (tokscale-inspired).
54
+ // The `c` object is a live proxy to the current theme — changing the theme
55
+ // via setTheme() updates all colors. Supports dark/light/high-contrast/monochrome.
56
+ import * as _themeMod from "./tui-themes.mjs";
52
57
  const ESC = "\x1b[";
53
- const c = {
54
- reset: `${ESC}0m`,
55
- bold: `${ESC}1m`,
56
- dim: `${ESC}2m`,
57
- gold: `${ESC}33m`,
58
- boldGold: `${ESC}1;33m`,
59
- cyan: `${ESC}36m`,
60
- boldCyan: `${ESC}1;36m`,
61
- green: `${ESC}32m`,
62
- red: `${ESC}31m`,
63
- white: `${ESC}97m`,
64
- boldWhite: `${ESC}1;97m`,
65
- magenta: `${ESC}35m`,
66
- blue: `${ESC}34m`,
67
- bgDim: `${ESC}48;5;236m`, // dark grey bg for active tab
68
- bgCyan: `${ESC}48;5;23m`, // dark teal bg for active tab
69
- };
58
+ // `c` is a live proxy — reads currentTheme at access time so theme switches
59
+ // take effect immediately. The helper functions below (paint/bold/dim/etc.)
60
+ // read from `c` at call time, so they always use the active theme's colors.
61
+ const c = new Proxy({}, {
62
+ get(_target, prop) {
63
+ return _themeMod.currentTheme[prop];
64
+ },
65
+ });
66
+ const setTheme = _themeMod.setTheme;
70
67
  const paint = (col, s) => `${col}${s}${c.reset}`;
71
68
  const bold = (s) => paint(c.bold, s);
72
69
  const dim = (s) => paint(c.dim, s);
@@ -177,7 +174,7 @@ function ansiTrunc(s, w) {
177
174
  vis++;
178
175
  i++;
179
176
  }
180
- return out + "\x1b[0m"; // reset so a cut mid-color doesn't bleed into next line
177
+ return out + c.reset; // reset so a cut mid-color doesn't bleed into next line
181
178
  }
182
179
  function padEnd(s, w) {
183
180
  const v = stripAnsi(s).length;
@@ -644,8 +641,14 @@ function renderDashboard(data, status = "", scrollOffset = 0) {
644
641
  // Responsive: the full 12-col table is ~124 wide. On terminals narrower than
645
642
  // that, drop the two derived columns (Vel, 10x) and tighten the inter-column
646
643
  // gap from 2 spaces to 1, so the core pillars + Υ/SNR/Lev/Class still fit.
644
+ // On very narrow terminals (<100), also hide Class and shorten cache headers
645
+ // to CW/CR so the core pillars + Υ/SNR/Lev still fit at 80 cols.
647
646
  const narrow = w < 124;
647
+ const veryNarrow = w < 100;
648
+ const ultraNarrow = w < 90;
648
649
  const gap = narrow ? " " : " ";
650
+ const cwLabel = veryNarrow ? "CW" : "CacheW";
651
+ const crLabel = veryNarrow ? "CR" : "CacheR";
649
652
  const renderRow = (label, colorFn, winKey, p, est = false) => {
650
653
  const cas = cascadeFrom(p);
651
654
  if (!cas) return;
@@ -665,15 +668,15 @@ function renderDashboard(data, status = "", scrollOffset = 0) {
665
668
  ),
666
669
  padStart((p.cacheRead ?? 0) > 0 ? fmtTok(p.cacheRead) : dim("—"), 9),
667
670
  padStart(cas.yield > 10000 ? gold(fmtY(cas.yield)) : fmtY(cas.yield), 9),
668
- padStart(fmtSNR(cas.snr), 7),
669
- padStart(fmtLev(cas.leverage) + "×", 7),
671
+ ...(ultraNarrow ? [] : [padStart(fmtSNR(cas.snr), 7)]),
672
+ ...(ultraNarrow ? [] : [padStart(fmtLev(cas.leverage) + "×", 7)]),
670
673
  ...(narrow
671
674
  ? []
672
675
  : [
673
676
  padStart(cas.velocity?.toFixed(2) ?? "—", 6),
674
677
  padStart(cas.dev10x?.toFixed(2) ?? "—", 6),
675
678
  ]),
676
- padEnd(clsFn(cas.class), 13),
679
+ ...(veryNarrow ? [] : [padEnd(clsFn(cas.class), 13)]),
677
680
  ];
678
681
  emit(` ${cols.join(gap)}`);
679
682
  };
@@ -689,16 +692,16 @@ function renderDashboard(data, status = "", scrollOffset = 0) {
689
692
  padEnd(hdr("Win"), 5),
690
693
  padStart(hdr("Input"), 8),
691
694
  padStart(hdr("Output"), 8),
692
- padStart(hdr("CacheW"), 8),
693
- padStart(hdr("CacheR"), 9),
695
+ padStart(hdr(cwLabel), 8),
696
+ padStart(hdr(crLabel), 9),
694
697
  padStart(hdr("Υ Yield"), 9),
695
- padStart(hdr("SNR"), 7),
696
- padStart(hdr("Lev"), 7),
698
+ ...(ultraNarrow ? [] : [padStart(hdr("SNR"), 7)]),
699
+ ...(ultraNarrow ? [] : [padStart(hdr("Lev"), 7)]),
697
700
  ...(narrow ? [] : [padStart(hdr("Vel"), 6), padStart(hdr("10x"), 6)]),
698
- padEnd(hdr("Class"), 13),
701
+ ...(veryNarrow ? [] : [padEnd(hdr("Class"), 13)]),
699
702
  ];
700
703
  emit(` ${CH.join(gap)}`);
701
- emit(` ${dim("·".repeat(Math.max(0, Math.min(w - 4, narrow ? 96 : 114))))}`);
704
+ emit(` ${dim("·".repeat(Math.max(0, Math.min(w - 4, narrow ? (ultraNarrow ? 70 : veryNarrow ? 80 : 96) : 114))))}`);
702
705
 
703
706
  if (active.length === 0) {
704
707
  // Count is derived from the registry — a hardcoded number drifts every time an
@@ -1215,8 +1218,12 @@ function renderCompare(data) {
1215
1218
  ].filter((s) => Object.keys(s.pillars).length > 0);
1216
1219
 
1217
1220
  emit();
1221
+ // Shorten the source list on narrow terminals to avoid header overflow.
1222
+ const sourceList = w < 90
1223
+ ? `${SOURCES.length} sources`
1224
+ : "tokenpull vs ccusage vs token-dash vs tokscale";
1218
1225
  emit(
1219
- ` ${bold("Source Comparison")} ${dim(`platform: ${platform}`)} ${dim("· tokenpull vs ccusage vs token-dash vs tokscale")}`,
1226
+ ` ${bold("Source Comparison")} ${dim(`platform: ${platform}`)} ${dim(`· ${sourceList}`)}`,
1220
1227
  );
1221
1228
 
1222
1229
  if (!tpData) {
@@ -1305,6 +1312,28 @@ function renderCompare(data) {
1305
1312
  emit();
1306
1313
  }
1307
1314
 
1315
+ // Fill the dead-space between the source table and cascade metrics with a
1316
+ // log-scale Υ Yield comparison bar — owner wants more mini-graphs, and this
1317
+ // gap was flagged as dead-space by the audit.
1318
+ if (used < budget - 6 && SOURCES.length > 0) {
1319
+ const yields = SOURCES.map((s) => {
1320
+ const p = s.pillars["all"];
1321
+ const cas = p ? cascadeFrom(p) : null;
1322
+ return { name: s.name, color: s.color, yield: cas?.yield ?? 0 };
1323
+ }).filter((y) => y.yield > 0);
1324
+ if (yields.length > 0) {
1325
+ const maxYield = Math.max(...yields.map((y) => y.yield));
1326
+ const maxLog = Math.log10(maxYield) || 1;
1327
+ const barW = Math.min(Math.max(w - 40, 15), 30);
1328
+ emit(` ${dim("Υ Yield comparison (log scale)")}`);
1329
+ for (const y of yields) {
1330
+ const { bar } = logBar(y.yield, maxLog, barW, c.cyan);
1331
+ emit(` ${padEnd(y.color(y.name), 12)} ${bar} ${fmtY(y.yield)}`);
1332
+ }
1333
+ emit();
1334
+ }
1335
+ }
1336
+
1308
1337
  if (used < budget - 4) {
1309
1338
  emit(` ${hr()}`);
1310
1339
  emit(
@@ -1387,8 +1416,12 @@ function renderSubmissions(
1387
1416
  ? `${dim(" · you highlighted")}`
1388
1417
  : "";
1389
1418
  emit();
1419
+ // Shorten the header on narrow terminals to avoid overflow.
1420
+ const boardHeader = w < 90
1421
+ ? `window: ${window} · ranked by Υ Yield`
1422
+ : `window: ${window} · all submissions ranked by Υ Yield · signalaf.com`;
1390
1423
  emit(
1391
- ` ${bold("Submissions")} ${dim(`window: ${window} · all submissions ranked by Υ Yield · signalaf.com`)}${modeLabel}`,
1424
+ ` ${bold("Submissions")} ${dim(boardHeader)}${modeLabel}`,
1392
1425
  );
1393
1426
  emit();
1394
1427
 
@@ -1419,15 +1452,26 @@ function renderSubmissions(
1419
1452
  ? v.toFixed(2)
1420
1453
  : String(v);
1421
1454
 
1455
+ // Responsive column hiding — the full table is 106 cols wide. Hide columns
1456
+ // at narrower terminals so rows never overflow/wrap/garble.
1457
+ // w >= 120: all columns (106 cols fits)
1458
+ // w < 120: hide Tokens (saves 11) → 95 cols
1459
+ // w < 100: hide Tokens + Class (saves 14) → 81 cols
1460
+ // w < 90: hide Tokens + Class + Op Ratio, shorten Codename 20→16 → 61 cols
1461
+ const showTokens = w >= 120;
1462
+ const showClass = w >= 100;
1463
+ const showOpRatio = w >= 90;
1464
+ const nameWidth = w < 90 ? 16 : 20;
1465
+
1422
1466
  const SH = [
1423
1467
  padStart(hdr("#"), 4),
1424
- padEnd(hdr("Codename"), 20),
1468
+ padEnd(hdr("Codename"), nameWidth),
1425
1469
  padEnd(hdr("Platform"), 10),
1426
1470
  padEnd(hdr("Win"), 6),
1427
1471
  padStart(hdr("Υ Yield"), 9),
1428
- padEnd(hdr("Op Ratio"), 18),
1429
- padEnd(hdr("Class"), 12),
1430
- padStart(hdr("Tokens"), 9),
1472
+ ...(showOpRatio ? [padEnd(hdr("Op Ratio"), 18)] : []),
1473
+ ...(showClass ? [padEnd(hdr("Class"), 12)] : []),
1474
+ ...(showTokens ? [padStart(hdr("Tokens"), 9)] : []),
1431
1475
  ];
1432
1476
  emit(` ${SH.join(" ")}`);
1433
1477
  emit(` ${dim("·".repeat(Math.min(w - 4, 100)))}`);
@@ -1445,10 +1489,10 @@ function renderSubmissions(
1445
1489
  : idx < 3
1446
1490
  ? cyan(`#${idx + 1}`)
1447
1491
  : `#${idx + 1}`;
1448
- const nmRaw = trunc(gName(e), 20);
1492
+ const nmRaw = trunc(gName(e), nameWidth);
1449
1493
  const nm = isYou
1450
- ? `${c.bgCyan}${c.boldCyan}${padEnd(` ${trunc(gName(e), 18)} `, 20)}${c.reset}`
1451
- : padEnd(nmRaw, 20);
1494
+ ? `${c.bgCyan}${c.boldCyan}${padEnd(` ${trunc(gName(e), nameWidth - 2)} `, nameWidth)}${c.reset}`
1495
+ : padEnd(nmRaw, nameWidth);
1452
1496
  const yv = gYield(e);
1453
1497
  const yld = padStart(
1454
1498
  yv != null ? (yv > 10000 ? gold(fmtY(yv)) : fmtY(yv)) : "—",
@@ -1457,9 +1501,17 @@ function renderSubmissions(
1457
1501
  const cls = padEnd(colorCls(gClass(e)), 12);
1458
1502
  const tk = gTokens(e);
1459
1503
  const youMark = isYou ? ` ${c.bgCyan}${c.boldCyan}YOU${c.reset}` : "";
1460
- emit(
1461
- ` ${padStart(rk, 4)} ${nm} ${padEnd(cyan(trunc(gPlat(e), 10)), 10)} ${padEnd(dim(trunc(gWin(e), 6)), 6)} ${yld} ${padEnd(dim(fmtOp(gOpRatio(e))), 18)} ${cls} ${padStart(tk != null ? fmtTok(tk) : "—", 9)}${youMark}`,
1462
- );
1504
+ const cells = [
1505
+ padStart(rk, 4),
1506
+ nm,
1507
+ padEnd(cyan(trunc(gPlat(e), 10)), 10),
1508
+ padEnd(dim(trunc(gWin(e), 6)), 6),
1509
+ yld,
1510
+ ...(showOpRatio ? [padEnd(dim(fmtOp(gOpRatio(e))), 18)] : []),
1511
+ ...(showClass ? [cls] : []),
1512
+ ...(showTokens ? [padStart(tk != null ? fmtTok(tk) : "—", 9)] : []),
1513
+ ];
1514
+ emit(` ${cells.join(" ")}${youMark}`);
1463
1515
  shown++;
1464
1516
  }
1465
1517
  // A3-style: if the height budget dropped rows, say so instead of silently truncating.
@@ -1547,10 +1599,16 @@ function renderBoard(
1547
1599
  d10: top3((e) => e.dev10x),
1548
1600
  };
1549
1601
  // Wrap a padded cell in a medal bg-tint (dark fg on the medal color) when its value places top-3.
1602
+ // Theme-aware: monochrome uses bold instead of color backgrounds.
1550
1603
  const medal = (map, val, padded) => {
1551
1604
  const place = val == null ? null : map.get(val);
1552
1605
  if (!place) return padded;
1553
- return `${ESC}48;5;${MEDAL_BG[place]}m${ESC}38;5;232m${padded}${c.reset}`;
1606
+ const bgCode = c.medalBg?.[place];
1607
+ if (bgCode != null) {
1608
+ return `${ESC}48;5;${bgCode}m${c.medalFg}${padded}${c.reset}`;
1609
+ }
1610
+ // Monochrome / no medal bg — use bold
1611
+ return `${c.bold}${padded}${c.reset}`;
1554
1612
  };
1555
1613
 
1556
1614
  const BH = [
@@ -1695,11 +1753,22 @@ function renderConnect(id, codeBuf = "", msg = "") {
1695
1753
  );
1696
1754
  writeln();
1697
1755
  writeln(
1698
- ` ${dim('Need a new key? Click "New key" at signalaf.com → Settings, then paste it here.')}`,
1699
- );
1700
- writeln(
1701
- ` ${dim("Signed in on the wrong device, or want a fresh start?")} ${bold("[X]")} ${dim("signs out — next paste provisions a fresh device.")}`,
1756
+ ` ${dim(W() < 90 ? 'Need a new key? signalaf.com → Settings → "New key", then paste here.' : 'Need a new key? Click "New key" at signalaf.com → Settings, then paste it here.')}`,
1702
1757
  );
1758
+ // Wrap the sign-out help across 2 lines on narrow terminals (the full
1759
+ // sentence is 108 cols — overflows 100-col terminals by 8).
1760
+ if (W() >= 110) {
1761
+ writeln(
1762
+ ` ${dim("Signed in on the wrong device, or want a fresh start?")} ${bold("[X]")} ${dim("signs out — next paste provisions a fresh device.")}`,
1763
+ );
1764
+ } else {
1765
+ writeln(
1766
+ ` ${dim("Signed in on the wrong device, or want a fresh start?")}`,
1767
+ );
1768
+ writeln(
1769
+ ` ${bold("[X]")} ${dim("signs out — next paste provisions a fresh device.")}`,
1770
+ );
1771
+ }
1703
1772
  } else {
1704
1773
  writeln(` ${bold("Log in to submit to board")}`);
1705
1774
  writeln(` ${hr()}`);
@@ -1723,7 +1792,13 @@ function renderConnect(id, codeBuf = "", msg = "") {
1723
1792
  // watch grid), instead of a single platform/window. `platform`/`win` are OPTIONAL focus filters —
1724
1793
  // null/'all'/'all-windows' means "everything". Each row is a (platform, window) cascade cell:
1725
1794
  // Υ Yield + SNR/Lev/Vel/class. Iterates active platforms (input+output > 0 in any window).
1726
- async function renderWatch(platform = "all", win = "all-windows") {
1795
+ // Load Watch data once — scan all candidate platforms for active sessions.
1796
+ // Separated from rendering (renderWatchData) so the headless audit can load
1797
+ // once and render across many sizes/themes without re-scanning the filesystem
1798
+ // each frame (each full scan is ~8s across 16 platforms; 16 frames × 8s would
1799
+ // be ~130s of pure re-scan otherwise). Mirrors how Dashboard/Compare/Board
1800
+ // already separate load from render.
1801
+ async function loadWatchData(platform = "all", win = "all-windows") {
1727
1802
  // ../tokenpull.mjs — this file lives in presentation/, so "./tokenpull.mjs" resolved
1728
1803
  // to presentation/tokenpull.mjs (nonexistent) and threw ERR_MODULE_NOT_FOUND for
1729
1804
  // every row. Matches the static `../tokenpull.mjs` import at the top of this file.
@@ -1748,17 +1823,6 @@ async function renderWatch(platform = "all", win = "all-windows") {
1748
1823
  ];
1749
1824
  const platFilter = platform && platform !== "all" ? platform : null;
1750
1825
  const winFilter = win && win !== "all-windows" ? win : null;
1751
- const WINS = winFilter ? [winFilter] : ["7d", "30d", "90d", "all"];
1752
- const w = W();
1753
- const budget = H() - 4;
1754
- let used = 0;
1755
- const emit = (s = "") => {
1756
- if (used < budget) {
1757
- writeln(s);
1758
- used++;
1759
- }
1760
- };
1761
-
1762
1826
  // Detect active platforms (any window with input+output > 0), respecting an optional [P] focus.
1763
1827
  const candidates = platFilter ? [platFilter] : ALL_PLATFORMS;
1764
1828
  const settled = await Promise.allSettled(
@@ -1778,6 +1842,23 @@ async function renderWatch(platform = "all", win = "all-windows") {
1778
1842
  (a, b) =>
1779
1843
  ALL_PLATFORMS.indexOf(a.platform) - ALL_PLATFORMS.indexOf(b.platform),
1780
1844
  );
1845
+ return { active, platFilter, winFilter };
1846
+ }
1847
+
1848
+ // Render the Watch tab from pre-loaded data. Sync — no scanning, safe to call
1849
+ // once per terminal size/theme in the audit.
1850
+ function renderWatchData(data) {
1851
+ const { active, platFilter, winFilter } = data;
1852
+ const WINS = winFilter ? [winFilter] : ["7d", "30d", "90d", "all"];
1853
+ const w = W();
1854
+ const budget = H() - 4;
1855
+ let used = 0;
1856
+ const emit = (s = "") => {
1857
+ if (used < budget) {
1858
+ writeln(s);
1859
+ used++;
1860
+ }
1861
+ };
1781
1862
 
1782
1863
  const scopeLabel =
1783
1864
  (platFilter || "all active platforms") +
@@ -1821,6 +1902,13 @@ async function renderWatch(platform = "all", win = "all-windows") {
1821
1902
  }
1822
1903
  }
1823
1904
 
1905
+ // Convenience: scan + render in one call (used by --render and --record, which
1906
+ // only render once). The audit uses loadWatchData + renderWatchData separately
1907
+ // to avoid re-scanning across its 16 frames.
1908
+ async function renderWatch(platform = "all", win = "all-windows") {
1909
+ renderWatchData(await loadWatchData(platform, win));
1910
+ }
1911
+
1824
1912
  // ── SUBMIT PREVIEW (FIX I1 / FIX E) — see→confirm→send ───────────────────────
1825
1913
  // Renders a platform × window grid of what submit_verified WOULD send, using the
1826
1914
  // already-loaded dashData. [Enter] confirms → fires submit_verified per platform;
@@ -2043,6 +2131,12 @@ export async function runTui({
2043
2131
  platform: initPlatform = "claude",
2044
2132
  window: win = "7d",
2045
2133
  } = {}) {
2134
+ // Theme selection — --theme <name> sets the color palette (dark/light/high-contrast/monochrome)
2135
+ const themeIdx = process.argv.indexOf("--theme");
2136
+ if (themeIdx !== -1 && process.argv[themeIdx + 1]) {
2137
+ setTheme(process.argv[themeIdx + 1]);
2138
+ }
2139
+
2046
2140
  // FIX J: platform is mutable — [P] cycles it on Compare + Watch tabs. Was a const
2047
2141
  // param (permanently locked to 'claude'); now a let so the user can switch + [S]
2048
2142
  // submits the selected platform (needed for Phase 4 multi-platform submit).
@@ -2055,6 +2149,51 @@ export async function runTui({
2055
2149
  return;
2056
2150
  }
2057
2151
 
2152
+ // Audit mode — headless review of every tab × every terminal size. Checks
2153
+ // overflow, truncation, dead space, budget, color health, sparkline integrity,
2154
+ // responsive design, visual weight. Supports golden frame snapshots
2155
+ // (--golden-save / --golden-check), SVG export (--svg), and CI mode (--ci
2156
+ // exits non-zero on HIGH severity issues).
2157
+ const ai = process.argv.indexOf("--audit");
2158
+ if (ai !== -1) {
2159
+ let runAudit, formatAuditReport;
2160
+ try {
2161
+ ({ runAudit, formatAuditReport } = await import("../dev/tui-audit.mjs"));
2162
+ } catch {
2163
+ console.error("--audit is a development tool, not available in the npm package.");
2164
+ console.error("Clone the repo to use it: https://github.com/SunrisesIllNeverSee/sigrank-mcp");
2165
+ process.exit(1);
2166
+ }
2167
+ const auditData = await runAudit({
2168
+ goldenSave: process.argv.includes("--golden-save"),
2169
+ goldenCheck: process.argv.includes("--golden-check"),
2170
+ svg: process.argv.includes("--svg"),
2171
+ png: process.argv.includes("--png"),
2172
+ ci: process.argv.includes("--ci"),
2173
+ });
2174
+ process.stdout.write(formatAuditReport(auditData) + "\n");
2175
+ // Always exit in audit mode — a data load that timed out may still have a
2176
+ // pending background promise (network fetch / local scan) that would keep
2177
+ // the event loop alive after runTui returns. The audit is headless and must
2178
+ // terminate; unconditional exit guarantees that regardless of ciExitCode.
2179
+ process.exit(auditData.ciExitCode);
2180
+ }
2181
+
2182
+ // Record mode — capture a TUI session as an animated GIF (headless, no TTY needed)
2183
+ const recIdx = process.argv.indexOf("--record");
2184
+ if (recIdx !== -1) {
2185
+ let recordCli;
2186
+ try {
2187
+ ({ recordCli } = await import("../dev/tui-record.mjs"));
2188
+ } catch {
2189
+ console.error("--record is a development tool, not available in the npm package.");
2190
+ console.error("Clone the repo to use it: https://github.com/SunrisesIllNeverSee/sigrank-mcp");
2191
+ process.exit(1);
2192
+ }
2193
+ await recordCli(process.argv);
2194
+ return;
2195
+ }
2196
+
2058
2197
  write(ENTER_ALT); // switch to alternate screen — original terminal state preserved on exit
2059
2198
  write(CLEAR_SB); // FIXED-WINDOW (2026-06-27): clear scrollback so nothing is above the TUI
2060
2199
  write(HIDE);
@@ -2905,8 +3044,41 @@ export async function runTui({
2905
3044
  writeln();
2906
3045
  }
2907
3046
 
3047
+ // ── Exports for the headless audit system (tui-audit.mjs) ──────────────────
3048
+ // These expose the render functions + buffer state so the audit module can
3049
+ // render tabs in-process (fast) instead of spawning child processes.
3050
+ export {
3051
+ renderDashboard,
3052
+ renderTrends,
3053
+ renderCompare,
3054
+ renderBoard,
3055
+ renderConnect,
3056
+ renderWatch,
3057
+ renderWatchData,
3058
+ loadWatchData,
3059
+ loadDashboardData,
3060
+ loadCompareData,
3061
+ loadBoardData,
3062
+ startBuffer,
3063
+ stripAnsi,
3064
+ };
3065
+ // _screenBuf is a `let` — exporting it snapshots the value, not the binding.
3066
+ // Expose getter/setter so the audit module can read the live buffer after
3067
+ // startBuffer() + render() populate it, and reset it after capturing.
3068
+ export function _getScreenBuf() {
3069
+ return _screenBuf;
3070
+ }
3071
+ export function _resetBuf() {
3072
+ _screenBuf = null;
3073
+ _footerBuf = null;
3074
+ }
3075
+
2908
3076
  // Direct-run entry (e.g. `node tui.mjs --render 0`). Normal launch is via cli.mjs.
2909
- if (import.meta.url === `file://${process.argv[1]}`) {
3077
+ // pathToFileURL resolves relative argv[1] (e.g. `presentation/tui.mjs`) to the
3078
+ // same absolute file:// URL that import.meta.url always carries — without it,
3079
+ // `node presentation/tui.mjs --audit` never matched the guard and runTui never
3080
+ // ran (silent no-op). This is the standard Node ESM main-module detection.
3081
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
2910
3082
  runTui().catch((e) => {
2911
3083
  console.error(e);
2912
3084
  process.exit(1);