sigrank 0.19.0 → 1.0.1
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/CHANGELOG.md +158 -0
- package/MCP_SERVER_DESCRIPTION.md +172 -0
- package/README.md +89 -41
- package/adapters/index.mjs +1100 -0
- package/adapters/tokenpull.mjs +568 -0
- package/adapters.mjs +1 -865
- package/analytics/cascade.mjs +338 -0
- package/cascade.mjs +1 -274
- package/cli.mjs +1 -1853
- package/connect.mjs +1 -13
- package/identity/connect.mjs +13 -0
- package/identity/keystore.mjs +231 -0
- package/identity/sign.mjs +106 -0
- package/index.mjs +47 -162
- package/keystore.mjs +1 -242
- package/lib/constants.mjs +137 -0
- package/lib/pkg-version.mjs +59 -0
- package/manifest.json +46 -0
- package/narrate.mjs +1 -77
- package/package.json +41 -16
- package/preflight.mjs +1 -191
- package/presentation/cli.mjs +1926 -0
- package/presentation/narrate.mjs +219 -0
- package/presentation/tui-themes.mjs +189 -0
- package/presentation/tui.mjs +3316 -0
- package/prompts/check-my-efficiency.md +1 -0
- package/prompts/compare-with-leader.md +1 -0
- package/prompts/simulate-improvement.md +1 -0
- package/proxy.mjs +492 -0
- package/resources/class-tiers.md +81 -0
- package/resources/data-policy.md +31 -0
- package/resources/install-guide.md +59 -0
- package/resources/privacy-model.md +44 -0
- package/resources/scoring-formula.md +24 -0
- package/review.mjs +263 -0
- package/sign.mjs +1 -106
- package/submit/index.mjs +297 -0
- package/submit/preflight.mjs +191 -0
- package/submit.mjs +1 -303
- package/tokenpull.mjs +1 -576
- package/tokscale_analytics.mjs +864 -0
- package/tools/_framing.mjs +217 -0
- package/tools/_helpers.mjs +253 -0
- package/tools/_peer-matching.mjs +361 -0
- package/tools/_report.mjs +262 -0
- package/tools/_schemas.mjs +492 -0
- package/tools/_verifiers.mjs +148 -0
- package/tools/compare-operators.mjs +80 -0
- package/tools/compare-self.mjs +124 -0
- package/tools/describe-power-user.mjs +78 -0
- package/tools/diagnose-cascade.mjs +192 -0
- package/tools/discover-peers.mjs +198 -0
- package/tools/enroll.mjs +119 -0
- package/tools/get-best-operator.mjs +59 -0
- package/tools/get-leaderboard.mjs +27 -0
- package/tools/get-operator.mjs +33 -0
- package/tools/index.mjs +229 -0
- package/tools/optimize-efficiency.mjs +170 -0
- package/tools/rank-paste.mjs +51 -0
- package/tools/rank-windows.mjs +120 -0
- package/tools/self-improve.mjs +423 -0
- package/tools/simulate-change.mjs +207 -0
- package/tools/submit-paste.mjs +121 -0
- package/tools/submit-verified.mjs +153 -0
- package/tools/suggest-improvements.mjs +168 -0
- package/tools/tokenpull-compare.mjs +123 -0
- package/tools/tokenpull-submit.mjs +235 -0
- package/tools/tokenpull.mjs +113 -0
- package/tools/tokscale-breakdown.mjs +119 -0
- package/tools/tokscale-competitive-intel.mjs +45 -0
- package/tools/tokscale-cost-analysis.mjs +31 -0
- package/tools/tokscale-developer-profile.mjs +36 -0
- package/tools/tokscale-device-profile.mjs +35 -0
- package/tools/tokscale-market-share.mjs +38 -0
- package/tools/tokscale-mcp-usage.mjs +33 -0
- package/tools/tokscale-model-trends.mjs +32 -0
- package/tools/watch-tokenpull.mjs +134 -0
- package/tools.mjs +1 -3897
- package/tui.mjs +4 -2650
- package/banner.svg +0 -7
|
@@ -0,0 +1,3316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tui.mjs — SigRank full tabbed TUI.
|
|
3
|
+
*
|
|
4
|
+
* Tabs: [1] Dashboard [2] Compare [3] Board [4] Watch
|
|
5
|
+
* Navigation: ← → arrow keys, 1-4 number keys, or tab letter shortcuts.
|
|
6
|
+
* No external dependencies — pure ANSI/Node.js stdin raw mode.
|
|
7
|
+
*
|
|
8
|
+
* Architecture:
|
|
9
|
+
* - State machine: activeTab + per-tab data cache.
|
|
10
|
+
* - On tab switch: clear screen, redraw active tab.
|
|
11
|
+
* - Board + Watch tabs auto-refresh on a timer.
|
|
12
|
+
* - Dashboard + Compare load once, refresh on [R].
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { callTool, DEFAULT_API_BASE, pullActivePlatforms } from "../tools.mjs";
|
|
16
|
+
import { ALL_PLATFORMS } from "../adapters.mjs";
|
|
17
|
+
import { cascade, CLASS_TIERS, UNCLASSED, tierOf } from "../cascade.mjs";
|
|
18
|
+
import { freshVerifierPillars } from "../tokenpull.mjs";
|
|
19
|
+
import { isSignedIn, isCodeChar } from "../connect.mjs";
|
|
20
|
+
import { loadIdentity, clearIdentity } from "../keystore.mjs";
|
|
21
|
+
import { execFile } from "child_process";
|
|
22
|
+
import { existsSync, readFileSync } from "fs";
|
|
23
|
+
import os from "os";
|
|
24
|
+
import path from "path";
|
|
25
|
+
import { pathToFileURL } from "node:url";
|
|
26
|
+
import { pkgVersion } from "../lib/pkg-version.mjs";
|
|
27
|
+
import { LEADERBOARD_METRIC } from "../lib/constants.mjs";
|
|
28
|
+
|
|
29
|
+
// Version read from the shared pkg-version helper (single source of truth —
|
|
30
|
+
// matches cli.mjs; never hardcode, that's what caused the version drift).
|
|
31
|
+
const VERSION = pkgVersion();
|
|
32
|
+
|
|
33
|
+
// Expensive platform readers that the Dashboard only runs on explicit user
|
|
34
|
+
// request. They remain in ALL_PLATFORMS and are still available to me, watch,
|
|
35
|
+
// Compare, and MCP tool calls when the caller explicitly selects the platform.
|
|
36
|
+
export const DASHBOARD_MANUAL_PLATFORMS = Object.freeze(["omp"]);
|
|
37
|
+
export function dashboardAutoPlatforms(platforms = ALL_PLATFORMS) {
|
|
38
|
+
return platforms.filter((p) => !DASHBOARD_MANUAL_PLATFORMS.includes(p));
|
|
39
|
+
}
|
|
40
|
+
export function dashboardEnabledPlatforms({
|
|
41
|
+
includeManual = false,
|
|
42
|
+
platforms = ALL_PLATFORMS,
|
|
43
|
+
} = {}) {
|
|
44
|
+
const automatic = dashboardAutoPlatforms(platforms);
|
|
45
|
+
if (!includeManual) return automatic;
|
|
46
|
+
return [
|
|
47
|
+
...automatic,
|
|
48
|
+
...DASHBOARD_MANUAL_PLATFORMS.filter((p) => platforms.includes(p)),
|
|
49
|
+
];
|
|
50
|
+
}
|
|
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";
|
|
57
|
+
import { platformColor } from "./tui-themes.mjs";
|
|
58
|
+
const ESC = "\x1b[";
|
|
59
|
+
// `c` is a live proxy — reads currentTheme at access time so theme switches
|
|
60
|
+
// take effect immediately. The helper functions below (paint/bold/dim/etc.)
|
|
61
|
+
// read from `c` at call time, so they always use the active theme's colors.
|
|
62
|
+
const c = new Proxy({}, {
|
|
63
|
+
get(_target, prop) {
|
|
64
|
+
return _themeMod.currentTheme[prop];
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
const setTheme = _themeMod.setTheme;
|
|
68
|
+
const paint = (col, s) => `${col}${s}${c.reset}`;
|
|
69
|
+
const bold = (s) => paint(c.bold, s);
|
|
70
|
+
const dim = (s) => paint(c.dim, s);
|
|
71
|
+
const gold = (s) => paint(c.boldGold, s);
|
|
72
|
+
const cyan = (s) => paint(c.boldCyan, s);
|
|
73
|
+
const hdr = (s) => paint(c.boldCyan, s); // table column-header label color (readability)
|
|
74
|
+
const green = (s) => paint(c.green, s);
|
|
75
|
+
const red = (s) => paint(c.red, s);
|
|
76
|
+
|
|
77
|
+
const CLEAR = `${ESC}H${ESC}2J`; // home + erase visible area
|
|
78
|
+
const CLEAR_SB = `${ESC}3J`; // clear scrollback/saved lines (prevents scroll-up past TUI)
|
|
79
|
+
const ENTER_ALT = `${ESC}?1049h`; // enter alternate screen buffer
|
|
80
|
+
const EXIT_ALT = `${ESC}?1049l`; // exit alternate screen buffer (restores original)
|
|
81
|
+
const HIDE = `${ESC}?25l`;
|
|
82
|
+
const SHOW = `${ESC}?25h`;
|
|
83
|
+
const UP = (n) => `${ESC}${n}A`;
|
|
84
|
+
const ERLINE = `${ESC}2K`;
|
|
85
|
+
const GOTO = (r, col = 1) => `${ESC}${r};${col}H`; // absolute cursor position
|
|
86
|
+
|
|
87
|
+
const W = () => process.stdout.columns || 100;
|
|
88
|
+
// FIX 0b: safe default 24 (not 40) when rows is undefined (non-TTY / IDE terminals).
|
|
89
|
+
// A 40-row assumption into a 24-row window scrolls the alt-screen → footer smears off.
|
|
90
|
+
const H = () => process.stdout.rows || 24;
|
|
91
|
+
|
|
92
|
+
// ── Screen buffer: collect lines, then paint only what fits ─────────────
|
|
93
|
+
// Render functions call write/writeln as before; the buffer captures them.
|
|
94
|
+
// flushScreen() paints to the real terminal using absolute cursor positioning
|
|
95
|
+
// so the TUI never scrolls — it's a locked frame like tokscale.
|
|
96
|
+
let _screenBuf = null; // null = direct mode (unbuffered); string[] = buffered
|
|
97
|
+
const write = (s) => {
|
|
98
|
+
if (_screenBuf) {
|
|
99
|
+
const parts = s.split("\n");
|
|
100
|
+
if (parts.length === 1) {
|
|
101
|
+
_screenBuf[_screenBuf.length - 1] += s;
|
|
102
|
+
} else {
|
|
103
|
+
_screenBuf[_screenBuf.length - 1] += parts[0];
|
|
104
|
+
for (let i = 1; i < parts.length; i++) _screenBuf.push(parts[i]);
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
process.stdout.write(s);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const writeln = (s = "") => {
|
|
111
|
+
if (_screenBuf) {
|
|
112
|
+
_screenBuf[_screenBuf.length - 1] += s;
|
|
113
|
+
_screenBuf.push("");
|
|
114
|
+
} else {
|
|
115
|
+
process.stdout.write(s + "\n");
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
let _footerBuf = null; // footer lines pinned to bottom
|
|
120
|
+
function startBuffer() {
|
|
121
|
+
_screenBuf = [""];
|
|
122
|
+
_footerBuf = null;
|
|
123
|
+
}
|
|
124
|
+
function setFooter(lines) {
|
|
125
|
+
_footerBuf = lines;
|
|
126
|
+
}
|
|
127
|
+
function flushScreen() {
|
|
128
|
+
if (!_screenBuf) return;
|
|
129
|
+
const lines = _screenBuf;
|
|
130
|
+
const footer = _footerBuf || [];
|
|
131
|
+
_screenBuf = null;
|
|
132
|
+
_footerBuf = null;
|
|
133
|
+
const h = H();
|
|
134
|
+
const w = W();
|
|
135
|
+
// FIX 0b: footer is SACROSANCT. Clamp CONTENT to h - footer.length first, then
|
|
136
|
+
// always append every footer line. The footer must NEVER be the thing dropped
|
|
137
|
+
// (it was — footer painted last, clamp cut the tail = footer first → lost hints
|
|
138
|
+
// + lost lower cascade rows, together, because they're the same tail of the frame).
|
|
139
|
+
const contentRows = Math.max(0, h - footer.length);
|
|
140
|
+
const content = lines.slice(0, contentRows);
|
|
141
|
+
const frame = [...content, ...footer];
|
|
142
|
+
const maxRows = Math.min(frame.length, h);
|
|
143
|
+
let out = "";
|
|
144
|
+
for (let i = 0; i < maxRows; i++) {
|
|
145
|
+
out += GOTO(i + 1) + ERLINE + ansiTrunc(frame[i], w);
|
|
146
|
+
}
|
|
147
|
+
// Clear any rows below the frame left over from a taller previous frame.
|
|
148
|
+
for (let i = maxRows; i < h; i++) {
|
|
149
|
+
out += GOTO(i + 1) + ERLINE;
|
|
150
|
+
}
|
|
151
|
+
process.stdout.write(out);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function stripAnsi(s) {
|
|
155
|
+
return s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
156
|
+
}
|
|
157
|
+
// ANSI-aware truncate: cut to `w` VISIBLE columns while keeping color escapes
|
|
158
|
+
// intact (escapes don't count toward width, and we never slice mid-sequence).
|
|
159
|
+
// Replaces raw `.slice(0, w)`, which corrupts color codes + miscounts width.
|
|
160
|
+
function ansiTrunc(s, w) {
|
|
161
|
+
if (stripAnsi(s).length <= w) return s;
|
|
162
|
+
let out = "",
|
|
163
|
+
vis = 0,
|
|
164
|
+
i = 0;
|
|
165
|
+
while (i < s.length && vis < w) {
|
|
166
|
+
if (s[i] === "\x1b") {
|
|
167
|
+
const m = s.slice(i).match(/^\x1b\[[0-9;]*m/);
|
|
168
|
+
if (m) {
|
|
169
|
+
out += m[0];
|
|
170
|
+
i += m[0].length;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
out += s[i];
|
|
175
|
+
vis++;
|
|
176
|
+
i++;
|
|
177
|
+
}
|
|
178
|
+
return out + c.reset; // reset so a cut mid-color doesn't bleed into next line
|
|
179
|
+
}
|
|
180
|
+
function padEnd(s, w) {
|
|
181
|
+
const v = stripAnsi(s).length;
|
|
182
|
+
return v >= w ? s : s + " ".repeat(w - v);
|
|
183
|
+
}
|
|
184
|
+
function padStart(s, w) {
|
|
185
|
+
const v = stripAnsi(s).length;
|
|
186
|
+
return v >= w ? s : " ".repeat(w - v) + s;
|
|
187
|
+
}
|
|
188
|
+
function trunc(s, w) {
|
|
189
|
+
return stripAnsi(s).length <= w ? s : s.slice(0, w - 1) + "…";
|
|
190
|
+
}
|
|
191
|
+
// Wrap a plain-text string into dim'd lines that fit within `maxW` columns.
|
|
192
|
+
// Used for no-data/empty-state messages that can be long on narrow terminals.
|
|
193
|
+
// Returns an array of strings (each already dim'd + indented) for emit().
|
|
194
|
+
function wrapDim(text, indent = " ", maxW) {
|
|
195
|
+
const w = maxW || W();
|
|
196
|
+
const avail = w - 2 - indent.length; // 2 = leading " " from emit caller
|
|
197
|
+
if (avail < 20) return [`${indent}${dim(text)}`]; // too narrow to wrap — let it be
|
|
198
|
+
const words = text.split(" ");
|
|
199
|
+
const lines = [];
|
|
200
|
+
let line = "";
|
|
201
|
+
for (const word of words) {
|
|
202
|
+
if ((line + " " + word).trim().length > avail) {
|
|
203
|
+
if (line) lines.push(`${indent}${dim(line.trim())}`);
|
|
204
|
+
line = word;
|
|
205
|
+
} else {
|
|
206
|
+
line = (line + " " + word).trim();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (line) lines.push(`${indent}${dim(line.trim())}`);
|
|
210
|
+
return lines;
|
|
211
|
+
}
|
|
212
|
+
function hr(ch = "─") {
|
|
213
|
+
return dim(ch.repeat(Math.max(0, W() - 4)));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Number formatters ───────────────────────────────────────────────────────
|
|
217
|
+
const fmtY = (y) =>
|
|
218
|
+
y == null
|
|
219
|
+
? "—"
|
|
220
|
+
: y >= 10000
|
|
221
|
+
? `${(y / 1000).toFixed(1)}K`
|
|
222
|
+
: y >= 1000
|
|
223
|
+
? `${(y / 1000).toFixed(2)}K`
|
|
224
|
+
: y.toFixed(1);
|
|
225
|
+
const fmtLev = (l) =>
|
|
226
|
+
l == null ? "—" : l >= 1000 ? `${(l / 1000).toFixed(1)}K` : l.toFixed(0);
|
|
227
|
+
const fmtSNR = (n) => (n == null ? "—" : `${(n * 100).toFixed(1)}%`);
|
|
228
|
+
const fmtTok = (n) =>
|
|
229
|
+
n == null
|
|
230
|
+
? "—"
|
|
231
|
+
: n >= 1e9
|
|
232
|
+
? `${(n / 1e9).toFixed(1)}B`
|
|
233
|
+
: n >= 1e6
|
|
234
|
+
? `${(n / 1e6).toFixed(1)}M`
|
|
235
|
+
: n >= 1e3
|
|
236
|
+
? `${(n / 1e3).toFixed(1)}K`
|
|
237
|
+
: String(n);
|
|
238
|
+
const fmtMov = (n) =>
|
|
239
|
+
n == null || n === 0 ? dim("—") : n > 0 ? green(`+${n}`) : red(`${n}`);
|
|
240
|
+
|
|
241
|
+
// ── Class tier colors ────────────────────────────────────────────────────────
|
|
242
|
+
// Color is keyed on the BASE TIER (8 entries in CLASS_TIERS). classify() returns
|
|
243
|
+
// a full sub-stage string (e.g. "REFINER II") — tierOf() extracts the base tier
|
|
244
|
+
// for color lookup. TRANSMITTER is not a class — it's a temporary peak badge.
|
|
245
|
+
const CLS = {
|
|
246
|
+
"ARCH+": (s) => paint(c.boldCyan, s),
|
|
247
|
+
ARCH: (s) => paint(c.cyan, s),
|
|
248
|
+
POWER: (s) => paint(c.boldWhite, s),
|
|
249
|
+
BASE: (s) => paint(c.white, s),
|
|
250
|
+
SEEKER: (s) => paint(c.magenta, s),
|
|
251
|
+
REFINER: (s) => paint(c.blue, s),
|
|
252
|
+
BEARER: (s) => paint(c.dim, s),
|
|
253
|
+
IGNITER: (s) => paint(c.dim, s),
|
|
254
|
+
[UNCLASSED]: (s) => paint(c.dim, s),
|
|
255
|
+
};
|
|
256
|
+
const colorCls = (cls) => (CLS[tierOf(cls)] ?? CLS[UNCLASSED] ?? ((s) => s))(cls);
|
|
257
|
+
|
|
258
|
+
// Guard: every canonical tier must have a color, so a new tier added in
|
|
259
|
+
// analytics/cascade.mjs can't silently render uncolored here.
|
|
260
|
+
for (const t of CLASS_TIERS) {
|
|
261
|
+
if (!CLS[t]) {
|
|
262
|
+
// eslint-disable-next-line no-console
|
|
263
|
+
console.warn(`[tui] CLS map missing color for tier "${t}"`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Cascade math ───────────────────────────────────────────────────────────
|
|
268
|
+
// Fix 3: the inline cascadeFrom() (a third copy of the cascade math with its
|
|
269
|
+
// own null-guard policy) is replaced by the single shared cascade() from
|
|
270
|
+
// analytics/cascade.mjs. CLI, TUI, and MCP tools now report identical Υ for
|
|
271
|
+
// identical tokens. cascadeFrom() keeps the old name as a thin alias so the
|
|
272
|
+
// many call sites below don't need rewriting; it forwards to the shared
|
|
273
|
+
// implementation and returns null only when there are no pillars at all.
|
|
274
|
+
function cascadeFrom(p) {
|
|
275
|
+
if (!p) return null;
|
|
276
|
+
return cascade(p);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Unicode bar chart (no dep) ───────────────────────────────────────────────
|
|
280
|
+
const BLOCKS = " ▏▎▍▌▋▊▉█";
|
|
281
|
+
|
|
282
|
+
// Log-scale bar — use when values span multiple orders of magnitude (e.g. token pillars
|
|
283
|
+
// where cacheRead >> input). Maps log10(v) to bar width so each 10x = same visual step.
|
|
284
|
+
// minLog floor prevents zero/tiny values from going negative. WIRED into the Dashboard
|
|
285
|
+
// Token Composition section (logTokenBar) so cache-heavy operators like MOSES (1000×
|
|
286
|
+
// scale differences between pillars) render every pillar visibly instead of one pillar
|
|
287
|
+
// erasing the other three.
|
|
288
|
+
function logBar(v, maxLog, width = 40, colorCode = c.cyan) {
|
|
289
|
+
if (!v || v <= 0) return { bar: dim("·".repeat(width)), pct: 0 };
|
|
290
|
+
const log = Math.log10(v);
|
|
291
|
+
const pct = Math.min(log / maxLog, 1);
|
|
292
|
+
const full = Math.floor(pct * width);
|
|
293
|
+
const frac = Math.floor((pct * width - full) * 8);
|
|
294
|
+
const bar = paint(
|
|
295
|
+
colorCode,
|
|
296
|
+
"█".repeat(full) + (frac > 0 ? BLOCKS[frac] : ""),
|
|
297
|
+
);
|
|
298
|
+
return { bar, pct };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── Sparkline (no dep) ────────────────────────────────────────────────────────
|
|
302
|
+
const SPARK = "▁▂▃▄▅▆▇█";
|
|
303
|
+
function sparkline(values) {
|
|
304
|
+
const valid = values.filter((v) => v != null && Number.isFinite(v));
|
|
305
|
+
if (valid.length === 0) return dim("no data");
|
|
306
|
+
// With only 1 valid point there's no trend to show — render a flat marker
|
|
307
|
+
// at the mid-height so it doesn't look like a rising/falling edge.
|
|
308
|
+
if (valid.length === 1) {
|
|
309
|
+
return values
|
|
310
|
+
.map((v) =>
|
|
311
|
+
v != null && Number.isFinite(v) ? SPARK[4] : dim(" "),
|
|
312
|
+
)
|
|
313
|
+
.join("");
|
|
314
|
+
}
|
|
315
|
+
const min = Math.min(...valid),
|
|
316
|
+
max = Math.max(...valid);
|
|
317
|
+
return values
|
|
318
|
+
.map((v) => {
|
|
319
|
+
if (v == null || !Number.isFinite(v)) return dim(" "); // gap, not a fake low point
|
|
320
|
+
const idx = max === min ? 4 : Math.round(((v - min) / (max - min)) * 7);
|
|
321
|
+
return SPARK[idx];
|
|
322
|
+
})
|
|
323
|
+
.join("");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Data sources ─────────────────────────────────────────────────────────────
|
|
327
|
+
// (0.12.1 FIX 4) The local verifier readers (ccusage / tokscale / token-dashboard)
|
|
328
|
+
// moved to `freshVerifierPillars()` in tokenpull.mjs — FRESH on-demand pulls used by
|
|
329
|
+
// the Compare tab (#9). The old in-tui copies were dead after A1 removed the Dashboard
|
|
330
|
+
// verifierMap loop and #9 routed Compare through the fresh helper; removed here.
|
|
331
|
+
// (tools.mjs keeps its own `_ccusagePillars`/`_tokscalePillars`/`_tokenDashPillars` —
|
|
332
|
+
// still used by the `tokenpull_compare` MCP tool.)
|
|
333
|
+
|
|
334
|
+
async function loadDashboardData({ includeOmp = false } = {}) {
|
|
335
|
+
const boardPromise = Promise.race([
|
|
336
|
+
fetch(`${DEFAULT_API_BASE}/api/v1/leaderboard?window=30d&metric=${LEADERBOARD_METRIC}`, {
|
|
337
|
+
headers: { accept: "application/json" },
|
|
338
|
+
})
|
|
339
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
340
|
+
.catch(() => null),
|
|
341
|
+
new Promise((r) => setTimeout(() => r(null), 5000)),
|
|
342
|
+
]);
|
|
343
|
+
|
|
344
|
+
// Batched windows: fetch the signed-in operator's rolled-forward totals
|
|
345
|
+
// (never decrease when platforms delete old logs). Only fetched when signed in.
|
|
346
|
+
const id = loadIdentity();
|
|
347
|
+
const batchedPromise = (id && isSignedIn(id))
|
|
348
|
+
? Promise.race([
|
|
349
|
+
fetch(`${DEFAULT_API_BASE}/api/v1/operators/${encodeURIComponent(id.codename)}`, {
|
|
350
|
+
headers: { accept: "application/json" },
|
|
351
|
+
})
|
|
352
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
353
|
+
.then((d) => d?.batched ?? null)
|
|
354
|
+
.catch(() => null),
|
|
355
|
+
new Promise((r) => setTimeout(() => r(null), 5000)),
|
|
356
|
+
])
|
|
357
|
+
: Promise.resolve(null);
|
|
358
|
+
|
|
359
|
+
// FIX 0 + unify (0.14): progressive load via the SHARED loader (pullActivePlatforms) — paint the
|
|
360
|
+
// primary platform (claude) FAST, then fill the rest in fillDashboardRest. This is the SAME loader
|
|
361
|
+
// `me` and `watch` use, so the three views can't drift apart. (Verifier comparison still runs
|
|
362
|
+
// FRESH + on-demand in the Compare tab — see loadCompareData — never on this Dashboard path.)
|
|
363
|
+
const active = await pullActivePlatforms({ platforms: ["claude"] }).catch(
|
|
364
|
+
() => [],
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
const boardData = await boardPromise;
|
|
368
|
+
const batchedData = await batchedPromise;
|
|
369
|
+
return {
|
|
370
|
+
active,
|
|
371
|
+
verifierMap: {},
|
|
372
|
+
tdPillars: null,
|
|
373
|
+
boardData,
|
|
374
|
+
batchedData,
|
|
375
|
+
_loading: true,
|
|
376
|
+
_remaining: dashboardEnabledPlatforms({
|
|
377
|
+
includeManual: includeOmp,
|
|
378
|
+
}).filter((p) => p !== "claude"),
|
|
379
|
+
_ompAvailable: true,
|
|
380
|
+
_ompEnabled: includeOmp,
|
|
381
|
+
_ompLoading: false,
|
|
382
|
+
_ompScanned: false,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const dashboardPlatformRank = (p) =>
|
|
387
|
+
p === "claude" ? -2 : p === "codex" ? -1 : ALL_PLATFORMS.indexOf(p);
|
|
388
|
+
|
|
389
|
+
async function pullDashboardPlatform(dashData, platform) {
|
|
390
|
+
try {
|
|
391
|
+
const active = await pullActivePlatforms({ platforms: [platform] });
|
|
392
|
+
for (const d of active) {
|
|
393
|
+
if (!dashData.active.find((a) => a.platform === d.platform))
|
|
394
|
+
dashData.active.push(d);
|
|
395
|
+
}
|
|
396
|
+
dashData.active.sort(
|
|
397
|
+
(a, b) =>
|
|
398
|
+
dashboardPlatformRank(a.platform) - dashboardPlatformRank(b.platform),
|
|
399
|
+
);
|
|
400
|
+
return { ok: true, found: active.some((d) => d.platform === platform) };
|
|
401
|
+
} catch {
|
|
402
|
+
return { ok: false, found: false };
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* FIX 0: fill the remaining platforms after the primary render. Mutates
|
|
408
|
+
* dashData.active and calls onPlatformLoaded after each individual scan so the
|
|
409
|
+
* TUI can paint rows progressively.
|
|
410
|
+
*/
|
|
411
|
+
async function fillDashboardRest(
|
|
412
|
+
dashData,
|
|
413
|
+
{ shouldLoadPlatform, onPlatformStarted, onPlatformLoaded } = {},
|
|
414
|
+
) {
|
|
415
|
+
if (!dashData?._remaining) return false;
|
|
416
|
+
// Pull all remaining platforms IN PARALLEL. The old sequential approach took
|
|
417
|
+
// 25s+ (18 platforms × ~1.5s each, most returning nothing). Parallel cuts
|
|
418
|
+
// total wait to the slowest single platform (~2-3s).
|
|
419
|
+
const toLoad = dashData._remaining.filter(
|
|
420
|
+
(p) => !shouldLoadPlatform || shouldLoadPlatform(p),
|
|
421
|
+
);
|
|
422
|
+
await Promise.all(
|
|
423
|
+
toLoad.map(async (platform) => {
|
|
424
|
+
await onPlatformStarted?.(platform);
|
|
425
|
+
const result = await pullDashboardPlatform(dashData, platform);
|
|
426
|
+
await onPlatformLoaded?.(platform, result);
|
|
427
|
+
}),
|
|
428
|
+
);
|
|
429
|
+
dashData._loading = false;
|
|
430
|
+
dashData._remaining = null;
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// #9 / C1 (2026-06-27): Compare pulls the three external verifier sources FRESH + on-demand
|
|
435
|
+
// (ccusage + tokscale models + a token-dashboard rescan), via tokenpull.freshVerifierPillars —
|
|
436
|
+
// NOT the old stale snapshot readers (static tokscale_report.json / 3-day-old db). This runs ONLY
|
|
437
|
+
// when the Compare tab opens (it's a 5–60s scan), never on the Dashboard load path. Scoped to the
|
|
438
|
+
// current platform. tokenpull (the canonical board/cascade source) stays the fast/fresh tpData.
|
|
439
|
+
async function loadCompareData(platform = "claude") {
|
|
440
|
+
const tpData = await callTool("tokenpull", { platform }).catch(() => null);
|
|
441
|
+
// Fresh pull — { ccusage, tokscale, tokendash }, each null or window-keyed pillars.
|
|
442
|
+
const fresh = await freshVerifierPillars(platform).catch(() => ({
|
|
443
|
+
ccusage: null,
|
|
444
|
+
tokscale: null,
|
|
445
|
+
tokendash: null,
|
|
446
|
+
}));
|
|
447
|
+
return {
|
|
448
|
+
tpData,
|
|
449
|
+
cc: fresh.ccusage,
|
|
450
|
+
ts: fresh.tokscale,
|
|
451
|
+
td: fresh.tokendash,
|
|
452
|
+
platform,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// #8 (2026-06-27): the Board tab shows ALL SUBMISSIONS ranked (raw submission rows), not operator
|
|
457
|
+
// aggregates. Try the NEW GET /api/v1/submissions first; if it's not deployed yet (non-200 / 404),
|
|
458
|
+
// FALL BACK to the existing /api/v1/leaderboard so nothing breaks pre-deploy. The return is tagged
|
|
459
|
+
// with `_source` ('submissions' | 'leaderboard') so renderBoard picks the right column layout.
|
|
460
|
+
async function loadBoardData(window = "30d") {
|
|
461
|
+
// Primary: submissions endpoint (ranked submission rows).
|
|
462
|
+
try {
|
|
463
|
+
const res = await fetch(
|
|
464
|
+
`${DEFAULT_API_BASE}/api/v1/submissions?window=${window}&metric=${LEADERBOARD_METRIC}`,
|
|
465
|
+
{
|
|
466
|
+
headers: { accept: "application/json" },
|
|
467
|
+
},
|
|
468
|
+
);
|
|
469
|
+
if (res.ok) {
|
|
470
|
+
const data = await res.json();
|
|
471
|
+
return { ...data, _source: "submissions" };
|
|
472
|
+
}
|
|
473
|
+
} catch {
|
|
474
|
+
/* fall through to leaderboard */
|
|
475
|
+
}
|
|
476
|
+
// Fallback: existing leaderboard endpoint + current render (pre-deploy safety).
|
|
477
|
+
try {
|
|
478
|
+
const res = await fetch(
|
|
479
|
+
`${DEFAULT_API_BASE}/api/v1/leaderboard?window=${window}&metric=${LEADERBOARD_METRIC}`,
|
|
480
|
+
{
|
|
481
|
+
headers: { accept: "application/json" },
|
|
482
|
+
},
|
|
483
|
+
);
|
|
484
|
+
if (!res.ok) return null;
|
|
485
|
+
const data = await res.json();
|
|
486
|
+
return { ...data, _source: "leaderboard" };
|
|
487
|
+
} catch {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ── TAB BAR ──────────────────────────────────────────────────────────────────
|
|
493
|
+
const TABS = [
|
|
494
|
+
{ key: "1", label: "Dashboard", short: "D" },
|
|
495
|
+
{ key: "2", label: "Trends", short: "T" }, // every metric across windows (sub-views: You/Platform/Field)
|
|
496
|
+
{ key: "3", label: "Compare", short: "C" },
|
|
497
|
+
{ key: "4", label: "Board", short: "B" },
|
|
498
|
+
{ key: "5", label: "Watch", short: "W" }, // in-TUI landing panel; [Enter] launches the watcher
|
|
499
|
+
{ key: "6", label: "Connect", short: "N" }, // sign in / switch device — the whole app is the TUI
|
|
500
|
+
];
|
|
501
|
+
|
|
502
|
+
// Four Degrees of Leverage — reference values matching signalaf.com/wiki/four-degrees.
|
|
503
|
+
// base = AA baseline (7:2:1 modeled reference, static)
|
|
504
|
+
// hcm = Human Center of Mass (median of all real operators)
|
|
505
|
+
// power = Power users (median of top 100 real operators)
|
|
506
|
+
// top = Top Evals (single top real operator)
|
|
507
|
+
// STATIC fallback values match the web app's GoldColumn fallbacks (lib/marketing/top-operator-column.ts).
|
|
508
|
+
// Goes live (hcm/power/top computed from the board) once there's real user volume.
|
|
509
|
+
const TD = {
|
|
510
|
+
base: { yield: 1.75, snr: 0.33, vel: 0.50, lev: 3.5, d10: 0.54 },
|
|
511
|
+
hcm: { yield: 1.57, snr: 0.33, vel: 0.50, lev: 3.2, d10: 0.50 },
|
|
512
|
+
power: { yield: 1.51, snr: 0.07, vel: 0.08, lev: 22.3, d10: 1.35 },
|
|
513
|
+
top: { yield: 488.65, snr: 0.58, vel: 1.36, lev: 360.2, d10: 2.56 },
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
function renderTabBar(activeIdx) {
|
|
517
|
+
const w = W();
|
|
518
|
+
const ts = new Date().toLocaleTimeString("en-US", { hour12: false });
|
|
519
|
+
|
|
520
|
+
// logo
|
|
521
|
+
const logo = ` ${gold("⊙ SigRank")}`;
|
|
522
|
+
// tabs
|
|
523
|
+
const tabStr = TABS.map((t, i) => {
|
|
524
|
+
const lbl = ` ${t.key}:${t.label} `;
|
|
525
|
+
return i === activeIdx
|
|
526
|
+
? `${c.bgCyan}${c.boldCyan}${lbl}${c.reset}`
|
|
527
|
+
: `${c.bgDim}${c.dim}${lbl}${c.reset}`;
|
|
528
|
+
}).join("");
|
|
529
|
+
// right side — version (single source of truth) · site · clock
|
|
530
|
+
const right = dim(
|
|
531
|
+
`${gold("v" + VERSION)}${c.reset}${c.dim} · signalaf.com · ${ts}`,
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
const logoVis = stripAnsi(logo).length;
|
|
535
|
+
const tabsVis = TABS.reduce((a, t) => a + t.label.length + 4, 0);
|
|
536
|
+
const rightVis = stripAnsi(right).length;
|
|
537
|
+
const gap = Math.max(1, w - logoVis - tabsVis - rightVis - 2);
|
|
538
|
+
|
|
539
|
+
writeln(`${logo} ${tabStr}${" ".repeat(gap)}${right}`);
|
|
540
|
+
writeln(` ${hr()}`);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ── GRAPHICS helpers ─────────────────────────────────────────────────────────
|
|
544
|
+
|
|
545
|
+
// Horizontal stacked bar: input | cacheW | cacheR | output
|
|
546
|
+
function tokenBar(p, width = 40) {
|
|
547
|
+
if (!p) return dim(" no data");
|
|
548
|
+
const total =
|
|
549
|
+
(p.input ?? 0) +
|
|
550
|
+
(p.output ?? 0) +
|
|
551
|
+
(p.cacheCreate ?? 0) +
|
|
552
|
+
(p.cacheRead ?? 0);
|
|
553
|
+
if (total === 0) return dim(" no data");
|
|
554
|
+
const seg = (val, colorCode) => {
|
|
555
|
+
const w = Math.round((val / total) * width);
|
|
556
|
+
return w > 0 ? paint(colorCode, "█".repeat(w)) : "";
|
|
557
|
+
};
|
|
558
|
+
const bar =
|
|
559
|
+
seg(p.input, c.cyan) +
|
|
560
|
+
seg(p.cacheCreate, c.blue) +
|
|
561
|
+
seg(p.cacheRead, c.boldGold) +
|
|
562
|
+
seg(p.output, c.green);
|
|
563
|
+
return bar;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Log-scale per-pillar breakdown — 4 separate log10 bars (input / cacheW / cacheR / output)
|
|
567
|
+
// so every pillar stays visible even when one dominates by 1000× (e.g. MOSES cacheRead
|
|
568
|
+
// 2.5B vs input 1.25M). The linear tokenBar() above erases small pillars in that case;
|
|
569
|
+
// this companion view shows the true shape. Returns an array of 4 lines:
|
|
570
|
+
// [" I ▏▎▍▌ 1.25M", " W ▏▎▍▌▋ 128M", " R ▏▎▍▌▋▊▉█ 2.5B", " O ▏▎▍ 11.3M"]
|
|
571
|
+
// maxLog is derived from the largest pillar so the biggest pillar fills the bar.
|
|
572
|
+
function logTokenBar(p, width = 36) {
|
|
573
|
+
if (!p) return [dim(" no data")];
|
|
574
|
+
const vals = [
|
|
575
|
+
{ lbl: "I", v: p.input ?? 0, color: c.cyan },
|
|
576
|
+
{ lbl: "W", v: p.cacheCreate ?? 0, color: c.blue },
|
|
577
|
+
{ lbl: "R", v: p.cacheRead ?? 0, color: c.boldGold },
|
|
578
|
+
{ lbl: "O", v: p.output ?? 0, color: c.green },
|
|
579
|
+
];
|
|
580
|
+
const maxV = Math.max(...vals.map((x) => x.v), 1);
|
|
581
|
+
const maxLog = Math.log10(maxV);
|
|
582
|
+
if (maxLog <= 0) return [dim(" no data")]; // all pillars ≤ 1 → nothing to show
|
|
583
|
+
return vals.map(({ lbl, v, color }) => {
|
|
584
|
+
const { bar } = logBar(v, maxLog, width, color);
|
|
585
|
+
return ` ${paint(color, lbl)} ${bar} ${dim(fmtTok(v))}`;
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Generalized cascade-metric sparkline across windows (7d→30d→90d→all).
|
|
590
|
+
// pick: (cas) => number · fmt: (v) => string. Used by the Trends tab for every metric.
|
|
591
|
+
// FIX G1: window headers are rendered once at the top of each metric block (mirror
|
|
592
|
+
// renderCompare's column layout). metricSpark returns sparkline + RIGHT-ALIGNED
|
|
593
|
+
// values in fixed COL_W columns — NO per-value `w:` prefix (the header row
|
|
594
|
+
// carries the window labels). Null windows render a dim `—` (columns stay aligned).
|
|
595
|
+
const TREND_COL_W = 10;
|
|
596
|
+
const TREND_WINS = ["7d", "30d", "90d", "all"];
|
|
597
|
+
|
|
598
|
+
function trendHeader() {
|
|
599
|
+
return (
|
|
600
|
+
padEnd(dim(""), 12) +
|
|
601
|
+
TREND_WINS.map((w) => padStart(dim(w), TREND_COL_W)).join(" ")
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function metricSpark(d, pick, fmt) {
|
|
606
|
+
const vals = TREND_WINS.map((w) => {
|
|
607
|
+
const wd = d.windows?.find((x) => x.window === w);
|
|
608
|
+
if (!wd) return null;
|
|
609
|
+
const cas = cascadeFrom(wd.pillars);
|
|
610
|
+
return cas ? pick(cas, wd.pillars) : null; // pick gets (cascade, pillars)
|
|
611
|
+
});
|
|
612
|
+
const cols = vals.map((v) =>
|
|
613
|
+
v != null ? padStart(fmt(v), TREND_COL_W) : padStart(dim("—"), TREND_COL_W),
|
|
614
|
+
);
|
|
615
|
+
// Non-numeric pick values (e.g. Op Ratio returns an object) can't sparkline —
|
|
616
|
+
// show just the formatted columns. Numeric values get the sparkline prefix.
|
|
617
|
+
const numericVals = vals.map((v) =>
|
|
618
|
+
v != null && Number.isFinite(v) ? v : null,
|
|
619
|
+
);
|
|
620
|
+
const hasNumeric = numericVals.some((v) => v != null);
|
|
621
|
+
if (!hasNumeric) return ` ${cols.join(" ")}`;
|
|
622
|
+
return sparkline(numericVals) + ` ${cols.join(" ")}`;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ── TAB 1: DASHBOARD ─────────────────────────────────────────────────────────
|
|
626
|
+
|
|
627
|
+
// SCROLL-VIEW (2026-06-27): section-scoped scroll for the cascade table.
|
|
628
|
+
// The cascade section gets a real scroll viewport — up/down arrows scroll just
|
|
629
|
+
// the cascade rows while the rest of the dashboard (header, token composition,
|
|
630
|
+
// board, footer) stays pinned. This replaces the misleading "+N more — resize /
|
|
631
|
+
// scroll" text (A3) which implied scroll existed but didn't (alt-screen locked
|
|
632
|
+
// frame = no terminal scroll). The scroll is IN-SECTION only, on the Dashboard tab.
|
|
633
|
+
|
|
634
|
+
const DASH_WINS = ["7d", "30d", "90d", "all"];
|
|
635
|
+
|
|
636
|
+
// Count how many cascade rows the active platforms would produce (every window
|
|
637
|
+
// with data across all active platforms). This is the "total wanted" count.
|
|
638
|
+
function cascadeScrollableCount(active) {
|
|
639
|
+
return active.reduce(
|
|
640
|
+
(n, d) =>
|
|
641
|
+
n +
|
|
642
|
+
DASH_WINS.filter((wk) => {
|
|
643
|
+
const wd = d.windows?.find((x) => x.window === wk);
|
|
644
|
+
return wd && wd.pillars.input + wd.pillars.output > 0;
|
|
645
|
+
}).length,
|
|
646
|
+
0,
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Compute the max cascade rows that fit in the current terminal height budget.
|
|
651
|
+
// Mirrors the calculation in renderDashboard (budget - used - sectionsBelow).
|
|
652
|
+
function maxCascadeRowsFor(active) {
|
|
653
|
+
const budget = H() - 4; // 2 tab bar + 2 footer
|
|
654
|
+
const platformCount = active.length;
|
|
655
|
+
const barLines = 4 + platformCount + (platformCount > 1 ? 1 : 0);
|
|
656
|
+
const boardLines = 17;
|
|
657
|
+
const sectionsBelow = barLines + boardLines;
|
|
658
|
+
// used before cascade = header(2) + blank(1) + title(1) + blank(1) + col-header(1) + separator(1) = 7
|
|
659
|
+
const usedBeforeCascade = 7;
|
|
660
|
+
return Math.max(8, budget - usedBeforeCascade - sectionsBelow);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function renderDashboard(data, status = "", scrollOffset = 0) {
|
|
664
|
+
const w = W();
|
|
665
|
+
const budget = H() - 4; // 2 tab bar + 2 footer
|
|
666
|
+
const WINS = ["7d", "30d", "90d", "all"];
|
|
667
|
+
let used = 0;
|
|
668
|
+
const emit = (s = "") => {
|
|
669
|
+
if (used < budget) {
|
|
670
|
+
writeln(s);
|
|
671
|
+
used++;
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
// A2 (2026-06-27): tolerate null/empty dashData so the FIRST paint can happen BEFORE the
|
|
676
|
+
// initial load resolves (loadAll renders an empty frame + "reading your cascade…" immediately,
|
|
677
|
+
// then re-renders with real data). Guard the destructure: no data → header + status + return.
|
|
678
|
+
if (!data || !Array.isArray(data.active)) {
|
|
679
|
+
emit();
|
|
680
|
+
emit(` ${bold("Your Cascade")} ${dim("all platforms · all windows")}`);
|
|
681
|
+
emit();
|
|
682
|
+
emit(` ${dim(" reading token logs… (press [R] to refresh)")}`);
|
|
683
|
+
if (status && used < budget) emit(` ${dim(status)}`);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const { active } = data;
|
|
688
|
+
|
|
689
|
+
// Responsive: the full 12-col table is ~124 wide. On terminals narrower than
|
|
690
|
+
// that, drop the two derived columns (Vel, 10x) and tighten the inter-column
|
|
691
|
+
// gap from 2 spaces to 1, so the core pillars + Υ/SNR/Lev/Class still fit.
|
|
692
|
+
// On very narrow terminals (<100), also hide Class and shorten cache headers
|
|
693
|
+
// to CW/CR so the core pillars + Υ/SNR/Lev still fit at 80 cols.
|
|
694
|
+
const narrow = w < 124;
|
|
695
|
+
const veryNarrow = w < 100;
|
|
696
|
+
const ultraNarrow = w < 90;
|
|
697
|
+
const gap = narrow ? " " : " ";
|
|
698
|
+
const cwLabel = veryNarrow ? "CW" : "CacheW";
|
|
699
|
+
const crLabel = veryNarrow ? "CR" : "CacheR";
|
|
700
|
+
const renderRow = (label, colorFn, winKey, p, est = false) => {
|
|
701
|
+
const cas = cascadeFrom(p);
|
|
702
|
+
if (!cas) return;
|
|
703
|
+
const clsFn = CLS[cas.class] ?? ((s) => s);
|
|
704
|
+
const cols = [
|
|
705
|
+
padEnd(colorFn(label), 12),
|
|
706
|
+
padEnd(dim(winKey), 5),
|
|
707
|
+
padStart(est ? dim("~") + fmtTok(p.input) : fmtTok(p.input), 8),
|
|
708
|
+
padStart(fmtTok(p.output), 8),
|
|
709
|
+
padStart(
|
|
710
|
+
(p.cacheCreate ?? 0) > 0
|
|
711
|
+
? est
|
|
712
|
+
? dim("~") + fmtTok(p.cacheCreate)
|
|
713
|
+
: fmtTok(p.cacheCreate)
|
|
714
|
+
: dim("—"),
|
|
715
|
+
8,
|
|
716
|
+
),
|
|
717
|
+
padStart((p.cacheRead ?? 0) > 0 ? fmtTok(p.cacheRead) : dim("—"), 9),
|
|
718
|
+
padStart(cas.yield > 10000 ? gold(fmtY(cas.yield)) : fmtY(cas.yield), 9),
|
|
719
|
+
...(ultraNarrow ? [] : [padStart(fmtSNR(cas.snr), 7)]),
|
|
720
|
+
...(ultraNarrow ? [] : [padStart(fmtLev(cas.leverage) + "×", 7)]),
|
|
721
|
+
...(narrow
|
|
722
|
+
? []
|
|
723
|
+
: [
|
|
724
|
+
padStart(cas.velocity?.toFixed(2) ?? "—", 6),
|
|
725
|
+
padStart(cas.dev10x?.toFixed(2) ?? "—", 6),
|
|
726
|
+
]),
|
|
727
|
+
...(veryNarrow ? [] : [padEnd(clsFn(cas.class), 13)]),
|
|
728
|
+
];
|
|
729
|
+
emit(` ${cols.join(gap)}`);
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
// ── Cascade table
|
|
733
|
+
emit();
|
|
734
|
+
emit(` ${bold("Your Cascade")} ${dim("all platforms · all windows")}`);
|
|
735
|
+
emit();
|
|
736
|
+
|
|
737
|
+
// header (matches renderRow's responsive column set + gap)
|
|
738
|
+
const CH = [
|
|
739
|
+
padEnd(hdr("Platform"), 12),
|
|
740
|
+
padEnd(hdr("Win"), 5),
|
|
741
|
+
padStart(hdr("Input"), 8),
|
|
742
|
+
padStart(hdr("Output"), 8),
|
|
743
|
+
padStart(hdr(cwLabel), 8),
|
|
744
|
+
padStart(hdr(crLabel), 9),
|
|
745
|
+
padStart(hdr("Υ Yield"), 9),
|
|
746
|
+
...(ultraNarrow ? [] : [padStart(hdr("SNR"), 7)]),
|
|
747
|
+
...(ultraNarrow ? [] : [padStart(hdr("Lev"), 7)]),
|
|
748
|
+
...(narrow ? [] : [padStart(hdr("Vel"), 6), padStart(hdr("10x"), 6)]),
|
|
749
|
+
...(veryNarrow ? [] : [padEnd(hdr("Class"), 13)]),
|
|
750
|
+
];
|
|
751
|
+
emit(` ${CH.join(gap)}`);
|
|
752
|
+
emit(` ${dim("·".repeat(Math.max(0, Math.min(w - 4, narrow ? (ultraNarrow ? 70 : veryNarrow ? 80 : 96) : 114))))}`);
|
|
753
|
+
|
|
754
|
+
if (active.length === 0) {
|
|
755
|
+
// Count is derived from the registry — a hardcoded number drifts every time an
|
|
756
|
+
// adapter is added (it read "14 platforms" while ALL_PLATFORMS held 17).
|
|
757
|
+
emit(
|
|
758
|
+
` ${dim(` reading token logs… (${dashboardAutoPlatforms().length} automatic · omp is manual [O])`)}`,
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Calculate how many cascade rows we can fit — reserve space for lower sections.
|
|
763
|
+
// (Υ-trend section moved to the Trends tab; its old `sparkLines` reservation is gone,
|
|
764
|
+
// which is what was starving codex's cascade rows after the Your-Read/Three-Degrees panels landed.)
|
|
765
|
+
const platformCount = active.length;
|
|
766
|
+
const barLines = 4 + platformCount + (platformCount > 1 ? 1 : 0); // Token Composition: hr + header + platforms + combined + note
|
|
767
|
+
// Your Read (hr+header+up to 4 wins+up to 5 gaps+summary = 13) + Four Degrees (blank+hr+header+col-header+5 rows+note = 10) + Lifetime Tokens (blank+hr+header+platforms+combined+note = ~7) + status
|
|
768
|
+
const boardLines = 24;
|
|
769
|
+
const sectionsBelow = barLines + boardLines;
|
|
770
|
+
const maxCascadeRows = Math.max(8, budget - used - sectionsBelow);
|
|
771
|
+
|
|
772
|
+
// SCROLL-VIEW (2026-06-27): Build the full list of cascade rows first, then
|
|
773
|
+
// slice by [scrollOffset, scrollOffset + maxCascadeRows]. This gives a real
|
|
774
|
+
// in-section scroll viewport — up/down arrows scroll just the cascade rows
|
|
775
|
+
// while the rest of the dashboard stays pinned. Replaces the A3 "+N more —
|
|
776
|
+
// resize / scroll" text which implied scroll existed but didn't.
|
|
777
|
+
let firstWin = {};
|
|
778
|
+
const allCascadeRows = []; // flat list: { platform, winKey, pillars, estimated }
|
|
779
|
+
for (const d of active) {
|
|
780
|
+
firstWin[d.platform] = WINS.find((wk) => {
|
|
781
|
+
const wd = d.windows?.find((w) => w.window === wk);
|
|
782
|
+
return wd && wd.pillars.input + wd.pillars.output > 0;
|
|
783
|
+
});
|
|
784
|
+
for (const wk of WINS) {
|
|
785
|
+
const wd = d.windows?.find((x) => x.window === wk);
|
|
786
|
+
if (!wd || wd.pillars.input + wd.pillars.output === 0) continue;
|
|
787
|
+
allCascadeRows.push({
|
|
788
|
+
platform: d.platform,
|
|
789
|
+
winKey: wk,
|
|
790
|
+
pillars: wd.pillars,
|
|
791
|
+
estimated: d.estimated,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const totalWanted = allCascadeRows.length;
|
|
797
|
+
const canScroll = totalWanted > maxCascadeRows;
|
|
798
|
+
// Clamp scroll offset to valid range [0, totalWanted - maxCascadeRows]
|
|
799
|
+
const maxOffset = Math.max(0, totalWanted - maxCascadeRows);
|
|
800
|
+
const effectiveOffset = Math.min(scrollOffset, maxOffset);
|
|
801
|
+
const visibleRows = allCascadeRows.slice(
|
|
802
|
+
effectiveOffset,
|
|
803
|
+
effectiveOffset + maxCascadeRows,
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
// ▲ indicator: rows above the current viewport
|
|
807
|
+
if (canScroll && effectiveOffset > 0) {
|
|
808
|
+
emit(
|
|
809
|
+
` ${dim(` ▲ ${effectiveOffset} row${effectiveOffset > 1 ? "s" : ""} above`)}`,
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Render the visible slice — group by platform (emit a blank line between platforms)
|
|
814
|
+
let lastPlatform = null;
|
|
815
|
+
for (const row of visibleRows) {
|
|
816
|
+
if (lastPlatform !== null && row.platform !== lastPlatform) emit();
|
|
817
|
+
renderRow(
|
|
818
|
+
row.platform,
|
|
819
|
+
(s) => {
|
|
820
|
+
const pc = platformColor(row.platform);
|
|
821
|
+
return row.winKey === firstWin[row.platform] ? bold(pc(s)) : dim(s);
|
|
822
|
+
},
|
|
823
|
+
row.winKey,
|
|
824
|
+
row.pillars,
|
|
825
|
+
row.estimated,
|
|
826
|
+
);
|
|
827
|
+
lastPlatform = row.platform;
|
|
828
|
+
}
|
|
829
|
+
if (visibleRows.length > 0) emit();
|
|
830
|
+
|
|
831
|
+
// ▼ indicator: rows below the current viewport
|
|
832
|
+
const rowsBelow = totalWanted - effectiveOffset - visibleRows.length;
|
|
833
|
+
if (canScroll && rowsBelow > 0) {
|
|
834
|
+
emit(
|
|
835
|
+
` ${dim(` ▼ ${rowsBelow} row${rowsBelow > 1 ? "s" : ""} below · ↑↓ scroll`)}${effectiveOffset > 0 ? dim(` · ${effectiveOffset}/${maxOffset}`) : ""}`,
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// combined
|
|
840
|
+
if (active.length > 1 && used < budget - sectionsBelow) {
|
|
841
|
+
const lbl = active.map((d) => d.platform).join("+");
|
|
842
|
+
const hasEst = active.some((d) => d.estimated);
|
|
843
|
+
let fst = true;
|
|
844
|
+
for (const wk of WINS) {
|
|
845
|
+
const cp = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 };
|
|
846
|
+
let any = false;
|
|
847
|
+
for (const d of active) {
|
|
848
|
+
const wd = d.windows?.find((x) => x.window === wk);
|
|
849
|
+
if (!wd || wd.pillars.input + wd.pillars.output === 0) continue;
|
|
850
|
+
cp.input += wd.pillars.input ?? 0;
|
|
851
|
+
cp.output += wd.pillars.output ?? 0;
|
|
852
|
+
cp.cacheCreate += wd.pillars.cacheCreate ?? 0;
|
|
853
|
+
cp.cacheRead += wd.pillars.cacheRead ?? 0;
|
|
854
|
+
any = true;
|
|
855
|
+
}
|
|
856
|
+
if (!any) continue;
|
|
857
|
+
renderRow(lbl, (s) => (fst ? bold(cyan(s)) : dim(s)), wk, cp, hasEst);
|
|
858
|
+
fst = false;
|
|
859
|
+
}
|
|
860
|
+
emit();
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// (Υ Trend moved to the dedicated Trends tab [2] — every metric, with sub-views.)
|
|
864
|
+
|
|
865
|
+
// ── Token bar charts (skip if no room)
|
|
866
|
+
if (used < budget - boardLines) {
|
|
867
|
+
emit(` ${hr()}`);
|
|
868
|
+
emit(
|
|
869
|
+
` ${bold("Token Composition")} ${dim("█")}${paint(c.cyan, "I")}${dim(" in █")}${paint(c.blue, "W")}${dim(" cW █")}${paint(c.boldGold, "R")}${dim(" cR █")}${paint(c.green, "O")}${dim(" out")}`,
|
|
870
|
+
);
|
|
871
|
+
for (const d of active) {
|
|
872
|
+
if (used >= budget - boardLines) break;
|
|
873
|
+
const all = d.windows?.find((w) => w.window === "all");
|
|
874
|
+
if (!all) continue;
|
|
875
|
+
emit(
|
|
876
|
+
` ${padEnd(platformColor(d.platform)(d.platform), 10)} ${tokenBar(all.pillars, 50)} ${dim(fmtTok((all.pillars.input ?? 0) + (all.pillars.output ?? 0) + (all.pillars.cacheCreate ?? 0) + (all.pillars.cacheRead ?? 0)))}`,
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
if (active.length > 1) {
|
|
880
|
+
const cp = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 };
|
|
881
|
+
for (const d of active) {
|
|
882
|
+
const all = d.windows?.find((w) => w.window === "all");
|
|
883
|
+
if (!all) continue;
|
|
884
|
+
cp.input += all.pillars.input ?? 0;
|
|
885
|
+
cp.output += all.pillars.output ?? 0;
|
|
886
|
+
cp.cacheCreate += all.pillars.cacheCreate ?? 0;
|
|
887
|
+
cp.cacheRead += all.pillars.cacheRead ?? 0;
|
|
888
|
+
}
|
|
889
|
+
emit(
|
|
890
|
+
` ${padEnd(bold(active.map((d) => d.platform).join("+")), 10)} ${tokenBar(cp, 50)} ${dim(fmtTok(cp.input + cp.output + cp.cacheCreate + cp.cacheRead))}`,
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
// Log-scale per-pillar breakdown — companion to the linear stacked bar above.
|
|
894
|
+
// The linear bar erases small pillars when one dominates by 1000× (MOSES-class:
|
|
895
|
+
// cacheRead 2.5B vs input 1.25M → input gets 0 width). The log view shows every
|
|
896
|
+
// pillar's true shape so the operator can see the full composition at a glance.
|
|
897
|
+
// Only renders for the combined row (or the single platform when there's one) to
|
|
898
|
+
// avoid blowing the height budget on multi-platform setups.
|
|
899
|
+
if (used < budget - boardLines - 5) {
|
|
900
|
+
const cp = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 };
|
|
901
|
+
for (const d of active) {
|
|
902
|
+
const all = d.windows?.find((w) => w.window === "all");
|
|
903
|
+
if (!all) continue;
|
|
904
|
+
cp.input += all.pillars.input ?? 0;
|
|
905
|
+
cp.output += all.pillars.output ?? 0;
|
|
906
|
+
cp.cacheCreate += all.pillars.cacheCreate ?? 0;
|
|
907
|
+
cp.cacheRead += all.pillars.cacheRead ?? 0;
|
|
908
|
+
}
|
|
909
|
+
const total = cp.input + cp.output + cp.cacheCreate + cp.cacheRead;
|
|
910
|
+
if (total > 0) {
|
|
911
|
+
emit(
|
|
912
|
+
` ${dim("log-scale (each 10× = same step — every pillar visible)")}`,
|
|
913
|
+
);
|
|
914
|
+
for (const line of logTokenBar(cp, 36)) emit(line);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// ── Your insights + the Four Degrees panel (replaces the old top-3 mini board)
|
|
920
|
+
// The user's own cascade (the firstWin/combined row computed above) read against the
|
|
921
|
+
// Four Degrees reference so the Dashboard says "here's you, here's the field, here's the ceiling."
|
|
922
|
+
const you = (() => {
|
|
923
|
+
// Best available cascade for the user: prefer claude's first-window, else any computed row.
|
|
924
|
+
for (const d of active) {
|
|
925
|
+
const wk = firstWin[d.platform];
|
|
926
|
+
const wd = d.windows?.find((x) => x.window === wk);
|
|
927
|
+
const cas = wd && cascadeFrom(wd.pillars);
|
|
928
|
+
// cascadeFrom returns null only when wd.pillars is falsy; otherwise it
|
|
929
|
+
// forwards to cascade() which always returns an object (degenerate input
|
|
930
|
+
// surfaces as null fields + warnings[], not a null return). The guard
|
|
931
|
+
// below skips rows with no pillars at all; downstream `chk(val == null)`
|
|
932
|
+
// guards handle degenerate-but-present pillars.
|
|
933
|
+
if (cas) return cas;
|
|
934
|
+
}
|
|
935
|
+
return null;
|
|
936
|
+
})();
|
|
937
|
+
|
|
938
|
+
if (used < budget - 8) {
|
|
939
|
+
// ── Custom insights — comparison vs the field + a concrete tip for the weakest metric
|
|
940
|
+
emit(` ${hr()}`);
|
|
941
|
+
emit(
|
|
942
|
+
` ${bold("Your Read")} ${dim("how your cascade sits vs the field")}`,
|
|
943
|
+
);
|
|
944
|
+
if (you) {
|
|
945
|
+
const wins = [],
|
|
946
|
+
gaps = [];
|
|
947
|
+
let aboveHcm = 0,
|
|
948
|
+
abovePower = 0,
|
|
949
|
+
totalMetrics = 0;
|
|
950
|
+
// Three-tier comparison: HCM (field median) → Power (top-100 median) → Top (best operator)
|
|
951
|
+
const chk = (label, val, hcm, power, top, fmt, tip) => {
|
|
952
|
+
if (val == null) return;
|
|
953
|
+
totalMetrics++;
|
|
954
|
+
if (val >= top * 0.9) {
|
|
955
|
+
wins.push(`${label} ${fmt(val)} — ${gold("top tier")}`);
|
|
956
|
+
aboveHcm++;
|
|
957
|
+
abovePower++;
|
|
958
|
+
} else if (val >= power) {
|
|
959
|
+
wins.push(`${label} ${fmt(val)} — ${paint(c.boldCyan, "power-user tier")}`);
|
|
960
|
+
aboveHcm++;
|
|
961
|
+
abovePower++;
|
|
962
|
+
} else if (val >= hcm) {
|
|
963
|
+
wins.push(`${label} ${fmt(val)} — above field, ${dim("approaching power-user")}`);
|
|
964
|
+
aboveHcm++;
|
|
965
|
+
} else {
|
|
966
|
+
gaps.push({
|
|
967
|
+
label,
|
|
968
|
+
txt: `${label} ${fmt(val)} — below field (${fmt(hcm)}): ${tip}`,
|
|
969
|
+
val,
|
|
970
|
+
hcm,
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
chk(
|
|
975
|
+
"Υ Yield",
|
|
976
|
+
you.yield,
|
|
977
|
+
TD.hcm.yield,
|
|
978
|
+
TD.power.yield,
|
|
979
|
+
TD.top.yield,
|
|
980
|
+
fmtY,
|
|
981
|
+
"compound more — reuse cache, raise output",
|
|
982
|
+
);
|
|
983
|
+
chk(
|
|
984
|
+
"SNR",
|
|
985
|
+
you.snr,
|
|
986
|
+
TD.hcm.snr,
|
|
987
|
+
TD.power.snr,
|
|
988
|
+
TD.top.snr,
|
|
989
|
+
fmtSNR,
|
|
990
|
+
"tighten prompts — less input per unit output",
|
|
991
|
+
);
|
|
992
|
+
chk(
|
|
993
|
+
"Leverage",
|
|
994
|
+
you.leverage,
|
|
995
|
+
TD.hcm.lev,
|
|
996
|
+
TD.power.lev,
|
|
997
|
+
TD.top.lev,
|
|
998
|
+
(v) => fmtLev(v) + "×",
|
|
999
|
+
"lean on cache-read — amplify prior context",
|
|
1000
|
+
);
|
|
1001
|
+
chk(
|
|
1002
|
+
"Velocity",
|
|
1003
|
+
you.velocity,
|
|
1004
|
+
TD.hcm.vel,
|
|
1005
|
+
TD.power.vel,
|
|
1006
|
+
TD.top.vel,
|
|
1007
|
+
(v) => v.toFixed(2),
|
|
1008
|
+
"more output per input token",
|
|
1009
|
+
);
|
|
1010
|
+
chk(
|
|
1011
|
+
"10xDEV",
|
|
1012
|
+
you.dev10x,
|
|
1013
|
+
TD.hcm.d10,
|
|
1014
|
+
TD.power.d10,
|
|
1015
|
+
TD.top.d10,
|
|
1016
|
+
(v) => v.toFixed(2),
|
|
1017
|
+
"raise leverage — every 10× lev adds 1 to 10xDEV",
|
|
1018
|
+
);
|
|
1019
|
+
for (const w of wins.slice(0, 4)) emit(` ${paint(c.green, "▲")} ${w}`);
|
|
1020
|
+
// all gaps shown; weakest gets the prescriptive tip
|
|
1021
|
+
const sortedGaps = gaps.sort(
|
|
1022
|
+
(a, b) => a.val / a.hcm - b.val / b.hcm,
|
|
1023
|
+
);
|
|
1024
|
+
for (let i = 0; i < sortedGaps.length; i++) {
|
|
1025
|
+
const g = sortedGaps[i];
|
|
1026
|
+
if (i === 0) emit(` ${paint(c.magenta, "▽")} ${dim(g.txt)}`);
|
|
1027
|
+
else emit(` ${paint(c.magenta, "▽")} ${dim(g.label + " below field")}`);
|
|
1028
|
+
}
|
|
1029
|
+
if (!wins.length && !sortedGaps.length) emit(` ${dim(" building your read…")}`);
|
|
1030
|
+
// summary line — where you sit across all metrics
|
|
1031
|
+
if (totalMetrics > 0) {
|
|
1032
|
+
const hcmStr = `${aboveHcm}/${totalMetrics}`;
|
|
1033
|
+
const pwrStr = `${abovePower}/${totalMetrics}`;
|
|
1034
|
+
emit(
|
|
1035
|
+
` ${dim(`above HCM on ${bold(hcmStr)} · above Power on ${bold(pwrStr)}`)}`,
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
} else {
|
|
1039
|
+
emit(` ${dim(" reading your cascade… (press [R] to refresh)")}`);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// ── Four Degrees — AA baseline · Human Center of Mass · Power users · Top Evals
|
|
1043
|
+
// Matches signalaf.com/wiki/four-degrees (expanded from 3 → 4 degrees 2026-07-17).
|
|
1044
|
+
emit();
|
|
1045
|
+
emit(` ${hr()}`);
|
|
1046
|
+
emit(
|
|
1047
|
+
` ${bold("Four Degrees")} ${dim("AA · HCM · Power · Top Evals — signalaf.com/wiki")}`,
|
|
1048
|
+
);
|
|
1049
|
+
const tdHead = [
|
|
1050
|
+
padEnd(hdr("Metric"), 10),
|
|
1051
|
+
padStart(hdr("AA base"), 9),
|
|
1052
|
+
padStart(hdr("HCM"), 9),
|
|
1053
|
+
padStart(hdr("Power"), 9),
|
|
1054
|
+
padStart(hdr("Top"), 9),
|
|
1055
|
+
];
|
|
1056
|
+
emit(` ${tdHead.join(" ")}`);
|
|
1057
|
+
const white = (s) => paint(c.white, s);
|
|
1058
|
+
const tdRow = (label, b, h, p, t, fmt) =>
|
|
1059
|
+
emit(
|
|
1060
|
+
` ${padEnd(label, 10)} ${padStart(dim(fmt(b)), 9)} ${padStart(white(fmt(h)), 9)} ${padStart(white(fmt(p)), 9)} ${padStart(gold(fmt(t)), 9)}`,
|
|
1061
|
+
);
|
|
1062
|
+
tdRow("Υ Yield", TD.base.yield, TD.hcm.yield, TD.power.yield, TD.top.yield, fmtY);
|
|
1063
|
+
tdRow("SNR", TD.base.snr, TD.hcm.snr, TD.power.snr, TD.top.snr, fmtSNR);
|
|
1064
|
+
tdRow("Velocity", TD.base.vel, TD.hcm.vel, TD.power.vel, TD.top.vel, (v) =>
|
|
1065
|
+
v.toFixed(2),
|
|
1066
|
+
);
|
|
1067
|
+
tdRow(
|
|
1068
|
+
"Leverage",
|
|
1069
|
+
TD.base.lev,
|
|
1070
|
+
TD.hcm.lev,
|
|
1071
|
+
TD.power.lev,
|
|
1072
|
+
TD.top.lev,
|
|
1073
|
+
(v) => fmtLev(v) + "×",
|
|
1074
|
+
);
|
|
1075
|
+
tdRow("10xDEV", TD.base.d10, TD.hcm.d10, TD.power.d10, TD.top.d10, (v) => v.toFixed(2));
|
|
1076
|
+
emit(
|
|
1077
|
+
` ${dim("reference values until live user volume calibrates HCM · Power · Top")}`,
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// ── Lifetime Tokens — submission-based rolled-forward totals (never decrease)
|
|
1082
|
+
// Shows when the operator is signed in and has submission history. The batched
|
|
1083
|
+
// data comes from the server (computeBatchedWindows over snapshot_submissions).
|
|
1084
|
+
const batched = data?.batchedData;
|
|
1085
|
+
if (batched && used < budget - 4) {
|
|
1086
|
+
const fmtT = (n) => {
|
|
1087
|
+
if (n >= 1e9) return (n / 1e9).toFixed(1) + "B";
|
|
1088
|
+
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
|
|
1089
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
|
|
1090
|
+
return String(n);
|
|
1091
|
+
};
|
|
1092
|
+
const total = (p) => (p?.input ?? 0) + (p?.output ?? 0) + (p?.cacheCreate ?? 0) + (p?.cacheRead ?? 0);
|
|
1093
|
+
emit();
|
|
1094
|
+
emit(` ${hr()}`);
|
|
1095
|
+
emit(` ${bold("Lifetime Tokens")} ${dim("archived submissions · never decreases")}`);
|
|
1096
|
+
const platforms = Object.entries(batched.per_platform ?? {});
|
|
1097
|
+
for (const [plat, pw] of platforms) {
|
|
1098
|
+
if (used >= budget - 2) break;
|
|
1099
|
+
const pc = platformColor(plat);
|
|
1100
|
+
const allT = total(pw.all);
|
|
1101
|
+
const d90T = total(pw["90d"]);
|
|
1102
|
+
emit(
|
|
1103
|
+
` ${padEnd(pc(plat), 10)} ${padStart(fmtT(allT), 8)} total ${dim("·")} ${padStart(fmtT(d90T), 8)} in last 90d`,
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
if (platforms.length > 1) {
|
|
1107
|
+
const cAll = total(batched.combined?.all);
|
|
1108
|
+
const c90 = total(batched.combined?.["90d"]);
|
|
1109
|
+
emit(
|
|
1110
|
+
` ${padEnd(bold("combined"), 10)} ${padStart(bold(fmtT(cAll)), 8)} total ${dim("·")} ${padStart(bold(fmtT(c90)), 8)} in last 90d`,
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
// Find the earliest baseline date across platforms
|
|
1114
|
+
const baselineDates = platforms.map(([, pw]) => pw.baseline_date).filter(Boolean).sort();
|
|
1115
|
+
const subCounts = platforms.map(([, pw]) => pw.submission_count ?? 0);
|
|
1116
|
+
const totalSubs = subCounts.reduce((a, b) => a + b, 0);
|
|
1117
|
+
if (baselineDates.length > 0) {
|
|
1118
|
+
emit(
|
|
1119
|
+
` ${dim(`${totalSubs} submission${totalSubs !== 1 ? "s" : ""} archived · baseline ${baselineDates[0].slice(0, 10)}`)}`,
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
if (status && used < budget) emit(` ${dim(status)}`);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// ── TAB 2: TRENDS ─────────────────────────────────────────────────────────────
|
|
1128
|
+
// Every cascade metric (+ rank) as a 7d→30d→90d→all sparkline. Three sub-tabs:
|
|
1129
|
+
// You — your own trajectory (+ rank trend, "calculating" until wired)
|
|
1130
|
+
// Platform — per-platform (claude / codex / combined)
|
|
1131
|
+
// Field — board-wide trend (calculating until window-history materializes)
|
|
1132
|
+
// Switch sub-tabs with [T] (cycles You · Platform · Field). Data: the same `active` the Dashboard loads.
|
|
1133
|
+
const TREND_SUBTABS = ["You", "Platform", "Field"];
|
|
1134
|
+
// All leaderboard metrics. Most derive from the per-window cascade (pick takes the
|
|
1135
|
+
// cascade obj + its pillars); §IGNA (proprietary composite) and $/1M (needs cost data)
|
|
1136
|
+
// aren't computed client-side yet → rendered "calculating", same honest pattern as Rank.
|
|
1137
|
+
const AA_EFF_BASELINE = 4.0; // efficiency is measured vs the AA 4.0 baseline (per the wiki)
|
|
1138
|
+
const TREND_METRICS = [
|
|
1139
|
+
{
|
|
1140
|
+
label: "∑ Total",
|
|
1141
|
+
pick: (c, p) =>
|
|
1142
|
+
(p.input ?? 0) +
|
|
1143
|
+
(p.output ?? 0) +
|
|
1144
|
+
(p.cacheCreate ?? 0) +
|
|
1145
|
+
(p.cacheRead ?? 0),
|
|
1146
|
+
fmt: fmtTok,
|
|
1147
|
+
},
|
|
1148
|
+
{ label: "Υ Yield", pick: (c) => c.yield, fmt: fmtY },
|
|
1149
|
+
{ label: "SNR", pick: (c) => c.snr, fmt: fmtSNR },
|
|
1150
|
+
{ label: "Velocity", pick: (c) => c.velocity, fmt: (v) => v.toFixed(2) },
|
|
1151
|
+
{ label: "Leverage", pick: (c) => c.leverage, fmt: (v) => fmtLev(v) + "×" },
|
|
1152
|
+
{ label: "10xDEV", pick: (c) => c.dev10x, fmt: (v) => v.toFixed(2) },
|
|
1153
|
+
{
|
|
1154
|
+
label: "Eff",
|
|
1155
|
+
pick: (c) => (c.yield != null ? c.yield / AA_EFF_BASELINE : null),
|
|
1156
|
+
fmt: (v) => v.toFixed(1) + "×",
|
|
1157
|
+
},
|
|
1158
|
+
{
|
|
1159
|
+
label: "§IGNA",
|
|
1160
|
+
pick: () => null,
|
|
1161
|
+
fmt: () => "—",
|
|
1162
|
+
stub: "composite calibrating (needs user volume)",
|
|
1163
|
+
},
|
|
1164
|
+
// FIX A4 (2026-06-27): operating ratio is INPUT-NORMALIZED `cache : 1 : output` (matches web canon —
|
|
1165
|
+
// ThreeDegreesChart / OperatingRatioBar, e.g. "3.5 : 1 : 0.50"), not raw token counts (which also
|
|
1166
|
+
// overflowed the Trends columns).
|
|
1167
|
+
{
|
|
1168
|
+
label: "Op Ratio",
|
|
1169
|
+
pick: (c, p) =>
|
|
1170
|
+
(p.input ?? 0) > 0
|
|
1171
|
+
? {
|
|
1172
|
+
cacheRead: p.cacheRead ?? 0,
|
|
1173
|
+
input: p.input ?? 0,
|
|
1174
|
+
output: p.output ?? 0,
|
|
1175
|
+
}
|
|
1176
|
+
: null,
|
|
1177
|
+
fmt: (v) => {
|
|
1178
|
+
const i = v.input || 1;
|
|
1179
|
+
return `${(v.cacheRead / i).toFixed(1)} : 1 : ${(v.output / i).toFixed(2)}`;
|
|
1180
|
+
},
|
|
1181
|
+
},
|
|
1182
|
+
{
|
|
1183
|
+
label: "$/1M",
|
|
1184
|
+
pick: () => null,
|
|
1185
|
+
fmt: () => "—",
|
|
1186
|
+
stub: "cost data wires post-ingest",
|
|
1187
|
+
},
|
|
1188
|
+
];
|
|
1189
|
+
function renderTrends(data, subIdx = 0) {
|
|
1190
|
+
const { active } = data ?? {};
|
|
1191
|
+
const budget = H() - 4;
|
|
1192
|
+
let used = 0;
|
|
1193
|
+
const emit = (s = "") => {
|
|
1194
|
+
if (used < budget) {
|
|
1195
|
+
writeln(s);
|
|
1196
|
+
used++;
|
|
1197
|
+
}
|
|
1198
|
+
};
|
|
1199
|
+
const sub = TREND_SUBTABS[subIdx] ?? "You";
|
|
1200
|
+
|
|
1201
|
+
// sub-tab switcher row
|
|
1202
|
+
const subBar = TREND_SUBTABS.map((t, i) =>
|
|
1203
|
+
i === subIdx
|
|
1204
|
+
? `${c.bgCyan}${c.boldCyan} ${t} ${c.reset}`
|
|
1205
|
+
: `${dim(` ${t} `)}`,
|
|
1206
|
+
).join(" ");
|
|
1207
|
+
emit();
|
|
1208
|
+
emit(
|
|
1209
|
+
` ${bold("Trends")} ${dim("every metric across windows (7d → all)")} ${subBar}`,
|
|
1210
|
+
);
|
|
1211
|
+
emit(` ${dim("·".repeat(Math.min(W() - 4, 70)))}`);
|
|
1212
|
+
emit();
|
|
1213
|
+
|
|
1214
|
+
if (sub === "You" || sub === "Platform") {
|
|
1215
|
+
// FIX (2026-08-18): "You" previously took only active[0]?.windows (the first
|
|
1216
|
+
// platform by sort order — usually claude). If that platform had no timestamps
|
|
1217
|
+
// (NULL-TIMESTAMP WINDOW ASYMMETRY → 7d/30d/90d empty) or no local data, "You"
|
|
1218
|
+
// showed nothing even when other platforms had data. Now aggregate ALL active
|
|
1219
|
+
// platforms' windows into one combined "you" row, summing pillars per window
|
|
1220
|
+
// so every platform contributes to every window it has data for.
|
|
1221
|
+
const rows =
|
|
1222
|
+
sub === "You"
|
|
1223
|
+
? active?.length
|
|
1224
|
+
? [
|
|
1225
|
+
{
|
|
1226
|
+
platform: "you",
|
|
1227
|
+
windows: TREND_WINS.map((wk) => {
|
|
1228
|
+
let input = 0,
|
|
1229
|
+
output = 0,
|
|
1230
|
+
cacheCreate = 0,
|
|
1231
|
+
cacheRead = 0,
|
|
1232
|
+
messages = 0;
|
|
1233
|
+
for (const d of active) {
|
|
1234
|
+
const wd = d.windows?.find((x) => x.window === wk);
|
|
1235
|
+
if (wd) {
|
|
1236
|
+
input += wd.pillars.input ?? 0;
|
|
1237
|
+
output += wd.pillars.output ?? 0;
|
|
1238
|
+
cacheCreate += wd.pillars.cacheCreate ?? 0;
|
|
1239
|
+
cacheRead += wd.pillars.cacheRead ?? 0;
|
|
1240
|
+
messages += wd.messages ?? 0;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
return {
|
|
1244
|
+
window: wk,
|
|
1245
|
+
pillars: { input, output, cacheCreate, cacheRead },
|
|
1246
|
+
messages,
|
|
1247
|
+
};
|
|
1248
|
+
}),
|
|
1249
|
+
},
|
|
1250
|
+
]
|
|
1251
|
+
: []
|
|
1252
|
+
: (active ?? []);
|
|
1253
|
+
if (!rows.length) {
|
|
1254
|
+
emit(` ${dim(" reading your cascade… (press [R] to refresh)")}`);
|
|
1255
|
+
}
|
|
1256
|
+
// A3 (2026-06-27): count metric/rank rows dropped by the height budget so we can show a
|
|
1257
|
+
// "+N more — resize / scroll" note instead of silently truncating. Each rendered block wants
|
|
1258
|
+
// TREND_METRICS.length metric rows + 1 rank row. (Fit logic below unchanged — observe only.)
|
|
1259
|
+
let trendsDropped = 0;
|
|
1260
|
+
for (let ri = 0; ri < rows.length; ri++) {
|
|
1261
|
+
const d = rows[ri];
|
|
1262
|
+
if (used >= budget - 2) {
|
|
1263
|
+
trendsDropped += (rows.length - ri) * (TREND_METRICS.length + 1);
|
|
1264
|
+
break;
|
|
1265
|
+
}
|
|
1266
|
+
emit(` ${bold(sub === "You" ? "Your trajectory" : cyan(d.platform))}`);
|
|
1267
|
+
// FIX G1: window column headers (mirror renderCompare) — one header row, then
|
|
1268
|
+
// values in fixed-width columns below. No per-value `w:` prefix.
|
|
1269
|
+
emit(` ${trendHeader()}`);
|
|
1270
|
+
let mi = 0;
|
|
1271
|
+
for (; mi < TREND_METRICS.length; mi++) {
|
|
1272
|
+
const m = TREND_METRICS[mi];
|
|
1273
|
+
if (used >= budget - 2) break;
|
|
1274
|
+
if (m.stub) {
|
|
1275
|
+
emit(
|
|
1276
|
+
` ${padEnd(dim(m.label), 10)} ${dim(" calculating… (" + m.stub + ")")}`,
|
|
1277
|
+
);
|
|
1278
|
+
} else {
|
|
1279
|
+
emit(
|
|
1280
|
+
` ${padEnd(cyan(m.label), 10)} ${metricSpark(d, m.pick, m.fmt)}`,
|
|
1281
|
+
);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
trendsDropped += TREND_METRICS.length - mi; // metrics this block couldn't fit
|
|
1285
|
+
// rank trend — wired later; honest placeholder for now
|
|
1286
|
+
if (used < budget - 1)
|
|
1287
|
+
emit(
|
|
1288
|
+
` ${padEnd(dim("Rank"), 10)} ${dim(" calculating… (rank history wires post-ingest)")}`,
|
|
1289
|
+
);
|
|
1290
|
+
else trendsDropped += 1;
|
|
1291
|
+
emit();
|
|
1292
|
+
}
|
|
1293
|
+
// A3: surface the dropped count instead of hiding it.
|
|
1294
|
+
// SCROLL-VIEW (2026-06-27): changed "resize / scroll" → "resize to see more"
|
|
1295
|
+
// because these tabs don't have a real scroll viewport (only Dashboard cascade does).
|
|
1296
|
+
if (trendsDropped > 0)
|
|
1297
|
+
emit(` ${dim(` +${trendsDropped} more — resize to see more`)}`);
|
|
1298
|
+
} else {
|
|
1299
|
+
// Field
|
|
1300
|
+
emit(` ${dim(" Field-wide trends — calculating…")}`);
|
|
1301
|
+
emit(
|
|
1302
|
+
` ${dim(" (needs per-window board history; materializes once live ingest lands)")}`,
|
|
1303
|
+
);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// ── TAB 3: COMPARE ───────────────────────────────────────────────────────────
|
|
1308
|
+
function renderCompare(data) {
|
|
1309
|
+
// FIX (2026-08-18): guard against null data — the debug renderOnce path
|
|
1310
|
+
// (node tui.mjs --render 1) passes null when loadCompareData fails, and
|
|
1311
|
+
// destructure of null throws. Coalesce to a safe empty shape.
|
|
1312
|
+
const { tpData, cc, ts, td } = data ?? {};
|
|
1313
|
+
const platform = data?.platform ?? "claude";
|
|
1314
|
+
const WINS = ["7d", "30d", "90d", "all"];
|
|
1315
|
+
const w = W();
|
|
1316
|
+
// Compare tab needs more vertical space — 4 sources × 4 pillars = 16 rows
|
|
1317
|
+
// + headers + cascade metrics. Use full terminal height (not H()-4) and
|
|
1318
|
+
// fall back to a generous fixed minimum when rows is undefined (--render mode).
|
|
1319
|
+
const budget = Math.max(H() - 2, 40);
|
|
1320
|
+
let used = 0;
|
|
1321
|
+
const emit = (s = "") => {
|
|
1322
|
+
if (used < budget) {
|
|
1323
|
+
writeln(s);
|
|
1324
|
+
used++;
|
|
1325
|
+
}
|
|
1326
|
+
};
|
|
1327
|
+
|
|
1328
|
+
const tpPillars = {};
|
|
1329
|
+
for (const win of tpData?.windows ?? []) tpPillars[win.window] = win.pillars;
|
|
1330
|
+
|
|
1331
|
+
const SOURCES = [
|
|
1332
|
+
{
|
|
1333
|
+
name: "tokenpull",
|
|
1334
|
+
color: (s) => paint(c.boldCyan, s),
|
|
1335
|
+
pillars: tpPillars,
|
|
1336
|
+
note: "JSONL canon",
|
|
1337
|
+
},
|
|
1338
|
+
{
|
|
1339
|
+
name: "ccusage",
|
|
1340
|
+
color: (s) => paint(c.green, s),
|
|
1341
|
+
pillars: cc ?? {},
|
|
1342
|
+
note: "ccusage CLI",
|
|
1343
|
+
},
|
|
1344
|
+
{
|
|
1345
|
+
name: "token-dash",
|
|
1346
|
+
color: (s) => paint(c.magenta, s),
|
|
1347
|
+
pillars: td ?? {},
|
|
1348
|
+
note: "SQLite",
|
|
1349
|
+
},
|
|
1350
|
+
{
|
|
1351
|
+
name: "tokscale",
|
|
1352
|
+
color: (s) => paint(c.blue, s),
|
|
1353
|
+
pillars: ts ?? {},
|
|
1354
|
+
note: "report.json",
|
|
1355
|
+
},
|
|
1356
|
+
].filter((s) => Object.keys(s.pillars).length > 0);
|
|
1357
|
+
|
|
1358
|
+
emit();
|
|
1359
|
+
// Shorten the source list on narrow terminals to avoid header overflow.
|
|
1360
|
+
const sourceList = w < 90
|
|
1361
|
+
? `${SOURCES.length} sources`
|
|
1362
|
+
: "tokenpull vs ccusage vs token-dash vs tokscale";
|
|
1363
|
+
emit(
|
|
1364
|
+
` ${bold("Source Comparison")} ${dim(`platform: ${platform}`)} ${dim(`· ${sourceList}`)}`,
|
|
1365
|
+
);
|
|
1366
|
+
|
|
1367
|
+
if (!tpData) {
|
|
1368
|
+
emit(
|
|
1369
|
+
` ${dim("tokenpull: no JSONL data found — check ~/.claude/projects/")}`,
|
|
1370
|
+
);
|
|
1371
|
+
} else {
|
|
1372
|
+
const all = tpPillars["all"];
|
|
1373
|
+
if (all) {
|
|
1374
|
+
emit(
|
|
1375
|
+
` ${dim("tokenpull all-time:")} In ${cyan(fmtTok(all.input))} Out ${green(fmtTok(all.output))} CW ${paint(c.blue, fmtTok(all.cacheCreate))} CR ${gold(fmtTok(all.cacheRead))}`,
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
// FIX (2026-08-18): surface WHY external verifiers are absent so the user knows
|
|
1380
|
+
// it's a missing-tool issue, not a bug. Each verifier is null when its CLI/DB
|
|
1381
|
+
// isn't installed or returned no data for this platform.
|
|
1382
|
+
const missing = [];
|
|
1383
|
+
if (!cc) missing.push("ccusage (npm i -g ccusage)");
|
|
1384
|
+
if (!td) missing.push("token-dashboard (~/.claude/token-dashboard.db)");
|
|
1385
|
+
if (!ts) missing.push("tokscale (npm i -g tokscale)");
|
|
1386
|
+
if (missing.length)
|
|
1387
|
+
for (const ln of wrapDim(`no data from: ${missing.join(" · ")}`)) emit(ln);
|
|
1388
|
+
emit();
|
|
1389
|
+
|
|
1390
|
+
// FIX (2026-08-18): if ALL sources are empty (tokenpull has no windows AND all
|
|
1391
|
+
// verifiers are null), show a clear empty-state message instead of bare headers
|
|
1392
|
+
// with zero data rows.
|
|
1393
|
+
if (SOURCES.length === 0) {
|
|
1394
|
+
for (const ln of wrapDim(
|
|
1395
|
+
"no source data available for this platform — install a verifier or check ~/.claude/projects/",
|
|
1396
|
+
))
|
|
1397
|
+
emit(ln);
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
const COL_W = 11;
|
|
1402
|
+
const hcols = [
|
|
1403
|
+
padEnd(dim("Source"), 12),
|
|
1404
|
+
padEnd(dim("Pillar"), 10),
|
|
1405
|
+
...WINS.map((wn) => padStart(dim(wn), COL_W)),
|
|
1406
|
+
];
|
|
1407
|
+
emit(` ${hcols.join(" ")}`);
|
|
1408
|
+
emit(
|
|
1409
|
+
` ${dim("·".repeat(Math.min(w - 4, 12 + 12 + WINS.length * (COL_W + 2))))}`,
|
|
1410
|
+
);
|
|
1411
|
+
|
|
1412
|
+
const PILLARS = [
|
|
1413
|
+
{ key: "input", label: "Input" },
|
|
1414
|
+
{ key: "output", label: "Output" },
|
|
1415
|
+
{ key: "cacheWrite", label: "CacheW", dbKey: "cacheCreate" },
|
|
1416
|
+
{ key: "cacheRead", label: "CacheR" },
|
|
1417
|
+
];
|
|
1418
|
+
|
|
1419
|
+
// Reserve space for cascade metrics below (~8 lines)
|
|
1420
|
+
const metricsLines = 6 + SOURCES.length;
|
|
1421
|
+
for (const src of SOURCES) {
|
|
1422
|
+
let firstRow = true;
|
|
1423
|
+
for (const pil of PILLARS) {
|
|
1424
|
+
if (used >= budget - metricsLines) break;
|
|
1425
|
+
const dbKey = pil.dbKey ?? pil.key;
|
|
1426
|
+
const cells = WINS.map((win) => {
|
|
1427
|
+
const p = src.pillars[win];
|
|
1428
|
+
const val = p?.[dbKey] ?? null;
|
|
1429
|
+
if (val == null) return padStart(dim("—"), COL_W);
|
|
1430
|
+
const baseVal = tpPillars[win]?.[dbKey];
|
|
1431
|
+
if (src.name !== "tokenpull" && baseVal != null && baseVal > 0) {
|
|
1432
|
+
const delta = ((val - baseVal) / baseVal) * 100;
|
|
1433
|
+
const dStr =
|
|
1434
|
+
delta === 0
|
|
1435
|
+
? ""
|
|
1436
|
+
: delta > 0
|
|
1437
|
+
? green(` +${delta.toFixed(0)}%`)
|
|
1438
|
+
: red(` ${delta.toFixed(0)}%`);
|
|
1439
|
+
return padStart(`${fmtTok(val)}${dStr}`, COL_W);
|
|
1440
|
+
}
|
|
1441
|
+
return padStart(fmtTok(val), COL_W);
|
|
1442
|
+
});
|
|
1443
|
+
const srcLabel = firstRow
|
|
1444
|
+
? padEnd(src.color(src.name), 12)
|
|
1445
|
+
: padEnd(dim(""), 12);
|
|
1446
|
+
emit(
|
|
1447
|
+
` ${srcLabel} ${padEnd(dim(pil.label), 10)} ${cells.join(" ")}`,
|
|
1448
|
+
);
|
|
1449
|
+
firstRow = false;
|
|
1450
|
+
}
|
|
1451
|
+
emit();
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// Fill the dead-space between the source table and cascade metrics with a
|
|
1455
|
+
// log-scale Υ Yield comparison bar — owner wants more mini-graphs, and this
|
|
1456
|
+
// gap was flagged as dead-space by the audit.
|
|
1457
|
+
if (used < budget - 6 && SOURCES.length > 0) {
|
|
1458
|
+
const yields = SOURCES.map((s) => {
|
|
1459
|
+
const p = s.pillars["all"];
|
|
1460
|
+
const cas = p ? cascadeFrom(p) : null;
|
|
1461
|
+
return { name: s.name, color: s.color, yield: cas?.yield ?? 0 };
|
|
1462
|
+
}).filter((y) => y.yield > 0);
|
|
1463
|
+
if (yields.length > 0) {
|
|
1464
|
+
const maxYield = Math.max(...yields.map((y) => y.yield));
|
|
1465
|
+
const maxLog = Math.log10(maxYield) || 1;
|
|
1466
|
+
const barW = Math.min(Math.max(w - 40, 15), 30);
|
|
1467
|
+
emit(` ${dim("Υ Yield comparison (log scale)")}`);
|
|
1468
|
+
for (const y of yields) {
|
|
1469
|
+
const { bar } = logBar(y.yield, maxLog, barW, c.cyan);
|
|
1470
|
+
emit(` ${padEnd(y.color(y.name), 12)} ${bar} ${fmtY(y.yield)}`);
|
|
1471
|
+
}
|
|
1472
|
+
emit();
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
if (used < budget - 4) {
|
|
1477
|
+
emit(` ${hr()}`);
|
|
1478
|
+
emit(
|
|
1479
|
+
` ${bold("Cascade Metrics")} ${dim("all-time · computed from each source")}`,
|
|
1480
|
+
);
|
|
1481
|
+
const MCH = [
|
|
1482
|
+
padEnd(hdr("Source"), 12),
|
|
1483
|
+
padStart(hdr("Υ Yield"), 9),
|
|
1484
|
+
padStart(hdr("SNR"), 7),
|
|
1485
|
+
padStart(hdr("Lev"), 8),
|
|
1486
|
+
padStart(hdr("Vel"), 6),
|
|
1487
|
+
padStart(hdr("10x"), 6),
|
|
1488
|
+
padEnd(hdr("Class"), 13),
|
|
1489
|
+
];
|
|
1490
|
+
emit(` ${MCH.join(" ")}`);
|
|
1491
|
+
emit(` ${dim("·".repeat(Math.min(w - 4, 72)))}`);
|
|
1492
|
+
for (const src of SOURCES) {
|
|
1493
|
+
if (used >= budget) break;
|
|
1494
|
+
const p = src.pillars["all"];
|
|
1495
|
+
if (!p) continue;
|
|
1496
|
+
const cas = cascadeFrom(p);
|
|
1497
|
+
if (!cas) continue;
|
|
1498
|
+
const clsFn = CLS[cas.class] ?? ((s) => s);
|
|
1499
|
+
const cols = [
|
|
1500
|
+
padEnd(src.color(src.name), 12),
|
|
1501
|
+
padStart(
|
|
1502
|
+
cas.yield > 10000 ? gold(fmtY(cas.yield)) : fmtY(cas.yield),
|
|
1503
|
+
9,
|
|
1504
|
+
),
|
|
1505
|
+
padStart(fmtSNR(cas.snr), 7),
|
|
1506
|
+
padStart(fmtLev(cas.leverage) + "×", 8),
|
|
1507
|
+
padStart(cas.velocity?.toFixed(2) ?? "—", 6),
|
|
1508
|
+
padStart(cas.dev10x?.toFixed(2) ?? "—", 6),
|
|
1509
|
+
padEnd(clsFn(cas.class), 13),
|
|
1510
|
+
];
|
|
1511
|
+
emit(` ${cols.join(" ")}`);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// ── TAB 3: BOARD — submissions view (#8) ──────────────────────────────────────
|
|
1517
|
+
// Renders ALL submissions ranked (raw submission rows from GET /api/v1/submissions), not operator
|
|
1518
|
+
// aggregates. Columns: rank · codename · platform · window · Υ Yield · op_ratio · class · tokens.
|
|
1519
|
+
// Field names are read defensively (the endpoint is new) so a slightly different server shape still
|
|
1520
|
+
// renders. [Y] "just me" filtering + the you-highlight carry over from the leaderboard view.
|
|
1521
|
+
function renderSubmissions(
|
|
1522
|
+
boardData,
|
|
1523
|
+
window = "30d",
|
|
1524
|
+
filterCodename = null,
|
|
1525
|
+
highlightCodename = null,
|
|
1526
|
+
) {
|
|
1527
|
+
const rows =
|
|
1528
|
+
boardData?.submissions ??
|
|
1529
|
+
boardData?.entries ??
|
|
1530
|
+
boardData?.rows ??
|
|
1531
|
+
(Array.isArray(boardData) ? boardData : []);
|
|
1532
|
+
const w = W();
|
|
1533
|
+
const budget = H() - 4;
|
|
1534
|
+
let used = 0;
|
|
1535
|
+
const emit = (s = "") => {
|
|
1536
|
+
if (used < budget) {
|
|
1537
|
+
writeln(s);
|
|
1538
|
+
used++;
|
|
1539
|
+
}
|
|
1540
|
+
};
|
|
1541
|
+
|
|
1542
|
+
// Defensive field readers (server shape is new / not yet pinned).
|
|
1543
|
+
const gName = (e) =>
|
|
1544
|
+
e.display_name || e.codename || e.operator_codename || "—";
|
|
1545
|
+
const gPlat = (e) => e.platform ?? e.platform_primary ?? "—";
|
|
1546
|
+
const gWin = (e) => e.window ?? e.window_type ?? "—";
|
|
1547
|
+
const gYield = (e) => e.yield_ ?? e.yield ?? e.y ?? null;
|
|
1548
|
+
const gOpRatio = (e) => e.op_ratio ?? e.operating_ratio ?? e.opRatio ?? null;
|
|
1549
|
+
const gClass = (e) => e.class_tier ?? e.class ?? e.class_ ?? "—";
|
|
1550
|
+
const gTokens = (e) => e.tokens ?? e.total_tokens ?? e.token_total ?? null;
|
|
1551
|
+
|
|
1552
|
+
const modeLabel = filterCodename
|
|
1553
|
+
? `${dim(" · just you")}`
|
|
1554
|
+
: highlightCodename
|
|
1555
|
+
? `${dim(" · you highlighted")}`
|
|
1556
|
+
: "";
|
|
1557
|
+
emit();
|
|
1558
|
+
// Shorten the header on narrow terminals to avoid overflow.
|
|
1559
|
+
const boardHeader = w < 90
|
|
1560
|
+
? `window: ${window} · ranked by Υ Yield`
|
|
1561
|
+
: `window: ${window} · all submissions ranked by Υ Yield · signalaf.com`;
|
|
1562
|
+
emit(
|
|
1563
|
+
` ${bold("Submissions")} ${dim(boardHeader)}${modeLabel}`,
|
|
1564
|
+
);
|
|
1565
|
+
emit();
|
|
1566
|
+
|
|
1567
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
1568
|
+
emit(` ${dim(" no submissions for this window")}`);
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
let sorted = [...rows].sort((a, b) => (gYield(b) ?? 0) - (gYield(a) ?? 0));
|
|
1573
|
+
if (filterCodename) {
|
|
1574
|
+
sorted = sorted.filter(
|
|
1575
|
+
(e) => (gName(e) ?? "").toLowerCase() === filterCodename.toLowerCase(),
|
|
1576
|
+
);
|
|
1577
|
+
if (sorted.length === 0) {
|
|
1578
|
+
emit(` ${dim(` you have no submissions in the ${window} window yet`)}`);
|
|
1579
|
+
emit(` ${dim(" press [S] from a read tab to submit your cascade")}`);
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// op_ratio may arrive as a preformatted string ("3.5 : 1 : 0.50") or a number — render either.
|
|
1585
|
+
const fmtOp = (v) =>
|
|
1586
|
+
v == null
|
|
1587
|
+
? "—"
|
|
1588
|
+
: typeof v === "string"
|
|
1589
|
+
? v
|
|
1590
|
+
: typeof v === "number"
|
|
1591
|
+
? v.toFixed(2)
|
|
1592
|
+
: String(v);
|
|
1593
|
+
|
|
1594
|
+
// Responsive column hiding — the full table is 106 cols wide. Hide columns
|
|
1595
|
+
// at narrower terminals so rows never overflow/wrap/garble.
|
|
1596
|
+
// w >= 120: all columns (106 cols fits)
|
|
1597
|
+
// w < 120: hide Tokens (saves 11) → 95 cols
|
|
1598
|
+
// w < 100: hide Tokens + Class (saves 14) → 81 cols
|
|
1599
|
+
// w < 90: hide Tokens + Class + Op Ratio, shorten Codename 20→16 → 61 cols
|
|
1600
|
+
const showTokens = w >= 120;
|
|
1601
|
+
const showClass = w >= 100;
|
|
1602
|
+
const showOpRatio = w >= 90;
|
|
1603
|
+
const nameWidth = w < 90 ? 16 : 20;
|
|
1604
|
+
|
|
1605
|
+
const SH = [
|
|
1606
|
+
padStart(hdr("#"), 4),
|
|
1607
|
+
padEnd(hdr("Codename"), nameWidth),
|
|
1608
|
+
padEnd(hdr("Platform"), 10),
|
|
1609
|
+
padEnd(hdr("Win"), 6),
|
|
1610
|
+
padStart(hdr("Υ Yield"), 9),
|
|
1611
|
+
...(showOpRatio ? [padEnd(hdr("Op Ratio"), 18)] : []),
|
|
1612
|
+
...(showClass ? [padEnd(hdr("Class"), 12)] : []),
|
|
1613
|
+
...(showTokens ? [padStart(hdr("Tokens"), 9)] : []),
|
|
1614
|
+
];
|
|
1615
|
+
emit(` ${SH.join(" ")}`);
|
|
1616
|
+
emit(` ${dim("·".repeat(Math.min(w - 4, 100)))}`);
|
|
1617
|
+
|
|
1618
|
+
let shown = 0;
|
|
1619
|
+
for (let idx = 0; idx < sorted.length; idx++) {
|
|
1620
|
+
if (used >= budget) break;
|
|
1621
|
+
const e = sorted[idx];
|
|
1622
|
+
const isYou =
|
|
1623
|
+
highlightCodename &&
|
|
1624
|
+
(gName(e) ?? "").toLowerCase() === highlightCodename.toLowerCase();
|
|
1625
|
+
const rk =
|
|
1626
|
+
idx === 0
|
|
1627
|
+
? gold(`#${idx + 1}`)
|
|
1628
|
+
: idx < 3
|
|
1629
|
+
? cyan(`#${idx + 1}`)
|
|
1630
|
+
: `#${idx + 1}`;
|
|
1631
|
+
const nmRaw = trunc(gName(e), nameWidth);
|
|
1632
|
+
const nm = isYou
|
|
1633
|
+
? `${c.bgCyan}${c.boldCyan}${padEnd(` ${trunc(gName(e), nameWidth - 2)} `, nameWidth)}${c.reset}`
|
|
1634
|
+
: padEnd(nmRaw, nameWidth);
|
|
1635
|
+
const yv = gYield(e);
|
|
1636
|
+
const yld = padStart(
|
|
1637
|
+
yv != null ? (yv > 10000 ? gold(fmtY(yv)) : fmtY(yv)) : "—",
|
|
1638
|
+
9,
|
|
1639
|
+
);
|
|
1640
|
+
const cls = padEnd(colorCls(gClass(e)), 12);
|
|
1641
|
+
const tk = gTokens(e);
|
|
1642
|
+
const youMark = isYou ? ` ${c.bgCyan}${c.boldCyan}YOU${c.reset}` : "";
|
|
1643
|
+
const cells = [
|
|
1644
|
+
padStart(rk, 4),
|
|
1645
|
+
nm,
|
|
1646
|
+
padEnd(platformColor(gPlat(e))(trunc(gPlat(e), 10)), 10),
|
|
1647
|
+
padEnd(dim(trunc(gWin(e), 6)), 6),
|
|
1648
|
+
yld,
|
|
1649
|
+
...(showOpRatio ? [padEnd(dim(fmtOp(gOpRatio(e))), 18)] : []),
|
|
1650
|
+
...(showClass ? [cls] : []),
|
|
1651
|
+
...(showTokens ? [padStart(tk != null ? fmtTok(tk) : "—", 9)] : []),
|
|
1652
|
+
];
|
|
1653
|
+
emit(` ${cells.join(" ")}${youMark}`);
|
|
1654
|
+
shown++;
|
|
1655
|
+
}
|
|
1656
|
+
// A3-style: if the height budget dropped rows, say so instead of silently truncating.
|
|
1657
|
+
// SCROLL-VIEW (2026-06-27): changed "resize / scroll" → "resize to see more"
|
|
1658
|
+
// (Board tab doesn't have a real scroll viewport — only Dashboard cascade does).
|
|
1659
|
+
const dropped = sorted.length - shown;
|
|
1660
|
+
if (dropped > 0 && used < budget)
|
|
1661
|
+
emit(` ${dim(` +${dropped} more — resize to see more`)}`);
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// ── TAB 3: BOARD ─────────────────────────────────────────────────────────────
|
|
1665
|
+
// FIX I2: hybrid board model. `filterCodename` (non-null = "just me" mode) shows
|
|
1666
|
+
// ONLY the signed-in operator's rows. `highlightCodename` (non-null = global mode)
|
|
1667
|
+
// highlights the signed-in operator's row in the global board. Both come from the
|
|
1668
|
+
// signed-in identity's codename; the [Y] key toggles between the two modes.
|
|
1669
|
+
function renderBoard(
|
|
1670
|
+
boardData,
|
|
1671
|
+
window = "30d",
|
|
1672
|
+
filterCodename = null,
|
|
1673
|
+
highlightCodename = null,
|
|
1674
|
+
) {
|
|
1675
|
+
// #8 (2026-06-27): when the data came from the NEW /api/v1/submissions endpoint, render ALL
|
|
1676
|
+
// submissions ranked (raw rows). Otherwise fall through to the existing leaderboard render
|
|
1677
|
+
// (pre-deploy fallback). loadBoardData tags the payload with `_source`.
|
|
1678
|
+
if (boardData?._source === "submissions") {
|
|
1679
|
+
renderSubmissions(boardData, window, filterCodename, highlightCodename);
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
const entries = boardData?.entries ?? boardData?.operators ?? boardData ?? [];
|
|
1683
|
+
const w = W();
|
|
1684
|
+
const budget = H() - 4;
|
|
1685
|
+
let used = 0;
|
|
1686
|
+
const emit = (s = "") => {
|
|
1687
|
+
if (used < budget) {
|
|
1688
|
+
writeln(s);
|
|
1689
|
+
used++;
|
|
1690
|
+
}
|
|
1691
|
+
};
|
|
1692
|
+
|
|
1693
|
+
const modeLabel = filterCodename
|
|
1694
|
+
? `${dim(" · just you")}`
|
|
1695
|
+
: highlightCodename
|
|
1696
|
+
? `${dim(" · you highlighted")}`
|
|
1697
|
+
: "";
|
|
1698
|
+
emit();
|
|
1699
|
+
emit(
|
|
1700
|
+
` ${bold("Leaderboard")} ${dim(`window: ${window} · sorted by Υ Yield · signalaf.com/leaderboard`)}${modeLabel}`,
|
|
1701
|
+
);
|
|
1702
|
+
emit();
|
|
1703
|
+
|
|
1704
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
1705
|
+
emit(` ${dim(" board unavailable")}`);
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
// FIX I2: "just me" mode — filter to the signed-in operator's rows only.
|
|
1710
|
+
let sorted = [...entries].sort((a, b) => (b.yield_ ?? 0) - (a.yield_ ?? 0));
|
|
1711
|
+
if (filterCodename) {
|
|
1712
|
+
sorted = sorted.filter(
|
|
1713
|
+
(e) => (e.codename ?? "").toLowerCase() === filterCodename.toLowerCase(),
|
|
1714
|
+
);
|
|
1715
|
+
if (sorted.length === 0) {
|
|
1716
|
+
emit(` ${dim(` you have no ranked rows in the ${window} window yet`)}`);
|
|
1717
|
+
emit(` ${dim(" press [S] from a read tab to submit your cascade")}`);
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// Per-column top-3 "shadowbox": medal bg-tint (gold/silver/bronze) on the 1st/2nd/3rd
|
|
1723
|
+
// best DISTINCT value in each metric column, mirroring the website's podium boxes.
|
|
1724
|
+
const MEDAL_BG = { 1: 220, 2: 250, 3: 130 }; // gold · silver · bronze (256-color)
|
|
1725
|
+
const top3 = (pick) => {
|
|
1726
|
+
const distinct = [
|
|
1727
|
+
...new Set(sorted.map(pick).filter((v) => v != null && isFinite(v))),
|
|
1728
|
+
].sort((a, b) => b - a);
|
|
1729
|
+
const m = new Map();
|
|
1730
|
+
distinct.slice(0, 3).forEach((v, i) => m.set(v, i + 1));
|
|
1731
|
+
return m;
|
|
1732
|
+
};
|
|
1733
|
+
const pods = {
|
|
1734
|
+
yield: top3((e) => e.yield_),
|
|
1735
|
+
snr: top3((e) => e.snr ?? e.compression_ratio),
|
|
1736
|
+
lev: top3((e) => e.leverage),
|
|
1737
|
+
vel: top3((e) => e.velocity),
|
|
1738
|
+
d10: top3((e) => e.dev10x),
|
|
1739
|
+
};
|
|
1740
|
+
// Wrap a padded cell in a medal bg-tint (dark fg on the medal color) when its value places top-3.
|
|
1741
|
+
// Theme-aware: monochrome uses bold instead of color backgrounds.
|
|
1742
|
+
const medal = (map, val, padded) => {
|
|
1743
|
+
const place = val == null ? null : map.get(val);
|
|
1744
|
+
if (!place) return padded;
|
|
1745
|
+
const bgCode = c.medalBg?.[place];
|
|
1746
|
+
if (bgCode != null) {
|
|
1747
|
+
return `${ESC}48;5;${bgCode}m${c.medalFg}${padded}${c.reset}`;
|
|
1748
|
+
}
|
|
1749
|
+
// Monochrome / no medal bg — use bold
|
|
1750
|
+
return `${c.bold}${padded}${c.reset}`;
|
|
1751
|
+
};
|
|
1752
|
+
|
|
1753
|
+
const BH = [
|
|
1754
|
+
padStart(hdr("#"), 4),
|
|
1755
|
+
padEnd(hdr("Codename"), 22),
|
|
1756
|
+
padEnd(hdr("Class"), 13),
|
|
1757
|
+
padStart(hdr("Υ Yield"), 9),
|
|
1758
|
+
padStart(hdr("SNR"), 7),
|
|
1759
|
+
padStart(hdr("Lev"), 7),
|
|
1760
|
+
padStart(hdr("Vel"), 6),
|
|
1761
|
+
padStart(hdr("10x"), 6),
|
|
1762
|
+
padStart(hdr("Pct"), 5),
|
|
1763
|
+
padStart(hdr("7d↕"), 5),
|
|
1764
|
+
];
|
|
1765
|
+
emit(` ${BH.join(" ")}`);
|
|
1766
|
+
emit(` ${dim("·".repeat(Math.min(w - 4, 98)))}`);
|
|
1767
|
+
|
|
1768
|
+
for (let idx = 0; idx < sorted.length; idx++) {
|
|
1769
|
+
if (used >= budget) break;
|
|
1770
|
+
const e = sorted[idx];
|
|
1771
|
+
// FIX I2: highlight the signed-in operator's row (global mode) with a cyan bg-tint.
|
|
1772
|
+
const isYou =
|
|
1773
|
+
highlightCodename &&
|
|
1774
|
+
(e.codename ?? "").toLowerCase() === highlightCodename.toLowerCase();
|
|
1775
|
+
const rk =
|
|
1776
|
+
idx === 0
|
|
1777
|
+
? gold(`#${idx + 1}`)
|
|
1778
|
+
: idx < 3
|
|
1779
|
+
? cyan(`#${idx + 1}`)
|
|
1780
|
+
: `#${idx + 1}`;
|
|
1781
|
+
const nmRaw = trunc(e.display_name || e.codename || "—", 22);
|
|
1782
|
+
// FIX I2: pad the visible content INSIDE the ANSI codes so the cyan bg fills
|
|
1783
|
+
// the full column width (padEnd after reset would leave uncolored trailing spaces).
|
|
1784
|
+
const nm = isYou
|
|
1785
|
+
? `${c.bgCyan}${c.boldCyan}${padEnd(` ${nmRaw} `, 22)}${c.reset}`
|
|
1786
|
+
: padEnd(nmRaw, 22);
|
|
1787
|
+
const cls = padEnd(colorCls(e.class_tier ?? "—"), 13);
|
|
1788
|
+
const snrVal = e.snr ?? e.compression_ratio;
|
|
1789
|
+
// value cells: pad first, then medal-wrap so the tint fills the whole column width
|
|
1790
|
+
const yld = medal(
|
|
1791
|
+
pods.yield,
|
|
1792
|
+
e.yield_,
|
|
1793
|
+
padStart(e.yield_ != null ? fmtY(e.yield_) : "—", 9),
|
|
1794
|
+
);
|
|
1795
|
+
const snr = medal(
|
|
1796
|
+
pods.snr,
|
|
1797
|
+
snrVal,
|
|
1798
|
+
padStart(snrVal != null ? fmtSNR(snrVal) : "—", 7),
|
|
1799
|
+
);
|
|
1800
|
+
const lev = medal(
|
|
1801
|
+
pods.lev,
|
|
1802
|
+
e.leverage,
|
|
1803
|
+
padStart(e.leverage != null ? fmtLev(e.leverage) + "×" : "—", 7),
|
|
1804
|
+
);
|
|
1805
|
+
const vel = medal(
|
|
1806
|
+
pods.vel,
|
|
1807
|
+
e.velocity,
|
|
1808
|
+
padStart(e.velocity != null ? e.velocity.toFixed(2) : "—", 6),
|
|
1809
|
+
);
|
|
1810
|
+
const d10 = medal(
|
|
1811
|
+
pods.d10,
|
|
1812
|
+
e.dev10x,
|
|
1813
|
+
padStart(e.dev10x != null ? e.dev10x.toFixed(2) : "—", 6),
|
|
1814
|
+
);
|
|
1815
|
+
const pct = padStart(e.percentile != null ? `${e.percentile}%` : "—", 5);
|
|
1816
|
+
const mv = padStart(fmtMov(e.movement_7d), 5);
|
|
1817
|
+
const youMark = isYou ? ` ${c.bgCyan}${c.boldCyan}YOU${c.reset}` : "";
|
|
1818
|
+
emit(
|
|
1819
|
+
` ${padStart(rk, 4)} ${nm} ${cls} ${yld} ${snr} ${lev} ${vel} ${d10} ${pct} ${mv}${youMark}`,
|
|
1820
|
+
);
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
// ── TAB 4: WATCH — landing panel (instructions + why it matters) ─────────────
|
|
1825
|
+
// Explains what the live watcher does and why it's the point of the agent: it
|
|
1826
|
+
// re-reads your local logs on an interval and feeds your verified cascade to the
|
|
1827
|
+
// leaderboard. Launched (in its own window) with [Enter]; interval is tunable.
|
|
1828
|
+
// A5 (2026-06-27): Watch defaults to ALL active platforms × ALL windows (mirror the cli.mjs watch
|
|
1829
|
+
// change). `platform`/`win` are OPTIONAL focus filters — null/'all' means "everything". [P]/[W]
|
|
1830
|
+
// cycle the focus (including back to "all"); the spawned watcher + this copy show everything by default.
|
|
1831
|
+
function renderWatchInfo(platform, win, refresh) {
|
|
1832
|
+
const platLabel =
|
|
1833
|
+
!platform || platform === "all" ? "all active platforms" : platform;
|
|
1834
|
+
const winLabel = !win || win === "all-windows" ? "all windows" : win;
|
|
1835
|
+
const allDefault =
|
|
1836
|
+
platLabel === "all active platforms" && winLabel === "all windows";
|
|
1837
|
+
const platColorFn = (!platform || platform === "all") ? cyan : platformColor(platform);
|
|
1838
|
+
writeln();
|
|
1839
|
+
writeln(
|
|
1840
|
+
` ${bold("Live Watch")} ${dim("the agent that keeps your rank current")}`,
|
|
1841
|
+
);
|
|
1842
|
+
writeln();
|
|
1843
|
+
writeln(` ${dim("What it does")}`);
|
|
1844
|
+
writeln(
|
|
1845
|
+
` Re-reads your local token logs for ${platColorFn(platLabel)} across ${cyan(winLabel)} every`,
|
|
1846
|
+
);
|
|
1847
|
+
writeln(
|
|
1848
|
+
` ${gold(refresh + "s")} and recomputes each cascade (Υ Yield · SNR · Leverage · class) live, on`,
|
|
1849
|
+
);
|
|
1850
|
+
writeln(
|
|
1851
|
+
` this machine. ${dim("By default it watches everything — every platform you actually use.")}`,
|
|
1852
|
+
);
|
|
1853
|
+
writeln();
|
|
1854
|
+
writeln(` ${dim("Why it matters")}`);
|
|
1855
|
+
writeln(
|
|
1856
|
+
` Watch is how the board stays ${bold("current")}: each refresh submits your latest`,
|
|
1857
|
+
);
|
|
1858
|
+
writeln(
|
|
1859
|
+
` verified cascade so ${cyan("signalaf.com/leaderboard")} ${bold("auto-updates")} as you work —`,
|
|
1860
|
+
);
|
|
1861
|
+
writeln(
|
|
1862
|
+
` no manual re-submit. Tokens never leave your machine; only the metrics post.`,
|
|
1863
|
+
);
|
|
1864
|
+
writeln();
|
|
1865
|
+
writeln(` ${dim("Settings")}`);
|
|
1866
|
+
writeln(
|
|
1867
|
+
` ${dim("Platform")} ${platColorFn(platLabel)} ${dim("Window")} ${cyan(winLabel)} ${dim("Refresh")} ${gold(refresh + "s")}`,
|
|
1868
|
+
);
|
|
1869
|
+
writeln(
|
|
1870
|
+
` ${dim(allDefault ? "watching everything (default)" : "focused — press [P]/[W] to widen back to all")}`,
|
|
1871
|
+
);
|
|
1872
|
+
writeln();
|
|
1873
|
+
writeln(
|
|
1874
|
+
` ${dim("[Enter]")} launch watcher (new window) ${dim("[+]/[-]")} refresh ±5s ${dim("[P]")} platform focus ${dim("[W]")} window focus`,
|
|
1875
|
+
);
|
|
1876
|
+
writeln();
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
// ── TAB 6: CONNECT ───────────────────────────────────────────────────────────
|
|
1880
|
+
// Sign in (paste a connect code) / show signed-in status. The whole app lives in
|
|
1881
|
+
// the TUI; this replaces every "go run the CLI to enroll" hint. Uses only helpers
|
|
1882
|
+
// already defined above (bold/dim/cyan/green/red/hr/writeln).
|
|
1883
|
+
function renderConnect(id, codeBuf = "", msg = "") {
|
|
1884
|
+
writeln();
|
|
1885
|
+
if (isSignedIn(id)) {
|
|
1886
|
+
writeln(` ${bold("Connect")} ${dim("your account")}`);
|
|
1887
|
+
writeln(` ${hr()}`);
|
|
1888
|
+
writeln(` ${green("✓")} Signed in as ${cyan(id.codename)}`);
|
|
1889
|
+
writeln(` ${dim(`device ${id.device_id}`)}`);
|
|
1890
|
+
writeln();
|
|
1891
|
+
writeln(
|
|
1892
|
+
` ${dim("Press")} ${bold("[S]")} ${dim("from any read tab to submit your runs to the board.")}`,
|
|
1893
|
+
);
|
|
1894
|
+
writeln();
|
|
1895
|
+
writeln(
|
|
1896
|
+
` ${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.')}`,
|
|
1897
|
+
);
|
|
1898
|
+
// Wrap the sign-out help across 2 lines on narrow terminals (the full
|
|
1899
|
+
// sentence is 108 cols — overflows 100-col terminals by 8).
|
|
1900
|
+
if (W() >= 110) {
|
|
1901
|
+
writeln(
|
|
1902
|
+
` ${dim("Signed in on the wrong device, or want a fresh start?")} ${bold("[X]")} ${dim("signs out — next paste provisions a fresh device.")}`,
|
|
1903
|
+
);
|
|
1904
|
+
} else {
|
|
1905
|
+
writeln(
|
|
1906
|
+
` ${dim("Signed in on the wrong device, or want a fresh start?")}`,
|
|
1907
|
+
);
|
|
1908
|
+
writeln(
|
|
1909
|
+
` ${bold("[X]")} ${dim("signs out — next paste provisions a fresh device.")}`,
|
|
1910
|
+
);
|
|
1911
|
+
}
|
|
1912
|
+
} else {
|
|
1913
|
+
writeln(` ${bold("Log in to submit to board")}`);
|
|
1914
|
+
writeln(` ${hr()}`);
|
|
1915
|
+
writeln(` ${dim("status:")} ${red("not signed in")}`);
|
|
1916
|
+
writeln();
|
|
1917
|
+
writeln(` ${dim("Paste your key, then")} ${bold("[Enter]")}${dim(":")}`);
|
|
1918
|
+
}
|
|
1919
|
+
writeln(` ${cyan(">")} ${codeBuf}${dim("▏")}`);
|
|
1920
|
+
writeln();
|
|
1921
|
+
writeln(
|
|
1922
|
+
` ${dim('Get a key at signalaf.com → Settings → "New key" (or "Generate connect code").')}`,
|
|
1923
|
+
);
|
|
1924
|
+
if (msg) {
|
|
1925
|
+
writeln();
|
|
1926
|
+
writeln(` ${msg}`);
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// ── TAB 4: WATCH (live grid) ──────────────────────────────────────────────────
|
|
1931
|
+
// A5 (2026-06-27): renders ALL active platforms × ALL windows by default (mirror the cli.mjs
|
|
1932
|
+
// watch grid), instead of a single platform/window. `platform`/`win` are OPTIONAL focus filters —
|
|
1933
|
+
// null/'all'/'all-windows' means "everything". Each row is a (platform, window) cascade cell:
|
|
1934
|
+
// Υ Yield + SNR/Lev/Vel/class. Iterates active platforms (input+output > 0 in any window).
|
|
1935
|
+
// Load Watch data once — scan all candidate platforms for active sessions.
|
|
1936
|
+
// Separated from rendering (renderWatchData) so the headless audit can load
|
|
1937
|
+
// once and render across many sizes/themes without re-scanning the filesystem
|
|
1938
|
+
// each frame (each full scan is ~8s across 16 platforms; 16 frames × 8s would
|
|
1939
|
+
// be ~130s of pure re-scan otherwise). Mirrors how Dashboard/Compare/Board
|
|
1940
|
+
// already separate load from render.
|
|
1941
|
+
async function loadWatchData(platform = "all", win = "all-windows") {
|
|
1942
|
+
// ../tokenpull.mjs — this file lives in presentation/, so "./tokenpull.mjs" resolved
|
|
1943
|
+
// to presentation/tokenpull.mjs (nonexistent) and threw ERR_MODULE_NOT_FOUND for
|
|
1944
|
+
// every row. Matches the static `../tokenpull.mjs` import at the top of this file.
|
|
1945
|
+
const { tokenpullAny } = await import("../tokenpull.mjs");
|
|
1946
|
+
const ALL_PLATFORMS = [
|
|
1947
|
+
"claude",
|
|
1948
|
+
"codex",
|
|
1949
|
+
"amp",
|
|
1950
|
+
"gemini",
|
|
1951
|
+
"kimi",
|
|
1952
|
+
"qwen",
|
|
1953
|
+
"goose",
|
|
1954
|
+
"kilo",
|
|
1955
|
+
"hermes",
|
|
1956
|
+
"droid",
|
|
1957
|
+
"codebuff",
|
|
1958
|
+
"copilot",
|
|
1959
|
+
"openclaw",
|
|
1960
|
+
"pi",
|
|
1961
|
+
"omp",
|
|
1962
|
+
"proxy",
|
|
1963
|
+
];
|
|
1964
|
+
const platFilter = platform && platform !== "all" ? platform : null;
|
|
1965
|
+
const winFilter = win && win !== "all-windows" ? win : null;
|
|
1966
|
+
// Detect active platforms (any window with input+output > 0), respecting an optional [P] focus.
|
|
1967
|
+
const candidates = platFilter ? [platFilter] : ALL_PLATFORMS;
|
|
1968
|
+
const settled = await Promise.allSettled(
|
|
1969
|
+
candidates.map((p) => tokenpullAny(p)),
|
|
1970
|
+
);
|
|
1971
|
+
const active = [];
|
|
1972
|
+
for (const r of settled) {
|
|
1973
|
+
if (r.status !== "fulfilled" || !r.value) continue;
|
|
1974
|
+
const d = r.value;
|
|
1975
|
+
if (
|
|
1976
|
+
(d.windows || []).some((ww) => ww.pillars.input + ww.pillars.output > 0)
|
|
1977
|
+
)
|
|
1978
|
+
active.push(d);
|
|
1979
|
+
}
|
|
1980
|
+
// stable display order
|
|
1981
|
+
active.sort(
|
|
1982
|
+
(a, b) =>
|
|
1983
|
+
ALL_PLATFORMS.indexOf(a.platform) - ALL_PLATFORMS.indexOf(b.platform),
|
|
1984
|
+
);
|
|
1985
|
+
return { active, platFilter, winFilter };
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
// Render the Watch tab from pre-loaded data. Sync — no scanning, safe to call
|
|
1989
|
+
// once per terminal size/theme in the audit.
|
|
1990
|
+
function renderWatchData(data) {
|
|
1991
|
+
const { active, platFilter, winFilter } = data;
|
|
1992
|
+
const WINS = winFilter ? [winFilter] : ["7d", "30d", "90d", "all"];
|
|
1993
|
+
const w = W();
|
|
1994
|
+
const budget = H() - 4;
|
|
1995
|
+
let used = 0;
|
|
1996
|
+
const emit = (s = "") => {
|
|
1997
|
+
if (used < budget) {
|
|
1998
|
+
writeln(s);
|
|
1999
|
+
used++;
|
|
2000
|
+
}
|
|
2001
|
+
};
|
|
2002
|
+
|
|
2003
|
+
const scopeLabel =
|
|
2004
|
+
(platFilter || "all active platforms") +
|
|
2005
|
+
" · " +
|
|
2006
|
+
(winFilter ? `${winFilter} window` : "all windows");
|
|
2007
|
+
emit();
|
|
2008
|
+
emit(` ${bold("Live Watch")} ${dim(scopeLabel)}`);
|
|
2009
|
+
emit(` ${dim("·".repeat(Math.min(w - 4, 80)))}`);
|
|
2010
|
+
|
|
2011
|
+
if (active.length === 0) {
|
|
2012
|
+
emit();
|
|
2013
|
+
for (const ln of wrapDim(
|
|
2014
|
+
"no active platforms detected — run some sessions, this picks them up automatically",
|
|
2015
|
+
))
|
|
2016
|
+
emit(ln);
|
|
2017
|
+
return;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
for (const d of active) {
|
|
2021
|
+
if (used >= budget - 1) break;
|
|
2022
|
+
const byWin = {};
|
|
2023
|
+
for (const ww of d.windows || []) byWin[ww.window] = ww;
|
|
2024
|
+
emit();
|
|
2025
|
+
emit(` ${cyan(d.platform)}${d.estimated ? dim(" (est)") : ""}`);
|
|
2026
|
+
for (const wk of WINS) {
|
|
2027
|
+
if (used >= budget - 1) break;
|
|
2028
|
+
const ww = byWin[wk];
|
|
2029
|
+
const cas = ww ? cascadeFrom(ww.pillars) : null;
|
|
2030
|
+
const winLabel = wk === "all" ? "all-time" : wk;
|
|
2031
|
+
if (!cas) {
|
|
2032
|
+
emit(` ${padEnd(dim(winLabel), 9)} ${dim("no data")}`);
|
|
2033
|
+
continue;
|
|
2034
|
+
}
|
|
2035
|
+
const clsFn = CLS[cas.class] ?? ((s) => s);
|
|
2036
|
+
const yDisplay =
|
|
2037
|
+
cas.yield > 1000 ? gold(fmtY(cas.yield)) : cyan(fmtY(cas.yield));
|
|
2038
|
+
const metrics = `${dim("SNR")} ${fmtSNR(cas.snr)} ${dim("Lev")} ${cas.leverage != null ? fmtLev(cas.leverage) + "×" : "—"} ${dim("Vel")} ${cas.velocity != null ? cas.velocity.toFixed(2) : "—"} ${clsFn(cas.class)}`;
|
|
2039
|
+
emit(
|
|
2040
|
+
` ${padEnd(dim(winLabel), 9)} ${bold("Υ")} ${yDisplay} ${metrics}`,
|
|
2041
|
+
);
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
// Convenience: scan + render in one call (used by --render and --record, which
|
|
2047
|
+
// only render once). The audit uses loadWatchData + renderWatchData separately
|
|
2048
|
+
// to avoid re-scanning across its 16 frames.
|
|
2049
|
+
async function renderWatch(platform = "all", win = "all-windows") {
|
|
2050
|
+
renderWatchData(await loadWatchData(platform, win));
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
// ── SUBMIT PREVIEW (FIX I1 / FIX E) — see→confirm→send ───────────────────────
|
|
2054
|
+
// Renders a platform × window grid of what submit_verified WOULD send, using the
|
|
2055
|
+
// already-loaded dashData. [Enter] confirms → fires submit_verified per platform;
|
|
2056
|
+
// [Esc] cancels → back to the read tab. Mirrors the Connect focused-field pattern.
|
|
2057
|
+
function renderSubmitPreview(dashData, id) {
|
|
2058
|
+
const budget = H() - 4;
|
|
2059
|
+
let used = 0;
|
|
2060
|
+
const emit = (s = "") => {
|
|
2061
|
+
if (used < budget) {
|
|
2062
|
+
writeln(s);
|
|
2063
|
+
used++;
|
|
2064
|
+
}
|
|
2065
|
+
};
|
|
2066
|
+
const WINS = ["7d", "30d", "90d", "all"];
|
|
2067
|
+
const COL_W = 14;
|
|
2068
|
+
|
|
2069
|
+
emit();
|
|
2070
|
+
emit(` ${bold("Submit Preview")} ${dim("what will publish to the board")}`);
|
|
2071
|
+
emit(
|
|
2072
|
+
` ${dim(`signed in as ${id?.codename ?? "—"} · device ${id?.device_id?.slice(0, 8) ?? "—"}…`)}`,
|
|
2073
|
+
);
|
|
2074
|
+
emit();
|
|
2075
|
+
// header row: platform | 7d | 30d | 90d | all
|
|
2076
|
+
const hcols = [
|
|
2077
|
+
padEnd(hdr("Platform"), 12),
|
|
2078
|
+
...WINS.map((w) => padStart(hdr(w), COL_W)),
|
|
2079
|
+
];
|
|
2080
|
+
emit(` ${hcols.join(" ")}`);
|
|
2081
|
+
emit(
|
|
2082
|
+
` ${dim("·".repeat(Math.min(W() - 4, 12 + WINS.length * (COL_W + 2))))}`,
|
|
2083
|
+
);
|
|
2084
|
+
|
|
2085
|
+
const active = dashData?.active ?? [];
|
|
2086
|
+
for (const d of active) {
|
|
2087
|
+
if (used >= budget - 3) break;
|
|
2088
|
+
const cells = WINS.map((w) => {
|
|
2089
|
+
const wd = d.windows?.find((x) => x.window === w);
|
|
2090
|
+
if (!wd || wd.pillars.input + wd.pillars.output === 0)
|
|
2091
|
+
return padStart(dim("—"), COL_W);
|
|
2092
|
+
const cas = cascadeFrom(wd.pillars);
|
|
2093
|
+
if (!cas) return padStart(dim("—"), COL_W);
|
|
2094
|
+
return padStart(gold(fmtY(cas.yield)), COL_W);
|
|
2095
|
+
});
|
|
2096
|
+
emit(` ${padEnd(cyan(d.platform), 12)} ${cells.join(" ")}`);
|
|
2097
|
+
}
|
|
2098
|
+
if (active.length === 0) {
|
|
2099
|
+
emit(` ${dim(" no platforms with data — press [R] to refresh")}`);
|
|
2100
|
+
}
|
|
2101
|
+
emit();
|
|
2102
|
+
emit(
|
|
2103
|
+
` ${dim("[Enter]")} confirm + submit all platforms ${dim("[Esc]")} cancel`,
|
|
2104
|
+
);
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
// ── DEBUG: render a tab once (no TTY/alt-screen) + audit each line's visible
|
|
2108
|
+
// width vs terminal columns. Usage: `node tui.mjs --render [0-5]`. Tab indices
|
|
2109
|
+
// match the live TABS array: 0=Dashboard, 1=Trends, 2=Compare, 3=Board,
|
|
2110
|
+
// 4=Watch, 5=Connect. Prints a width report (>w = would overflow/wrap) then
|
|
2111
|
+
// the raw frame. For diagnosing layout overflow without an interactive session.
|
|
2112
|
+
async function renderOnce(tabIdx = 0) {
|
|
2113
|
+
const w = W();
|
|
2114
|
+
const data = {
|
|
2115
|
+
0: await loadDashboardData().catch((e) => ({ error: e.message })),
|
|
2116
|
+
1: await loadDashboardData().catch((e) => ({ error: e.message })),
|
|
2117
|
+
2: await loadCompareData("claude").catch(() => null),
|
|
2118
|
+
3: await loadBoardData("30d").catch(() => null),
|
|
2119
|
+
}[tabIdx];
|
|
2120
|
+
startBuffer();
|
|
2121
|
+
if (tabIdx === 0) renderDashboard(data, "debug");
|
|
2122
|
+
else if (tabIdx === 1) renderTrends(data, 0);
|
|
2123
|
+
else if (tabIdx === 2) renderCompare(data);
|
|
2124
|
+
else if (tabIdx === 3) renderBoard(data, "30d");
|
|
2125
|
+
else if (tabIdx === 4) await renderWatch("all", "all-windows");
|
|
2126
|
+
else if (tabIdx === 5) renderConnect(loadIdentity(), "", "");
|
|
2127
|
+
const lines = _screenBuf || [];
|
|
2128
|
+
_screenBuf = null;
|
|
2129
|
+
_footerBuf = null;
|
|
2130
|
+
process.stdout.write(
|
|
2131
|
+
`\n=== WIDTH AUDIT (terminal w=${w}) — lines exceeding w wrap/overflow ===\n`,
|
|
2132
|
+
);
|
|
2133
|
+
lines.forEach((ln, i) => {
|
|
2134
|
+
const vis = stripAnsi(ln).length;
|
|
2135
|
+
if (vis > w)
|
|
2136
|
+
process.stdout.write(
|
|
2137
|
+
` OVERFLOW line ${i}: visible=${vis} (>${w} by ${vis - w}) «${stripAnsi(ln).slice(0, 60)}…»\n`,
|
|
2138
|
+
);
|
|
2139
|
+
});
|
|
2140
|
+
const maxVis = Math.max(0, ...lines.map((l) => stripAnsi(l).length));
|
|
2141
|
+
process.stdout.write(
|
|
2142
|
+
` widest visible line = ${maxVis} (terminal w=${w}) → ${maxVis > w ? "OVERFLOWS" : "fits"}\n`,
|
|
2143
|
+
);
|
|
2144
|
+
process.stdout.write(`\n=== RAW FRAME (stripAnsi) ===\n`);
|
|
2145
|
+
lines.forEach((ln) => process.stdout.write(stripAnsi(ln) + "\n"));
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
// ── OPENING SPLASH ───────────────────────────────────────────────────────────
|
|
2149
|
+
// Block-letter SIGRANK wordmark that flashes through colors CONTINUOUSLY until a
|
|
2150
|
+
// key is pressed (owner 2026-06-25: "keep the animation going"). Randomizes the
|
|
2151
|
+
// mode each launch (moses gold-shimmer / rainbow / chaos per-letter). Centered to
|
|
2152
|
+
// terminal width. Any key dismisses → the dashboard loads.
|
|
2153
|
+
const SPLASH_ART = [
|
|
2154
|
+
"███████ ██ ██████ ██████ █████ ███ ██ ██ ██",
|
|
2155
|
+
"██ ██ ██ ██ ██ ██ ██ ████ ██ ██ ██ ",
|
|
2156
|
+
"███████ ██ ██ ███ ██████ ███████ ██ ██ ██ █████ ",
|
|
2157
|
+
" ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ",
|
|
2158
|
+
"███████ ██ ██████ ██ ██ ██ ██ ██ ████ ██ ██",
|
|
2159
|
+
];
|
|
2160
|
+
const SPLASH_RULE = "◈ ─────────────────────────────────────────── ◈";
|
|
2161
|
+
const MOSES_PAL = [220, 178, 214, 208, 222, 230];
|
|
2162
|
+
const RAINBOW_PAL = [196, 202, 226, 46, 51, 21, 201, 213];
|
|
2163
|
+
const col256 = (n, s) => `${ESC}38;5;${n}m${s}${c.reset}`;
|
|
2164
|
+
const stripA = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
2165
|
+
const ctr = (s) => {
|
|
2166
|
+
const pad = Math.max(0, Math.floor((W() - stripA(s).length) / 2));
|
|
2167
|
+
return " ".repeat(pad) + s;
|
|
2168
|
+
};
|
|
2169
|
+
|
|
2170
|
+
function splashFrame(mode) {
|
|
2171
|
+
const pick = (a) => a[Math.floor(Math.random() * a.length)];
|
|
2172
|
+
const out = ["", "", ctr(dim(SPLASH_RULE)), ""];
|
|
2173
|
+
for (const line of SPLASH_ART) {
|
|
2174
|
+
if (mode === "chaos") {
|
|
2175
|
+
out.push(
|
|
2176
|
+
ctr(
|
|
2177
|
+
[...line]
|
|
2178
|
+
.map((ch) =>
|
|
2179
|
+
ch === " " ? " " : col256(Math.floor(Math.random() * 256), ch),
|
|
2180
|
+
)
|
|
2181
|
+
.join(""),
|
|
2182
|
+
),
|
|
2183
|
+
);
|
|
2184
|
+
} else {
|
|
2185
|
+
const n = mode === "rainbow" ? pick(RAINBOW_PAL) : pick(MOSES_PAL);
|
|
2186
|
+
out.push(ctr(col256(n, line)));
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
out.push("", ctr(dim(SPLASH_RULE)), "");
|
|
2190
|
+
out.push(ctr(bold("For all builders, burners and 10xers")));
|
|
2191
|
+
out.push(ctr(dim("signalaf")), "");
|
|
2192
|
+
out.push(ctr(`${dim("powered by")} ${gold("MO§ES™")}`));
|
|
2193
|
+
out.push(ctr(dim("$ npx sigrank")), "");
|
|
2194
|
+
out.push(ctr(`${gold("▸")} ${dim("press any key")} ${gold("◂")}`));
|
|
2195
|
+
return out.join("\n");
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
// Animated splash. Controls: [P] pause/resume the color animation · [Enter] (or
|
|
2199
|
+
// any other key) enter the TUI · Ctrl-C quit. Mode (moses/rainbow/chaos) varies
|
|
2200
|
+
// per launch. Frozen frame keeps the last colors so a pause reads as intentional.
|
|
2201
|
+
function showSplash() {
|
|
2202
|
+
const modes = ["moses", "rainbow", "chaos"];
|
|
2203
|
+
const mode = modes[Math.floor((Date.now() / 1000) % modes.length)];
|
|
2204
|
+
return new Promise((resolve) => {
|
|
2205
|
+
let paused = false;
|
|
2206
|
+
const interactive = !!process.stdin.isTTY; // can we read raw keypresses to dismiss?
|
|
2207
|
+
const hint = () =>
|
|
2208
|
+
interactive
|
|
2209
|
+
? `${dim(" [P]")} ${paused ? "resume" : "pause"} ${dim("[Enter]")} enter`
|
|
2210
|
+
: dim(" loading…");
|
|
2211
|
+
// FIXED-WINDOW FIX (2026-06-27): paint the splash via the buffered flushScreen
|
|
2212
|
+
// path (GOTO positioning), NOT raw write('\n'). Raw \n scrolls the alt-screen
|
|
2213
|
+
// buffer, creating scrollback above the TUI — that's why you could scroll up
|
|
2214
|
+
// to see what was above it. flushScreen uses absolute cursor positioning so
|
|
2215
|
+
// nothing ever scrolls the buffer. This matches how tokscale renders its frame.
|
|
2216
|
+
const paint = () => {
|
|
2217
|
+
startBuffer();
|
|
2218
|
+
const lines = splashFrame(mode).split("\n");
|
|
2219
|
+
lines.forEach((ln) => writeln(ln));
|
|
2220
|
+
writeln(ctr(hint()));
|
|
2221
|
+
setFooter([]);
|
|
2222
|
+
flushScreen();
|
|
2223
|
+
};
|
|
2224
|
+
paint();
|
|
2225
|
+
let timer = setInterval(paint, 110);
|
|
2226
|
+
|
|
2227
|
+
// Non-interactive stdin (some IDE/integrated terminals): still SHOW the animated
|
|
2228
|
+
// wordmark, but auto-advance after a short run instead of waiting for a key (which
|
|
2229
|
+
// would never arrive → hang). ~2.2s of animation, then enter the TUI.
|
|
2230
|
+
if (!interactive) {
|
|
2231
|
+
setTimeout(() => {
|
|
2232
|
+
clearInterval(timer);
|
|
2233
|
+
write(CLEAR);
|
|
2234
|
+
resolve();
|
|
2235
|
+
}, 2200);
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
process.stdin.setRawMode?.(true);
|
|
2240
|
+
process.stdin.resume();
|
|
2241
|
+
process.stdin.setEncoding("utf8");
|
|
2242
|
+
const onKey = (key) => {
|
|
2243
|
+
const k = key.toLowerCase();
|
|
2244
|
+
if (k === "p") {
|
|
2245
|
+
// toggle pause — don't enter
|
|
2246
|
+
paused = !paused;
|
|
2247
|
+
if (paused) {
|
|
2248
|
+
clearInterval(timer);
|
|
2249
|
+
timer = null;
|
|
2250
|
+
paint();
|
|
2251
|
+
} // freeze on current frame
|
|
2252
|
+
else {
|
|
2253
|
+
timer = setInterval(paint, 110);
|
|
2254
|
+
} // resume animating
|
|
2255
|
+
return;
|
|
2256
|
+
}
|
|
2257
|
+
if (timer) clearInterval(timer);
|
|
2258
|
+
process.stdin.removeListener("data", onKey);
|
|
2259
|
+
if (key === "\x03") {
|
|
2260
|
+
write(SHOW);
|
|
2261
|
+
write(EXIT_ALT);
|
|
2262
|
+
process.exit(0);
|
|
2263
|
+
} // Ctrl-C
|
|
2264
|
+
resolve(); // Enter / any other key → enter the TUI
|
|
2265
|
+
};
|
|
2266
|
+
process.stdin.on("data", onKey);
|
|
2267
|
+
});
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
// ── MAIN TUI LOOP ─────────────────────────────────────────────────────────────
|
|
2271
|
+
export async function runTui({
|
|
2272
|
+
platform: initPlatform = "claude",
|
|
2273
|
+
window: win = "7d",
|
|
2274
|
+
} = {}) {
|
|
2275
|
+
// Theme selection — --theme <name> sets the color palette (dark/light/high-contrast/monochrome)
|
|
2276
|
+
const themeIdx = process.argv.indexOf("--theme");
|
|
2277
|
+
if (themeIdx !== -1 && process.argv[themeIdx + 1]) {
|
|
2278
|
+
setTheme(process.argv[themeIdx + 1]);
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
// FIX J: platform is mutable — [P] cycles it on Compare + Watch tabs. Was a const
|
|
2282
|
+
// param (permanently locked to 'claude'); now a let so the user can switch + [S]
|
|
2283
|
+
// submits the selected platform (needed for Phase 4 multi-platform submit).
|
|
2284
|
+
let platform = initPlatform;
|
|
2285
|
+
// Debug render mode — non-interactive, dumps a tab + width audit, then exits.
|
|
2286
|
+
const ri = process.argv.indexOf("--render");
|
|
2287
|
+
if (ri !== -1) {
|
|
2288
|
+
const tab = parseInt(process.argv[ri + 1] ?? "0", 10) || 0;
|
|
2289
|
+
await renderOnce(tab);
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
// Audit mode — headless review of every tab × every terminal size. Checks
|
|
2294
|
+
// overflow, truncation, dead space, budget, color health, sparkline integrity,
|
|
2295
|
+
// responsive design, visual weight. Supports golden frame snapshots
|
|
2296
|
+
// (--golden-save / --golden-check), SVG export (--svg), and CI mode (--ci
|
|
2297
|
+
// exits non-zero on HIGH severity issues).
|
|
2298
|
+
const ai = process.argv.indexOf("--audit");
|
|
2299
|
+
if (ai !== -1) {
|
|
2300
|
+
let runAudit, formatAuditReport;
|
|
2301
|
+
try {
|
|
2302
|
+
({ runAudit, formatAuditReport } = await import("../dev/tui-audit.mjs"));
|
|
2303
|
+
} catch {
|
|
2304
|
+
console.error("--audit is a development tool, not available in the npm package.");
|
|
2305
|
+
console.error("Clone the repo to use it: https://github.com/SunrisesIllNeverSee/sigrank-mcp");
|
|
2306
|
+
process.exit(1);
|
|
2307
|
+
}
|
|
2308
|
+
const auditData = await runAudit({
|
|
2309
|
+
goldenSave: process.argv.includes("--golden-save"),
|
|
2310
|
+
goldenCheck: process.argv.includes("--golden-check"),
|
|
2311
|
+
svg: process.argv.includes("--svg"),
|
|
2312
|
+
png: process.argv.includes("--png"),
|
|
2313
|
+
ci: process.argv.includes("--ci"),
|
|
2314
|
+
});
|
|
2315
|
+
process.stdout.write(formatAuditReport(auditData) + "\n");
|
|
2316
|
+
// Always exit in audit mode — a data load that timed out may still have a
|
|
2317
|
+
// pending background promise (network fetch / local scan) that would keep
|
|
2318
|
+
// the event loop alive after runTui returns. The audit is headless and must
|
|
2319
|
+
// terminate; unconditional exit guarantees that regardless of ciExitCode.
|
|
2320
|
+
process.exit(auditData.ciExitCode);
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
// Record mode — capture a TUI session as an animated GIF (headless, no TTY needed)
|
|
2324
|
+
const recIdx = process.argv.indexOf("--record");
|
|
2325
|
+
if (recIdx !== -1) {
|
|
2326
|
+
let recordCli;
|
|
2327
|
+
try {
|
|
2328
|
+
({ recordCli } = await import("../dev/tui-record.mjs"));
|
|
2329
|
+
} catch {
|
|
2330
|
+
console.error("--record is a development tool, not available in the npm package.");
|
|
2331
|
+
console.error("Clone the repo to use it: https://github.com/SunrisesIllNeverSee/sigrank-mcp");
|
|
2332
|
+
process.exit(1);
|
|
2333
|
+
}
|
|
2334
|
+
await recordCli(process.argv);
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
write(ENTER_ALT); // switch to alternate screen — original terminal state preserved on exit
|
|
2339
|
+
write(CLEAR_SB); // FIXED-WINDOW (2026-06-27): clear scrollback so nothing is above the TUI
|
|
2340
|
+
write(HIDE);
|
|
2341
|
+
write(CLEAR);
|
|
2342
|
+
|
|
2343
|
+
// Opening splash — animated SIGRANK wordmark.
|
|
2344
|
+
// Gate on stdOUT.isTTY (can we draw the animation?), matching the launch guard —
|
|
2345
|
+
// NOT stdin.isTTY, which was skipping the splash in terminals whose input isn't a
|
|
2346
|
+
// raw TTY (some IDE/integrated terminals). showSplash() itself auto-advances when
|
|
2347
|
+
// stdin can't be read raw, so it never hangs. (Skippable with --no-splash.)
|
|
2348
|
+
if (!process.argv.includes("--no-splash") && process.stdout.isTTY) {
|
|
2349
|
+
await showSplash();
|
|
2350
|
+
write(CLEAR);
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
// ── State
|
|
2354
|
+
let activeTab = 0;
|
|
2355
|
+
let dashData = null;
|
|
2356
|
+
let compareData = null;
|
|
2357
|
+
let boardData = null;
|
|
2358
|
+
let boardWindow = "30d";
|
|
2359
|
+
// A5 (2026-06-27): Watch defaults to "all" focus — all active platforms × all windows. [P]/[W]
|
|
2360
|
+
// optionally narrow the focus and cycle back to 'all'. ('all-windows' is the window sentinel so
|
|
2361
|
+
// it never collides with the real 'all' window bucket in the cli watcher's optional --window.)
|
|
2362
|
+
let watchPlatform = "all";
|
|
2363
|
+
let watchWindow = "all-windows";
|
|
2364
|
+
let watchRefresh = 30; // [5] Watch poll interval (seconds) — [+]/[-] adjust
|
|
2365
|
+
let trendSub = 0; // [2] Trends sub-view index (You/Platform/Field) — [T] cycles
|
|
2366
|
+
let loading = true;
|
|
2367
|
+
let status = "loading…";
|
|
2368
|
+
let refreshTimer = null;
|
|
2369
|
+
let codeBuf = ""; // [6] Connect tab in-place code field buffer
|
|
2370
|
+
let connectMsg = ""; // [6] Connect last-action message (signed in / invalid code)
|
|
2371
|
+
let submitMsg = ""; // [S] in-place submit result, shown in the read-tab footer
|
|
2372
|
+
let submitPreview = false; // FIX I1: [S] opens a preview grid before sending (see→confirm→send)
|
|
2373
|
+
let boardYouOnly = false; // FIX I2: [Y] toggles the Board to "just me" (hybrid model)
|
|
2374
|
+
let cascadeScroll = 0; // SCROLL-VIEW (2026-06-27): cascade-section scroll offset (Dashboard only)
|
|
2375
|
+
let ompEnabled = false; // Session-only Dashboard toggle; deliberately OFF on every launch.
|
|
2376
|
+
|
|
2377
|
+
// ── Redraw (buffered: renders into memory, then paints as a locked frame)
|
|
2378
|
+
const redraw = async () => {
|
|
2379
|
+
startBuffer();
|
|
2380
|
+
renderTabBar(activeTab);
|
|
2381
|
+
|
|
2382
|
+
const hint = ` ${dim("← → or 1-6")} switch tabs ${dim("[R]")} refresh ${dim("[Q]")} quit`;
|
|
2383
|
+
const submitHint = ` ${dim("[S]")} submit to board · ${dim("[C]")} sign in`;
|
|
2384
|
+
// Read-tab footer: hr + hint, plus the in-place submit result line when present.
|
|
2385
|
+
const readFooter = (hintLine) =>
|
|
2386
|
+
submitMsg
|
|
2387
|
+
? [` ${hr()}`, hintLine, ` ${submitMsg}`]
|
|
2388
|
+
: [` ${hr()}`, hintLine];
|
|
2389
|
+
|
|
2390
|
+
// FIX I1: submit preview is a focused overlay — renders INSTEAD of the tab
|
|
2391
|
+
// content, with its own footer. [Enter] sends, [Esc] cancels.
|
|
2392
|
+
if (submitPreview) {
|
|
2393
|
+
renderSubmitPreview(dashData, loadIdentity());
|
|
2394
|
+
setFooter([
|
|
2395
|
+
` ${hr()}`,
|
|
2396
|
+
` ${dim("[Enter]")} submit all platforms ${dim("[Esc]")} cancel ${dim("[Q]")} quit`,
|
|
2397
|
+
]);
|
|
2398
|
+
flushScreen();
|
|
2399
|
+
return;
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
if (activeTab === 0) {
|
|
2403
|
+
// Dashboard
|
|
2404
|
+
if (!dashData) {
|
|
2405
|
+
writeln(`\n ${dim("loading dashboard…")}`);
|
|
2406
|
+
} else {
|
|
2407
|
+
renderDashboard(dashData, status, cascadeScroll);
|
|
2408
|
+
}
|
|
2409
|
+
// SCROLL-VIEW: show ↑↓ hint only when there are cascade rows to scroll
|
|
2410
|
+
const scrollHint =
|
|
2411
|
+
dashData && Array.isArray(dashData.active)
|
|
2412
|
+
? cascadeScrollableCount(dashData.active) >
|
|
2413
|
+
maxCascadeRowsFor(dashData.active)
|
|
2414
|
+
? ` ${dim("↑↓ scroll cascade")}`
|
|
2415
|
+
: ""
|
|
2416
|
+
: "";
|
|
2417
|
+
const ompHint = !dashData?._ompAvailable
|
|
2418
|
+
? ""
|
|
2419
|
+
: dashData._ompLoading
|
|
2420
|
+
? ` ${dim(ompEnabled ? "omp: on · scanning…" : "omp: off · finishing scan…")}`
|
|
2421
|
+
: ` ${dim("[O]")} omp: ${ompEnabled ? "on" : "off"}`;
|
|
2422
|
+
setFooter(readFooter(`${hint}${scrollHint}${ompHint}${submitHint}`));
|
|
2423
|
+
} else if (activeTab === 1) {
|
|
2424
|
+
// Trends
|
|
2425
|
+
if (!dashData) {
|
|
2426
|
+
writeln(`\n ${dim("loading trends…")}`);
|
|
2427
|
+
} else {
|
|
2428
|
+
renderTrends(dashData, trendSub);
|
|
2429
|
+
}
|
|
2430
|
+
setFooter(readFooter(`${hint} ${dim("[T]")} view${submitHint}`));
|
|
2431
|
+
} else if (activeTab === 2) {
|
|
2432
|
+
// Compare
|
|
2433
|
+
if (!compareData) {
|
|
2434
|
+
// #9 (2026-06-27): Compare runs a FRESH on-demand pull of the external verifier sources
|
|
2435
|
+
// (ccusage + tokscale + token-dashboard rescan) — it can take several seconds. Show a clear
|
|
2436
|
+
// spinner line while it runs, instead of a bare "loading…".
|
|
2437
|
+
writeln(
|
|
2438
|
+
`\n ${bold("Source Comparison")} ${dim(`platform: ${platform}`)}`,
|
|
2439
|
+
);
|
|
2440
|
+
writeln(
|
|
2441
|
+
`\n ${gold("◇")} ${dim(`pulling fresh sources… (ccusage · tokscale · token-dash — this can take several seconds)`)}`,
|
|
2442
|
+
);
|
|
2443
|
+
} else {
|
|
2444
|
+
renderCompare(compareData);
|
|
2445
|
+
}
|
|
2446
|
+
setFooter(
|
|
2447
|
+
readFooter(`${hint} ${dim("[P]")} switch platform${submitHint}`),
|
|
2448
|
+
);
|
|
2449
|
+
} else if (activeTab === 3) {
|
|
2450
|
+
// Board
|
|
2451
|
+
if (!boardData) {
|
|
2452
|
+
writeln(`\n ${dim(`loading board (${boardWindow})…`)}`);
|
|
2453
|
+
} else {
|
|
2454
|
+
// FIX I2: hybrid board model — global board + your row highlighted, [Y]
|
|
2455
|
+
// toggles "just me" (your placements only). Pass the signed-in codename so
|
|
2456
|
+
// renderBoard can highlight/filter.
|
|
2457
|
+
const id = loadIdentity();
|
|
2458
|
+
renderBoard(
|
|
2459
|
+
boardData,
|
|
2460
|
+
boardWindow,
|
|
2461
|
+
boardYouOnly ? id?.codename : null,
|
|
2462
|
+
id?.codename ?? null,
|
|
2463
|
+
);
|
|
2464
|
+
}
|
|
2465
|
+
const youHint = isSignedIn(loadIdentity())
|
|
2466
|
+
? ` ${dim("[Y]")} ${boardYouOnly ? "all" : "just me"}`
|
|
2467
|
+
: "";
|
|
2468
|
+
setFooter(
|
|
2469
|
+
readFooter(`${hint} ${dim("[W]")} window${youHint}${submitHint}`),
|
|
2470
|
+
);
|
|
2471
|
+
} else if (activeTab === 4) {
|
|
2472
|
+
// Watch
|
|
2473
|
+
renderWatchInfo(watchPlatform, watchWindow, watchRefresh);
|
|
2474
|
+
setFooter(readFooter(`${hint}${submitHint}`));
|
|
2475
|
+
} else if (activeTab === 5) {
|
|
2476
|
+
// Connect
|
|
2477
|
+
const connectId = loadIdentity();
|
|
2478
|
+
renderConnect(connectId, codeBuf, connectMsg);
|
|
2479
|
+
const signOutHint = isSignedIn(connectId)
|
|
2480
|
+
? ` ${dim("[X]")} sign out`
|
|
2481
|
+
: " " + dim("[X]") + " reset device";
|
|
2482
|
+
setFooter([
|
|
2483
|
+
` ${hr()}`,
|
|
2484
|
+
` ${dim("[Enter]")} sign in ${dim("[Esc]")} clear/back${signOutHint} ${dim("← →")} tabs ${dim("[Q]")} quit`,
|
|
2485
|
+
]);
|
|
2486
|
+
}
|
|
2487
|
+
flushScreen();
|
|
2488
|
+
};
|
|
2489
|
+
|
|
2490
|
+
// ── Initial data load
|
|
2491
|
+
const loadAll = async () => {
|
|
2492
|
+
// A2 (2026-06-27): paint the frame + a clear "reading your cascade…" status IMMEDIATELY,
|
|
2493
|
+
// BEFORE the await — so the very first thing on screen is the table header + a live status,
|
|
2494
|
+
// not a blank "loading dashboard…". renderDashboard tolerates this empty dashData (guarded).
|
|
2495
|
+
// Animated signal pulse during initial load — shows the system is alive.
|
|
2496
|
+
const INIT_PULSE = [
|
|
2497
|
+
"▁▁▁▁▁", "▂▁▁▁▁", "▃▂▁▁▁", "▄▃▂▁▁", "▅▄▃▂▁",
|
|
2498
|
+
"▆▅▄▃▂", "▇▆▅▄▃", "█▇▆▅▄", "▇█▇▆▅", "▆▇█▇▆",
|
|
2499
|
+
"▅▆▇█▇", "▄▅▆▇█", "▃▄▅▆▇", "▂▃▄▅▆", "▁▂▃▄▅",
|
|
2500
|
+
"▁▁▂▃▄", "▁▁▁▂▃", "▁▁▁▁▂",
|
|
2501
|
+
];
|
|
2502
|
+
const initStart = Date.now();
|
|
2503
|
+
let initIdx = 0;
|
|
2504
|
+
const initTimer = setInterval(() => {
|
|
2505
|
+
const elapsed = ((Date.now() - initStart) / 1000).toFixed(1);
|
|
2506
|
+
const pulse = INIT_PULSE[initIdx % INIT_PULSE.length];
|
|
2507
|
+
initIdx++;
|
|
2508
|
+
status = `${gold(pulse)} reading your cascade… ${bold(`${elapsed}s`)}`;
|
|
2509
|
+
redraw();
|
|
2510
|
+
}, 150);
|
|
2511
|
+
status = `${gold("▁▁▁▁▁")} reading your cascade…`;
|
|
2512
|
+
dashData = { active: [], _loading: true };
|
|
2513
|
+
await redraw();
|
|
2514
|
+
// FIX 0: progressive Dashboard load — claude first (fast), render it, THEN fill
|
|
2515
|
+
// the remaining ALL_PLATFORMS entries in the background + redraw. The user sees their cascade
|
|
2516
|
+
// within ~1 read instead of a 7s blank "loading dashboard…".
|
|
2517
|
+
dashData = await loadDashboardData({ includeOmp: ompEnabled }).catch((e) => {
|
|
2518
|
+
status = `dashboard error: ${e.message}`;
|
|
2519
|
+
return null;
|
|
2520
|
+
});
|
|
2521
|
+
clearInterval(initTimer);
|
|
2522
|
+
status = `last refreshed ${new Date().toLocaleTimeString("en-US", { hour12: false })}`;
|
|
2523
|
+
await redraw();
|
|
2524
|
+
// Keep a "filling other platforms…" status visible while fillDashboardRest runs.
|
|
2525
|
+
// #9 (2026-06-27): Compare is NO LONGER eagerly loaded here — loadCompareData now runs the
|
|
2526
|
+
// FRESH verifier pull (ccusage + tokscale + token-dash rescan, 5–60s), which would re-introduce
|
|
2527
|
+
// a slow Dashboard load path. Compare loads lazily ON-DEMAND when the Compare tab is opened.
|
|
2528
|
+
// Board still preloads (fast, cached) in parallel with the platform fill.
|
|
2529
|
+
if (dashData?._remaining) {
|
|
2530
|
+
status = "claude ready · filling other platforms…";
|
|
2531
|
+
await redraw();
|
|
2532
|
+
}
|
|
2533
|
+
// Animated signal pulse while the platform fill runs — shows the system
|
|
2534
|
+
// is alive, not frozen. Uses a rotating signal-bar animation + elapsed timer.
|
|
2535
|
+
const PULSE_FRAMES = [
|
|
2536
|
+
"▁▁▁▁▁",
|
|
2537
|
+
"▂▁▁▁▁",
|
|
2538
|
+
"▃▂▁▁▁",
|
|
2539
|
+
"▄▃▂▁▁",
|
|
2540
|
+
"▅▄▃▂▁",
|
|
2541
|
+
"▆▅▄▃▂",
|
|
2542
|
+
"▇▆▅▄▃",
|
|
2543
|
+
"█▇▆▅▄",
|
|
2544
|
+
"▇█▇▆▅",
|
|
2545
|
+
"▆▇█▇▆",
|
|
2546
|
+
"▅▆▇█▇",
|
|
2547
|
+
"▄▅▆▇█",
|
|
2548
|
+
"▃▄▅▆▇",
|
|
2549
|
+
"▂▃▄▅▆",
|
|
2550
|
+
"▁▂▃▄▅",
|
|
2551
|
+
"▁▁▂▃▄",
|
|
2552
|
+
"▁▁▁▂▃",
|
|
2553
|
+
"▁▁▁▁▂",
|
|
2554
|
+
];
|
|
2555
|
+
const fillStart = Date.now();
|
|
2556
|
+
let pulseIdx = 0;
|
|
2557
|
+
const pulseTimer = setInterval(() => {
|
|
2558
|
+
if (!dashData?._loading) return;
|
|
2559
|
+
const elapsed = ((Date.now() - fillStart) / 1000).toFixed(1);
|
|
2560
|
+
const pulse = PULSE_FRAMES[pulseIdx % PULSE_FRAMES.length];
|
|
2561
|
+
pulseIdx++;
|
|
2562
|
+
const platCount = dashData.active.length;
|
|
2563
|
+
status = `${gold(pulse)} scanning platforms… ${bold(`${elapsed}s`)} ${dim(`· ${platCount} found`)}`;
|
|
2564
|
+
redraw();
|
|
2565
|
+
}, 150);
|
|
2566
|
+
[, boardData] = await Promise.all([
|
|
2567
|
+
dashData?._remaining
|
|
2568
|
+
? fillDashboardRest(dashData, {
|
|
2569
|
+
shouldLoadPlatform: (platform) =>
|
|
2570
|
+
platform !== "omp" || ompEnabled,
|
|
2571
|
+
onPlatformStarted: async (platform) => {
|
|
2572
|
+
if (platform !== "omp") return;
|
|
2573
|
+
dashData._ompLoading = true;
|
|
2574
|
+
status =
|
|
2575
|
+
"omp is on · scanning history… large histories may take tens of seconds";
|
|
2576
|
+
await redraw();
|
|
2577
|
+
},
|
|
2578
|
+
onPlatformLoaded: async (platform, result) => {
|
|
2579
|
+
if (platform === "omp") {
|
|
2580
|
+
dashData._ompLoading = false;
|
|
2581
|
+
dashData._ompScanned = result.ok && ompEnabled;
|
|
2582
|
+
if (!ompEnabled) {
|
|
2583
|
+
dashData.active = dashData.active.filter(
|
|
2584
|
+
(d) => d.platform !== "omp",
|
|
2585
|
+
);
|
|
2586
|
+
status = "omp: off · completed scan result discarded";
|
|
2587
|
+
} else if (!result.ok) {
|
|
2588
|
+
status = "omp scan failed · switch [O] off, then on to retry";
|
|
2589
|
+
} else if (!result.found) {
|
|
2590
|
+
status = "omp: on · scan complete · no usage data found";
|
|
2591
|
+
} else {
|
|
2592
|
+
status = "omp: on · scan complete";
|
|
2593
|
+
}
|
|
2594
|
+
} else {
|
|
2595
|
+
status = dashData._ompLoading
|
|
2596
|
+
? `${platform} ready · omp scanning…`
|
|
2597
|
+
: `claude ready · ${platform} ready · filling other platforms…`;
|
|
2598
|
+
}
|
|
2599
|
+
await redraw();
|
|
2600
|
+
},
|
|
2601
|
+
})
|
|
2602
|
+
: Promise.resolve(false),
|
|
2603
|
+
loadBoardData(boardWindow).catch(() => null),
|
|
2604
|
+
]);
|
|
2605
|
+
clearInterval(pulseTimer);
|
|
2606
|
+
status = `last refreshed ${new Date().toLocaleTimeString("en-US", { hour12: false })}`;
|
|
2607
|
+
await redraw();
|
|
2608
|
+
};
|
|
2609
|
+
|
|
2610
|
+
// Always restore terminal on unexpected exit
|
|
2611
|
+
const cleanup = () => {
|
|
2612
|
+
write(SHOW);
|
|
2613
|
+
write(EXIT_ALT);
|
|
2614
|
+
process.exit(0);
|
|
2615
|
+
};
|
|
2616
|
+
process.once("SIGINT", cleanup);
|
|
2617
|
+
process.once("SIGTERM", cleanup);
|
|
2618
|
+
|
|
2619
|
+
// FIX 0b: re-render on terminal resize so the frame refits the new dimensions.
|
|
2620
|
+
process.stdout.on("resize", () => {
|
|
2621
|
+
redraw().catch(() => {});
|
|
2622
|
+
});
|
|
2623
|
+
|
|
2624
|
+
// Soft-landing: if not signed in, open on the Connect tab (a prompt, not a gate —
|
|
2625
|
+
// the user can still tab away to read Board/Dashboard while signed out).
|
|
2626
|
+
if (!isSignedIn(loadIdentity())) activeTab = 5;
|
|
2627
|
+
|
|
2628
|
+
// auto-refresh board every 30s
|
|
2629
|
+
const startAutoRefresh = () => {
|
|
2630
|
+
if (refreshTimer) clearInterval(refreshTimer);
|
|
2631
|
+
refreshTimer = setInterval(async () => {
|
|
2632
|
+
if (activeTab === 3) {
|
|
2633
|
+
// Board tab (now index 3)
|
|
2634
|
+
boardData = await loadBoardData(boardWindow).catch(() => null);
|
|
2635
|
+
await redraw();
|
|
2636
|
+
}
|
|
2637
|
+
}, 30000);
|
|
2638
|
+
};
|
|
2639
|
+
|
|
2640
|
+
// Draw the first frame, then load data in the background. Keyboard handling
|
|
2641
|
+
// must be installed before this work completes: pullActivePlatforms() and the
|
|
2642
|
+
// board request can take several seconds, and awaiting them here left a fully
|
|
2643
|
+
// rendered TUI that silently discarded every key (including Ctrl-C in raw
|
|
2644
|
+
// mode) until startup finished.
|
|
2645
|
+
await redraw();
|
|
2646
|
+
const initialLoad = loadAll().catch(async (e) => {
|
|
2647
|
+
status = `dashboard error: ${e.message}`;
|
|
2648
|
+
await redraw();
|
|
2649
|
+
});
|
|
2650
|
+
startAutoRefresh();
|
|
2651
|
+
|
|
2652
|
+
// ── Keyboard
|
|
2653
|
+
if (!process.stdin.isTTY) {
|
|
2654
|
+
await initialLoad;
|
|
2655
|
+
return;
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
process.stdin.setRawMode(true);
|
|
2659
|
+
process.stdin.resume();
|
|
2660
|
+
process.stdin.setEncoding("utf8");
|
|
2661
|
+
|
|
2662
|
+
// Esc-sequence buffer: some terminals (SSH, tmux, certain emulators) split an
|
|
2663
|
+
// arrow key (\x1b[C) into two data events: \x1b then [C. Without buffering, the
|
|
2664
|
+
// lone \x1b triggers the Esc handler (clearing the code buffer / leaving the tab)
|
|
2665
|
+
// before the [C arrives. We hold \x1b for 25ms; if more bytes follow, we
|
|
2666
|
+
// reassemble and deliver the full sequence. If nothing follows, it's a real Esc.
|
|
2667
|
+
let escBuf = null;
|
|
2668
|
+
let escTimer = null;
|
|
2669
|
+
const ESC_HOLD_MS = 25;
|
|
2670
|
+
|
|
2671
|
+
await new Promise((resolve) => {
|
|
2672
|
+
const handleKey = async (key) => {
|
|
2673
|
+
const k = key.toLowerCase();
|
|
2674
|
+
|
|
2675
|
+
if (k === "q" || k === "\x03") {
|
|
2676
|
+
if (refreshTimer) clearInterval(refreshTimer);
|
|
2677
|
+
// Startup data work may still be in flight now that input is available
|
|
2678
|
+
// immediately. Use the same hard cleanup as SIGINT/SIGTERM so pending
|
|
2679
|
+
// fetch timeouts or platform readers cannot keep the CLI alive or redraw
|
|
2680
|
+
// over the restored terminal after the user quits.
|
|
2681
|
+
cleanup();
|
|
2682
|
+
return;
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
// ── Connect tab is a FOCUSED code field (the one modal tab). Printable code
|
|
2686
|
+
// chars feed the buffer; Enter signs in; Esc clears or leaves; arrows switch.
|
|
2687
|
+
// Sits AFTER the quit block (so Q/Ctrl-C always escape) and BEFORE everything
|
|
2688
|
+
// else (so single-letter hotkeys don't fire while you're typing a code).
|
|
2689
|
+
if (activeTab === 5) {
|
|
2690
|
+
if (key === "\r" || key === "\n") {
|
|
2691
|
+
// [Enter] → sign in
|
|
2692
|
+
const code = codeBuf.trim();
|
|
2693
|
+
if (!code) {
|
|
2694
|
+
connectMsg = dim("paste a connect code first");
|
|
2695
|
+
await redraw();
|
|
2696
|
+
return;
|
|
2697
|
+
}
|
|
2698
|
+
connectMsg = dim("signing in…");
|
|
2699
|
+
await redraw();
|
|
2700
|
+
try {
|
|
2701
|
+
const out = await callTool("enroll", { code });
|
|
2702
|
+
if (out.status === "enrolled") {
|
|
2703
|
+
connectMsg = out.recovered
|
|
2704
|
+
? `${green("✓")} recovered account: ${cyan(out.codename || "(operator)")} ${dim("(device was already bound)")}`
|
|
2705
|
+
: `${green("✓")} signed in as ${cyan(out.codename || "(operator)")}`;
|
|
2706
|
+
codeBuf = "";
|
|
2707
|
+
} else {
|
|
2708
|
+
const reasons = {
|
|
2709
|
+
code_invalid:
|
|
2710
|
+
"that code is invalid, expired, or already used — generate a fresh one.",
|
|
2711
|
+
device_already_enrolled:
|
|
2712
|
+
'this device is already bound. Press [X] to reset the device, then paste a fresh code from signalaf.com → Settings → "New key".',
|
|
2713
|
+
bad_request: "the code or device key was malformed.",
|
|
2714
|
+
rate_limited:
|
|
2715
|
+
"too many attempts — wait a few minutes and retry.",
|
|
2716
|
+
persistence_unavailable:
|
|
2717
|
+
"sign-in is temporarily unavailable — try again shortly.",
|
|
2718
|
+
};
|
|
2719
|
+
connectMsg = `${red("✗")} ${reasons[out.reason] || `sign-in failed (${out.reason || "unknown"}).`}`;
|
|
2720
|
+
codeBuf = ""; // auto-clear on failure — the code is consumed/invalid
|
|
2721
|
+
}
|
|
2722
|
+
} catch (e) {
|
|
2723
|
+
connectMsg = `${red("✗")} ${e.message}`;
|
|
2724
|
+
codeBuf = ""; // auto-clear on error too
|
|
2725
|
+
}
|
|
2726
|
+
await redraw();
|
|
2727
|
+
return;
|
|
2728
|
+
}
|
|
2729
|
+
if (key === "\x1b") {
|
|
2730
|
+
// [Esc] → clear, or leave when empty
|
|
2731
|
+
if (codeBuf) {
|
|
2732
|
+
codeBuf = "";
|
|
2733
|
+
connectMsg = "";
|
|
2734
|
+
} else {
|
|
2735
|
+
activeTab = 0;
|
|
2736
|
+
}
|
|
2737
|
+
await redraw();
|
|
2738
|
+
return;
|
|
2739
|
+
}
|
|
2740
|
+
if (key === "\x1b[C") {
|
|
2741
|
+
activeTab = Math.min(5, activeTab + 1);
|
|
2742
|
+
await redraw();
|
|
2743
|
+
return;
|
|
2744
|
+
} // → tab
|
|
2745
|
+
if (key === "\x1b[D") {
|
|
2746
|
+
activeTab = Math.max(0, activeTab - 1);
|
|
2747
|
+
await redraw();
|
|
2748
|
+
return;
|
|
2749
|
+
} // ← tab
|
|
2750
|
+
if (key === "\x7f" || key === "\b") {
|
|
2751
|
+
codeBuf = codeBuf.slice(0, -1);
|
|
2752
|
+
await redraw();
|
|
2753
|
+
return;
|
|
2754
|
+
} // backspace
|
|
2755
|
+
// [X] sign out / reset — only when the code field is empty (so 'x'/'X' typed
|
|
2756
|
+
// mid-code still feeds the buffer — codes can contain X). The auto-clear on
|
|
2757
|
+
// sign-in failure (above) ensures the buffer is empty after a failed attempt,
|
|
2758
|
+
// so X works immediately when the user wants to reset. Drops the local
|
|
2759
|
+
// credential so a server-revoked OR server-enrolled-but-locally-unbound device
|
|
2760
|
+
// stops holding a stale device_id, and the next enroll provisions a fresh
|
|
2761
|
+
// device_id (escapes the Frankenstein-identity / 409-re-enroll deadlock).
|
|
2762
|
+
if (k === "x" && !codeBuf) {
|
|
2763
|
+
const wasSignedIn = isSignedIn(loadIdentity());
|
|
2764
|
+
clearIdentity();
|
|
2765
|
+
codeBuf = "";
|
|
2766
|
+
connectMsg = wasSignedIn
|
|
2767
|
+
? `${green("✓")} signed out — paste a new code to sign in`
|
|
2768
|
+
: `${green("✓")} device reset — paste a new code to sign in`;
|
|
2769
|
+
await redraw();
|
|
2770
|
+
return;
|
|
2771
|
+
}
|
|
2772
|
+
// A typed code char (len 1) or a pasted code (one multi-char chunk). Exclude any
|
|
2773
|
+
// escape sequence (↑/↓/etc.) so stray control bytes never pollute the buffer.
|
|
2774
|
+
if (!key.startsWith("\x1b") && (key.length > 1 || isCodeChar(key))) {
|
|
2775
|
+
codeBuf += key.split("").filter(isCodeChar).join("");
|
|
2776
|
+
await redraw();
|
|
2777
|
+
return;
|
|
2778
|
+
}
|
|
2779
|
+
return; // swallow any other key while focused (never falls through to global hotkeys)
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
// FIX I1: submit preview is a focused overlay (like Connect). [Enter] sends
|
|
2783
|
+
// all platforms, [Esc] cancels. Intercepts keys BEFORE the tab/ESC handlers.
|
|
2784
|
+
if (submitPreview) {
|
|
2785
|
+
if (key === "\r" || key === "\n") {
|
|
2786
|
+
// [Enter] → confirm + submit all platforms
|
|
2787
|
+
submitPreview = false;
|
|
2788
|
+
submitMsg = dim("submitting…");
|
|
2789
|
+
await redraw();
|
|
2790
|
+
const id = loadIdentity();
|
|
2791
|
+
if (!isSignedIn(id)) {
|
|
2792
|
+
submitMsg = `${red("✗")} sign in to submit`;
|
|
2793
|
+
activeTab = 5;
|
|
2794
|
+
await redraw();
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2797
|
+
// FIX I1: submit every active platform (data already loaded for the Dashboard).
|
|
2798
|
+
// Each platform is a separate submit_verified call → its own (platform, window) slot.
|
|
2799
|
+
const platforms = (dashData?.active ?? []).map((d) => d.platform);
|
|
2800
|
+
let totalVerified = 0,
|
|
2801
|
+
totalReceived = 0,
|
|
2802
|
+
hadFail = false,
|
|
2803
|
+
didMulti = false;
|
|
2804
|
+
for (const p of platforms) {
|
|
2805
|
+
try {
|
|
2806
|
+
const out = await callTool("submit_verified", { platform: p });
|
|
2807
|
+
const ws = out.windows || [];
|
|
2808
|
+
const received = ws.filter((w) => w.status === "received");
|
|
2809
|
+
const verified = received.filter(
|
|
2810
|
+
(w) => w.verification_tier === "verified",
|
|
2811
|
+
);
|
|
2812
|
+
totalReceived += received.length;
|
|
2813
|
+
totalVerified += verified.length;
|
|
2814
|
+
if (received.length > 0 && verified.length < received.length)
|
|
2815
|
+
hadFail = true;
|
|
2816
|
+
} catch {
|
|
2817
|
+
hadFail = true;
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
// MULTI: the combined cross-platform cascade (claude+codex+… summed). Only
|
|
2821
|
+
// meaningful with 2+ active platforms; submit_verified('multi') aggregates them.
|
|
2822
|
+
if (platforms.length >= 2) {
|
|
2823
|
+
try {
|
|
2824
|
+
const out = await callTool("submit_verified", {
|
|
2825
|
+
platform: "multi",
|
|
2826
|
+
});
|
|
2827
|
+
const ws = out.windows || [];
|
|
2828
|
+
const received = ws.filter((w) => w.status === "received");
|
|
2829
|
+
const verified = received.filter(
|
|
2830
|
+
(w) => w.verification_tier === "verified",
|
|
2831
|
+
);
|
|
2832
|
+
totalReceived += received.length;
|
|
2833
|
+
totalVerified += verified.length;
|
|
2834
|
+
if (received.length > 0 && verified.length < received.length)
|
|
2835
|
+
hadFail = true;
|
|
2836
|
+
if (received.length > 0) didMulti = true;
|
|
2837
|
+
} catch {
|
|
2838
|
+
hadFail = true;
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
if (totalReceived && !hadFail && totalVerified === totalReceived) {
|
|
2842
|
+
submitMsg = `${green("✓")} submitted · ${totalVerified} window${totalVerified > 1 ? "s" : ""} across ${platforms.length} platform${platforms.length > 1 ? "s" : ""}${didMulti ? " + combined" : ""} · verified`;
|
|
2843
|
+
} else if (hadFail && totalReceived) {
|
|
2844
|
+
submitMsg = `${red("✗")} device not verified — sign in again ([C])`;
|
|
2845
|
+
} else if (totalReceived === 0) {
|
|
2846
|
+
submitMsg = `${red("✗")} nothing to submit — no windows with data`;
|
|
2847
|
+
} else {
|
|
2848
|
+
submitMsg = `${red("✗")} submit failed`;
|
|
2849
|
+
}
|
|
2850
|
+
await redraw();
|
|
2851
|
+
return;
|
|
2852
|
+
}
|
|
2853
|
+
if (key === "\x1b") {
|
|
2854
|
+
// [Esc] → cancel
|
|
2855
|
+
submitPreview = false;
|
|
2856
|
+
await redraw();
|
|
2857
|
+
return;
|
|
2858
|
+
}
|
|
2859
|
+
return; // swallow all other keys while the preview is open
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
// Session-only omp toggle. OFF removes the row and excludes omp from later
|
|
2863
|
+
// refreshes; ON scans immediately and includes it until the TUI exits.
|
|
2864
|
+
if (activeTab === 0 && k === "o" && dashData?._ompAvailable) {
|
|
2865
|
+
// A filesystem traversal already in progress cannot be cancelled safely.
|
|
2866
|
+
// Switching OFF marks its result for discard; switching back ON waits
|
|
2867
|
+
// until that traversal finishes so two 10 GiB scans never overlap.
|
|
2868
|
+
if (dashData._ompLoading) {
|
|
2869
|
+
if (ompEnabled) {
|
|
2870
|
+
ompEnabled = false;
|
|
2871
|
+
dashData._ompEnabled = false;
|
|
2872
|
+
status = "omp: off · current scan will be discarded when it finishes";
|
|
2873
|
+
} else {
|
|
2874
|
+
status = "omp: off · waiting for the current scan to finish";
|
|
2875
|
+
}
|
|
2876
|
+
await redraw();
|
|
2877
|
+
return;
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2880
|
+
ompEnabled = !ompEnabled;
|
|
2881
|
+
dashData._ompEnabled = ompEnabled;
|
|
2882
|
+
if (!ompEnabled) {
|
|
2883
|
+
dashData.active = dashData.active.filter((d) => d.platform !== "omp");
|
|
2884
|
+
dashData._ompScanned = false;
|
|
2885
|
+
status = "omp: off · excluded from Dashboard refreshes";
|
|
2886
|
+
await redraw();
|
|
2887
|
+
return;
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
const targetDashData = dashData;
|
|
2891
|
+
targetDashData._ompLoading = true;
|
|
2892
|
+
status =
|
|
2893
|
+
"omp: on · scanning history… large histories may take tens of seconds";
|
|
2894
|
+
await redraw();
|
|
2895
|
+
|
|
2896
|
+
const result = await pullDashboardPlatform(targetDashData, "omp");
|
|
2897
|
+
targetDashData._ompLoading = false;
|
|
2898
|
+
targetDashData._ompScanned = result.ok && ompEnabled;
|
|
2899
|
+
|
|
2900
|
+
if (!ompEnabled) {
|
|
2901
|
+
targetDashData.active = targetDashData.active.filter(
|
|
2902
|
+
(d) => d.platform !== "omp",
|
|
2903
|
+
);
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
// [R] may replace dashData while this scan is running. Do not let the
|
|
2907
|
+
// result or status from that stale Dashboard generation overwrite it.
|
|
2908
|
+
if (dashData !== targetDashData) return;
|
|
2909
|
+
status = !ompEnabled
|
|
2910
|
+
? "omp: off · completed scan result discarded"
|
|
2911
|
+
: !result.ok
|
|
2912
|
+
? "omp scan failed · switch [O] off, then on to retry"
|
|
2913
|
+
: result.found
|
|
2914
|
+
? `omp: on · loaded ${new Date().toLocaleTimeString("en-US", { hour12: false })}`
|
|
2915
|
+
: "omp: on · scan complete · no usage data found";
|
|
2916
|
+
await redraw();
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
// ESC → go back to Dashboard from any tab
|
|
2921
|
+
if (key === "\x1b" && activeTab !== 0) {
|
|
2922
|
+
activeTab = 0;
|
|
2923
|
+
await redraw();
|
|
2924
|
+
return;
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
// SCROLL-VIEW (2026-06-27): ↑/↓ (or j/k) scroll the cascade section on the
|
|
2928
|
+
// Dashboard tab ONLY. The rest of the dashboard stays pinned. Only active
|
|
2929
|
+
// when there are more cascade rows than fit in the viewport.
|
|
2930
|
+
if (activeTab === 0 && dashData && Array.isArray(dashData.active)) {
|
|
2931
|
+
const total = cascadeScrollableCount(dashData.active);
|
|
2932
|
+
const maxRows = maxCascadeRowsFor(dashData.active);
|
|
2933
|
+
if (total > maxRows) {
|
|
2934
|
+
const maxOff = total - maxRows;
|
|
2935
|
+
if (key === "\x1b[A" || k === "k") {
|
|
2936
|
+
// ↑ / k → scroll up
|
|
2937
|
+
cascadeScroll = Math.max(0, cascadeScroll - 1);
|
|
2938
|
+
await redraw();
|
|
2939
|
+
return;
|
|
2940
|
+
}
|
|
2941
|
+
if (key === "\x1b[B" || k === "j") {
|
|
2942
|
+
// ↓ / j → scroll down
|
|
2943
|
+
cascadeScroll = Math.min(maxOff, cascadeScroll + 1);
|
|
2944
|
+
await redraw();
|
|
2945
|
+
return;
|
|
2946
|
+
}
|
|
2947
|
+
if (key === "\x1b[5~") {
|
|
2948
|
+
// PageUp → scroll up by viewport
|
|
2949
|
+
cascadeScroll = Math.max(0, cascadeScroll - maxRows);
|
|
2950
|
+
await redraw();
|
|
2951
|
+
return;
|
|
2952
|
+
}
|
|
2953
|
+
if (key === "\x1b[6~") {
|
|
2954
|
+
// PageDown → scroll down by viewport
|
|
2955
|
+
cascadeScroll = Math.min(maxOff, cascadeScroll + maxRows);
|
|
2956
|
+
await redraw();
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
// tab switching (6 tabs: 0=Dashboard 1=Trends 2=Compare 3=Board 4=Watch 5=Connect)
|
|
2963
|
+
let switched = false;
|
|
2964
|
+
if (key === "\x1b[C") {
|
|
2965
|
+
activeTab = Math.min(5, activeTab + 1);
|
|
2966
|
+
switched = true;
|
|
2967
|
+
}
|
|
2968
|
+
if (key === "\x1b[D") {
|
|
2969
|
+
activeTab = Math.max(0, activeTab - 1);
|
|
2970
|
+
switched = true;
|
|
2971
|
+
}
|
|
2972
|
+
if (k === "1") {
|
|
2973
|
+
activeTab = 0;
|
|
2974
|
+
switched = true;
|
|
2975
|
+
}
|
|
2976
|
+
if (k === "2") {
|
|
2977
|
+
activeTab = 1;
|
|
2978
|
+
switched = true;
|
|
2979
|
+
}
|
|
2980
|
+
if (k === "3") {
|
|
2981
|
+
activeTab = 2;
|
|
2982
|
+
switched = true;
|
|
2983
|
+
}
|
|
2984
|
+
if (k === "4") {
|
|
2985
|
+
activeTab = 3;
|
|
2986
|
+
switched = true;
|
|
2987
|
+
}
|
|
2988
|
+
if (k === "5") {
|
|
2989
|
+
activeTab = 4;
|
|
2990
|
+
switched = true;
|
|
2991
|
+
} // Watch = an in-TUI landing panel
|
|
2992
|
+
if (k === "6") {
|
|
2993
|
+
activeTab = 5;
|
|
2994
|
+
switched = true;
|
|
2995
|
+
} // Connect = sign in / switch device
|
|
2996
|
+
if (k === "c" && activeTab !== 5) {
|
|
2997
|
+
activeTab = 5;
|
|
2998
|
+
switched = true;
|
|
2999
|
+
} // [C] → Connect from any read tab
|
|
3000
|
+
|
|
3001
|
+
// SCROLL-VIEW: reset cascade scroll when leaving the Dashboard tab
|
|
3002
|
+
if (switched && activeTab !== 0) cascadeScroll = 0;
|
|
3003
|
+
|
|
3004
|
+
// #9 (2026-06-27): Compare loads its FRESH verifier pull ON-DEMAND when the tab opens
|
|
3005
|
+
// (NOT on the Dashboard load path — that pull is a 5–60s scan). Paint the "pulling fresh
|
|
3006
|
+
// sources…" spinner first (compareData null → redraw shows it), then run the pull + re-render.
|
|
3007
|
+
// ASYNC FIX (2026-06-27): with execFile (async), the event loop keeps running during the
|
|
3008
|
+
// pull — key presses are NOT blocked. Guard: only render the result if the user is still
|
|
3009
|
+
// on the Compare tab (they may have switched away during the pull).
|
|
3010
|
+
if (switched && activeTab === 2 && !compareData) {
|
|
3011
|
+
submitMsg = "";
|
|
3012
|
+
await redraw(); // show the spinner line before the (slow) fresh pull
|
|
3013
|
+
// Animated spinner with elapsed timer — the fresh verifier pull takes
|
|
3014
|
+
// 5-20s (ccusage CLI scans 1,700+ JSONL files). Without animation the
|
|
3015
|
+
// user sees a frozen "pulling fresh sources…" line and thinks it hung.
|
|
3016
|
+
const SPIN_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
3017
|
+
const BEAR_FRAMES = ["ʕ•ᴥ•ʔ", "ʕ•ᴥ•ʔノ", "ʕ•ᴥ•ʔ", "ʕ•ᴥ•ʔノ"];
|
|
3018
|
+
const pullStart = Date.now();
|
|
3019
|
+
let spinIdx = 0;
|
|
3020
|
+
const spinTimer = setInterval(() => {
|
|
3021
|
+
if (activeTab !== 2) return; // user switched away — don't repaint
|
|
3022
|
+
const elapsed = ((Date.now() - pullStart) / 1000).toFixed(1);
|
|
3023
|
+
const spin = SPIN_FRAMES[spinIdx % SPIN_FRAMES.length];
|
|
3024
|
+
const bear = BEAR_FRAMES[spinIdx % BEAR_FRAMES.length];
|
|
3025
|
+
spinIdx++;
|
|
3026
|
+
// Repaint just the spinner line in-place (line 4 of the frame)
|
|
3027
|
+
process.stdout.write(
|
|
3028
|
+
GOTO(4, 1) + ERLINE +
|
|
3029
|
+
` ${gold(spin)} ${dim(`pulling fresh sources…`)} ${bold(`${elapsed}s`)} ${dim(`·`)} ${gold(bear)}`,
|
|
3030
|
+
);
|
|
3031
|
+
}, 200);
|
|
3032
|
+
const result = await loadCompareData(platform).catch(() => null);
|
|
3033
|
+
clearInterval(spinTimer);
|
|
3034
|
+
if (activeTab === 2) {
|
|
3035
|
+
// still on Compare? render the result
|
|
3036
|
+
compareData = result;
|
|
3037
|
+
await redraw();
|
|
3038
|
+
}
|
|
3039
|
+
return;
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
// Trends tab: [T] cycles the sub-view (You · Platform · Field)
|
|
3043
|
+
if (activeTab === 1 && k === "t") {
|
|
3044
|
+
trendSub = (trendSub + 1) % 3;
|
|
3045
|
+
await redraw();
|
|
3046
|
+
return;
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
// Watch tab: [+]/[-] tune the refresh interval (5–600s), [Enter] launches the watcher
|
|
3050
|
+
if (activeTab === 4 && (k === "+" || k === "=")) {
|
|
3051
|
+
watchRefresh = Math.min(600, watchRefresh + 5);
|
|
3052
|
+
await redraw();
|
|
3053
|
+
return;
|
|
3054
|
+
}
|
|
3055
|
+
if (activeTab === 4 && (k === "-" || k === "_")) {
|
|
3056
|
+
watchRefresh = Math.max(5, watchRefresh - 5);
|
|
3057
|
+
await redraw();
|
|
3058
|
+
return;
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
// FIX J: [P] cycles platform on Compare + Watch tabs (was a dead hinted key).
|
|
3062
|
+
// Compare reloads compareData for the new platform; Watch just re-renders.
|
|
3063
|
+
const CYCLE_PLATFORMS = [
|
|
3064
|
+
"claude",
|
|
3065
|
+
"codex",
|
|
3066
|
+
"devin",
|
|
3067
|
+
"amp",
|
|
3068
|
+
"gemini",
|
|
3069
|
+
"kimi",
|
|
3070
|
+
"qwen",
|
|
3071
|
+
"goose",
|
|
3072
|
+
"kilo",
|
|
3073
|
+
"hermes",
|
|
3074
|
+
"droid",
|
|
3075
|
+
"codebuff",
|
|
3076
|
+
"copilot",
|
|
3077
|
+
"openclaw",
|
|
3078
|
+
"pi",
|
|
3079
|
+
"omp",
|
|
3080
|
+
];
|
|
3081
|
+
if (k === "p" && (activeTab === 2 || activeTab === 4)) {
|
|
3082
|
+
if (activeTab === 2) {
|
|
3083
|
+
// Compare: real platforms only (no 'all' — Compare is per-platform by design).
|
|
3084
|
+
const idx = CYCLE_PLATFORMS.indexOf(platform);
|
|
3085
|
+
platform = CYCLE_PLATFORMS[(idx + 1) % CYCLE_PLATFORMS.length];
|
|
3086
|
+
// #9: clearing compareData makes redraw show the "pulling fresh sources…" spinner
|
|
3087
|
+
// for the new platform while the fresh on-demand verifier pull runs.
|
|
3088
|
+
compareData = null;
|
|
3089
|
+
status = `loading ${platform}…`;
|
|
3090
|
+
await redraw();
|
|
3091
|
+
// Animated spinner (same as the initial Compare load — see above)
|
|
3092
|
+
const SPIN_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
3093
|
+
const BEAR_FRAMES = ["ʕ•ᴥ•ʔ", "ʕ•ᴥ•ʔノ", "ʕ•ᴥ•ʔ", "ʕ•ᴥ•ʔノ"];
|
|
3094
|
+
const pullStart = Date.now();
|
|
3095
|
+
let spinIdx = 0;
|
|
3096
|
+
const spinTimer = setInterval(() => {
|
|
3097
|
+
if (activeTab !== 2) return;
|
|
3098
|
+
const elapsed = ((Date.now() - pullStart) / 1000).toFixed(1);
|
|
3099
|
+
const spin = SPIN_FRAMES[spinIdx % SPIN_FRAMES.length];
|
|
3100
|
+
const bear = BEAR_FRAMES[spinIdx % BEAR_FRAMES.length];
|
|
3101
|
+
spinIdx++;
|
|
3102
|
+
process.stdout.write(
|
|
3103
|
+
GOTO(4, 1) + ERLINE +
|
|
3104
|
+
` ${gold(spin)} ${dim(`pulling ${platform}…`)} ${bold(`${elapsed}s`)} ${dim(`·`)} ${gold(bear)}`,
|
|
3105
|
+
);
|
|
3106
|
+
}, 200);
|
|
3107
|
+
// ASYNC FIX: guard — only render if still on Compare (user may have switched away)
|
|
3108
|
+
const result = await loadCompareData(platform).catch(() => null);
|
|
3109
|
+
clearInterval(spinTimer);
|
|
3110
|
+
if (activeTab === 2) {
|
|
3111
|
+
compareData = result;
|
|
3112
|
+
status = `last refreshed ${new Date().toLocaleTimeString("en-US", { hour12: false })}`;
|
|
3113
|
+
}
|
|
3114
|
+
} else {
|
|
3115
|
+
// A5: Watch [P] cycles 'all' → claude → codex → … → 'all' (optional focus; default 'all').
|
|
3116
|
+
const WATCH_PLAT_CYCLE = ["all", ...CYCLE_PLATFORMS];
|
|
3117
|
+
const idx = WATCH_PLAT_CYCLE.indexOf(watchPlatform);
|
|
3118
|
+
watchPlatform = WATCH_PLAT_CYCLE[(idx + 1) % WATCH_PLAT_CYCLE.length];
|
|
3119
|
+
}
|
|
3120
|
+
await redraw();
|
|
3121
|
+
return;
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
// FIX J / A5: [W] cycles window FOCUS on the Watch tab. 'all-windows' (default) → 7d → 30d →
|
|
3125
|
+
// 90d → all → back to 'all-windows'. Board tab's [W] (activeTab === 3) is handled below.
|
|
3126
|
+
if (k === "w" && activeTab === 4) {
|
|
3127
|
+
const WATCH_WIN_CYCLE = ["all-windows", "7d", "30d", "90d", "all"];
|
|
3128
|
+
const idx = WATCH_WIN_CYCLE.indexOf(watchWindow);
|
|
3129
|
+
watchWindow = WATCH_WIN_CYCLE[(idx + 1) % WATCH_WIN_CYCLE.length];
|
|
3130
|
+
await redraw();
|
|
3131
|
+
return;
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
if (activeTab === 4 && (key === "\r" || key === "\n")) {
|
|
3135
|
+
// A5 (2026-06-27): launch the live watcher in its own window. By default (focus = 'all')
|
|
3136
|
+
// pass NO --platform/--window so the cli watcher auto-loads ALL active platforms × ALL
|
|
3137
|
+
// windows. Only add a filter flag when the user has set an explicit [P]/[W] focus.
|
|
3138
|
+
try {
|
|
3139
|
+
let watchCmd = "sigrank watch";
|
|
3140
|
+
if (watchPlatform && watchPlatform !== "all")
|
|
3141
|
+
watchCmd += ` --platform ${watchPlatform}`;
|
|
3142
|
+
if (watchWindow && watchWindow !== "all-windows")
|
|
3143
|
+
watchCmd += ` --window ${watchWindow}`;
|
|
3144
|
+
watchCmd += ` --refresh ${watchRefresh}`;
|
|
3145
|
+
// ASYNC FIX (2026-06-27): execFile instead of execSync — no shell
|
|
3146
|
+
// interpolation. osascript args passed as array (defense-in-depth).
|
|
3147
|
+
execFile(
|
|
3148
|
+
"osascript",
|
|
3149
|
+
["-e", `tell application "Terminal" to do script "${watchCmd}"`],
|
|
3150
|
+
{ stdio: "ignore" },
|
|
3151
|
+
() => {},
|
|
3152
|
+
);
|
|
3153
|
+
const scope =
|
|
3154
|
+
(!watchPlatform || watchPlatform === "all") &&
|
|
3155
|
+
(!watchWindow || watchWindow === "all-windows")
|
|
3156
|
+
? "all platforms × all windows"
|
|
3157
|
+
: "focused";
|
|
3158
|
+
status = `watcher launched (${scope}, ${watchRefresh}s) in a new window`;
|
|
3159
|
+
} catch {
|
|
3160
|
+
status = "could not open Terminal.app — run: sigrank watch";
|
|
3161
|
+
}
|
|
3162
|
+
await redraw();
|
|
3163
|
+
return;
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
if (k === "r") {
|
|
3167
|
+
if (dashData?._ompLoading) {
|
|
3168
|
+
status = "omp scan already in progress · refresh is available when it finishes";
|
|
3169
|
+
await redraw();
|
|
3170
|
+
return;
|
|
3171
|
+
}
|
|
3172
|
+
status = "refreshing…";
|
|
3173
|
+
cascadeScroll = 0; // SCROLL-VIEW: reset scroll on refresh
|
|
3174
|
+
// #9: on the Compare tab, [R] re-runs the FRESH verifier pull for the current platform
|
|
3175
|
+
// (loadAll no longer touches Compare). Clearing compareData shows the spinner first.
|
|
3176
|
+
if (activeTab === 2) {
|
|
3177
|
+
compareData = null;
|
|
3178
|
+
await redraw();
|
|
3179
|
+
// ASYNC FIX: guard — only render if still on Compare
|
|
3180
|
+
const result = await loadCompareData(platform).catch(() => null);
|
|
3181
|
+
if (activeTab === 2) {
|
|
3182
|
+
compareData = result;
|
|
3183
|
+
status = `last refreshed ${new Date().toLocaleTimeString("en-US", { hour12: false })}`;
|
|
3184
|
+
await redraw();
|
|
3185
|
+
}
|
|
3186
|
+
return;
|
|
3187
|
+
}
|
|
3188
|
+
await redraw();
|
|
3189
|
+
await loadAll();
|
|
3190
|
+
return;
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
if (k === "s" && activeTab !== 5) {
|
|
3194
|
+
// FIX I1/E: [S] opens a submit PREVIEW (see→confirm→send) instead of firing
|
|
3195
|
+
// blind. The preview shows a platform × window grid of what will publish;
|
|
3196
|
+
// [Enter] sends all platforms, [Esc] cancels. (On the Connect tab, 's' is a
|
|
3197
|
+
// code char handled by the focused field above.)
|
|
3198
|
+
const id = loadIdentity();
|
|
3199
|
+
if (!isSignedIn(id)) {
|
|
3200
|
+
// not signed in → open Connect, never error
|
|
3201
|
+
activeTab = 5;
|
|
3202
|
+
connectMsg = dim("sign in to submit to board");
|
|
3203
|
+
await redraw();
|
|
3204
|
+
return;
|
|
3205
|
+
}
|
|
3206
|
+
submitPreview = true;
|
|
3207
|
+
submitMsg = "";
|
|
3208
|
+
await redraw();
|
|
3209
|
+
return;
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
// FIX I2: [Y] toggles the Board between global+highlighted and "just me"
|
|
3213
|
+
// (hybrid model — owner decision 2026-06-26). Only when signed in.
|
|
3214
|
+
if (k === "y" && activeTab === 3 && isSignedIn(loadIdentity())) {
|
|
3215
|
+
boardYouOnly = !boardYouOnly;
|
|
3216
|
+
await redraw();
|
|
3217
|
+
return;
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
if (k === "w" && activeTab === 3) {
|
|
3221
|
+
// Board tab (now index 3)
|
|
3222
|
+
const windows = ["7d", "30d", "90d", "all"];
|
|
3223
|
+
const idx = windows.indexOf(boardWindow);
|
|
3224
|
+
boardWindow = windows[(idx + 1) % windows.length];
|
|
3225
|
+
boardData = await loadBoardData(boardWindow).catch(() => null);
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
if (switched) submitMsg = ""; // submit result is tied to the tab it was sent from
|
|
3229
|
+
if (switched || k === "w") await redraw();
|
|
3230
|
+
};
|
|
3231
|
+
|
|
3232
|
+
// Buffered stdin listener: reassembles split Esc sequences before dispatch.
|
|
3233
|
+
process.stdin.on("data", (chunk) => {
|
|
3234
|
+
// If we have a pending \x1b held from a previous chunk, combine it with
|
|
3235
|
+
// this chunk to reassemble the full escape sequence (\x1b + [C = \x1b[C).
|
|
3236
|
+
if (escBuf) {
|
|
3237
|
+
clearTimeout(escTimer);
|
|
3238
|
+
const combined = escBuf + chunk;
|
|
3239
|
+
escBuf = null;
|
|
3240
|
+
escTimer = null;
|
|
3241
|
+
// If the combined result is \x1b followed by more \x1b (rare), just
|
|
3242
|
+
// deliver the first as Esc and re-buffer the second. Otherwise deliver
|
|
3243
|
+
// the combined sequence.
|
|
3244
|
+
if (combined.length > 1) {
|
|
3245
|
+
handleKey(combined);
|
|
3246
|
+
return;
|
|
3247
|
+
}
|
|
3248
|
+
// combined is still just \x1b (chunk was empty?) → fall through to hold
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
// Fast path: chunk doesn't start with \x1b → deliver immediately.
|
|
3252
|
+
if (chunk[0] !== "\x1b") {
|
|
3253
|
+
handleKey(chunk);
|
|
3254
|
+
return;
|
|
3255
|
+
}
|
|
3256
|
+
// Chunk starts with \x1b and is longer than 1 byte → complete escape
|
|
3257
|
+
// sequence → deliver immediately.
|
|
3258
|
+
if (chunk.length > 1) {
|
|
3259
|
+
handleKey(chunk);
|
|
3260
|
+
return;
|
|
3261
|
+
}
|
|
3262
|
+
// Chunk is exactly \x1b → hold for ESC_HOLD_MS to see if more bytes follow.
|
|
3263
|
+
escBuf = chunk;
|
|
3264
|
+
escTimer = setTimeout(() => {
|
|
3265
|
+
handleKey(escBuf);
|
|
3266
|
+
escBuf = null;
|
|
3267
|
+
escTimer = null;
|
|
3268
|
+
}, ESC_HOLD_MS);
|
|
3269
|
+
});
|
|
3270
|
+
});
|
|
3271
|
+
|
|
3272
|
+
process.stdin.setRawMode(false);
|
|
3273
|
+
process.stdin.pause();
|
|
3274
|
+
writeln();
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
// ── Exports for the headless audit system (tui-audit.mjs) ──────────────────
|
|
3278
|
+
// These expose the render functions + buffer state so the audit module can
|
|
3279
|
+
// render tabs in-process (fast) instead of spawning child processes.
|
|
3280
|
+
export {
|
|
3281
|
+
renderDashboard,
|
|
3282
|
+
renderTrends,
|
|
3283
|
+
renderCompare,
|
|
3284
|
+
renderBoard,
|
|
3285
|
+
renderConnect,
|
|
3286
|
+
renderWatch,
|
|
3287
|
+
renderWatchData,
|
|
3288
|
+
loadWatchData,
|
|
3289
|
+
loadDashboardData,
|
|
3290
|
+
loadCompareData,
|
|
3291
|
+
loadBoardData,
|
|
3292
|
+
startBuffer,
|
|
3293
|
+
stripAnsi,
|
|
3294
|
+
};
|
|
3295
|
+
// _screenBuf is a `let` — exporting it snapshots the value, not the binding.
|
|
3296
|
+
// Expose getter/setter so the audit module can read the live buffer after
|
|
3297
|
+
// startBuffer() + render() populate it, and reset it after capturing.
|
|
3298
|
+
export function _getScreenBuf() {
|
|
3299
|
+
return _screenBuf;
|
|
3300
|
+
}
|
|
3301
|
+
export function _resetBuf() {
|
|
3302
|
+
_screenBuf = null;
|
|
3303
|
+
_footerBuf = null;
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
// Direct-run entry (e.g. `node tui.mjs --render 0`). Normal launch is via cli.mjs.
|
|
3307
|
+
// pathToFileURL resolves relative argv[1] (e.g. `presentation/tui.mjs`) to the
|
|
3308
|
+
// same absolute file:// URL that import.meta.url always carries — without it,
|
|
3309
|
+
// `node presentation/tui.mjs --audit` never matched the guard and runTui never
|
|
3310
|
+
// ran (silent no-op). This is the standard Node ESM main-module detection.
|
|
3311
|
+
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
3312
|
+
runTui().catch((e) => {
|
|
3313
|
+
console.error(e);
|
|
3314
|
+
process.exit(1);
|
|
3315
|
+
});
|
|
3316
|
+
}
|