sigrank 0.11.4

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/tui.mjs ADDED
@@ -0,0 +1,1582 @@
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 } from './tools.mjs'
16
+ import { isSignedIn, isCodeChar } from './connect.mjs'
17
+ import { loadIdentity, clearIdentity } from './keystore.mjs'
18
+ import { execSync } from 'child_process'
19
+ import { existsSync, readFileSync } from 'fs'
20
+ import os from 'os'
21
+ import path from 'path'
22
+ import { createRequire } from 'module'
23
+
24
+ // Version read from package.json (single source of truth — matches cli.mjs;
25
+ // never hardcode, that's what caused the version drift).
26
+ const VERSION = (() => {
27
+ try { return createRequire(import.meta.url)('./package.json').version } catch { return '?' }
28
+ })()
29
+
30
+ // ── ANSI ───────────────────────────────────────────────────────────────────
31
+ const ESC = '\x1b['
32
+ const c = {
33
+ reset: `${ESC}0m`,
34
+ bold: `${ESC}1m`,
35
+ dim: `${ESC}2m`,
36
+ gold: `${ESC}33m`,
37
+ boldGold: `${ESC}1;33m`,
38
+ cyan: `${ESC}36m`,
39
+ boldCyan: `${ESC}1;36m`,
40
+ green: `${ESC}32m`,
41
+ red: `${ESC}31m`,
42
+ white: `${ESC}97m`,
43
+ boldWhite: `${ESC}1;97m`,
44
+ magenta: `${ESC}35m`,
45
+ blue: `${ESC}34m`,
46
+ bgDim: `${ESC}48;5;236m`, // dark grey bg for active tab
47
+ bgCyan: `${ESC}48;5;23m`, // dark teal bg for active tab
48
+ }
49
+ const paint = (col, s) => `${col}${s}${c.reset}`
50
+ const bold = (s) => paint(c.bold, s)
51
+ const dim = (s) => paint(c.dim, s)
52
+ const gold = (s) => paint(c.boldGold, s)
53
+ const cyan = (s) => paint(c.boldCyan, s)
54
+ const hdr = (s) => paint(c.boldCyan, s) // table column-header label color (readability)
55
+ const green = (s) => paint(c.green, s)
56
+ const red = (s) => paint(c.red, s)
57
+
58
+ const CLEAR = `${ESC}H${ESC}2J` // home + erase visible area
59
+ const ENTER_ALT = `${ESC}?1049h` // enter alternate screen buffer
60
+ const EXIT_ALT = `${ESC}?1049l` // exit alternate screen buffer (restores original)
61
+ const HIDE = `${ESC}?25l`
62
+ const SHOW = `${ESC}?25h`
63
+ const UP = (n) => `${ESC}${n}A`
64
+ const ERLINE = `${ESC}2K`
65
+ const GOTO = (r, col = 1) => `${ESC}${r};${col}H` // absolute cursor position
66
+
67
+ const W = () => process.stdout.columns || 100
68
+ // FIX 0b: safe default 24 (not 40) when rows is undefined (non-TTY / IDE terminals).
69
+ // A 40-row assumption into a 24-row window scrolls the alt-screen → footer smears off.
70
+ const H = () => process.stdout.rows || 24
71
+
72
+ // ── Screen buffer: collect lines, then paint only what fits ─────────────
73
+ // Render functions call write/writeln as before; the buffer captures them.
74
+ // flushScreen() paints to the real terminal using absolute cursor positioning
75
+ // so the TUI never scrolls — it's a locked frame like tokscale.
76
+ let _screenBuf = null // null = direct mode (unbuffered); string[] = buffered
77
+ const write = (s) => { if (_screenBuf) { const parts = s.split('\n'); if (parts.length === 1) { _screenBuf[_screenBuf.length-1] += s } else { _screenBuf[_screenBuf.length-1] += parts[0]; for (let i=1;i<parts.length;i++) _screenBuf.push(parts[i]) } } else { process.stdout.write(s) } }
78
+ const writeln = (s = '') => { if (_screenBuf) { _screenBuf[_screenBuf.length-1] += s; _screenBuf.push('') } else { process.stdout.write(s + '\n') } }
79
+
80
+ let _footerBuf = null // footer lines pinned to bottom
81
+ function startBuffer() { _screenBuf = ['']; _footerBuf = null }
82
+ function setFooter(lines) { _footerBuf = lines }
83
+ function flushScreen() {
84
+ if (!_screenBuf) return
85
+ const lines = _screenBuf
86
+ const footer = _footerBuf || []
87
+ _screenBuf = null
88
+ _footerBuf = null
89
+ const h = H()
90
+ const w = W()
91
+ // FIX 0b: footer is SACROSANCT. Clamp CONTENT to h - footer.length first, then
92
+ // always append every footer line. The footer must NEVER be the thing dropped
93
+ // (it was — footer painted last, clamp cut the tail = footer first → lost hints
94
+ // + lost lower cascade rows, together, because they're the same tail of the frame).
95
+ const contentRows = Math.max(0, h - footer.length)
96
+ const content = lines.slice(0, contentRows)
97
+ const frame = [...content, ...footer]
98
+ const maxRows = Math.min(frame.length, h)
99
+ let out = ''
100
+ for (let i = 0; i < maxRows; i++) {
101
+ out += GOTO(i + 1) + ERLINE + ansiTrunc(frame[i], w)
102
+ }
103
+ // Clear any rows below the frame left over from a taller previous frame.
104
+ for (let i = maxRows; i < h; i++) {
105
+ out += GOTO(i + 1) + ERLINE
106
+ }
107
+ process.stdout.write(out)
108
+ }
109
+
110
+ function stripAnsi(s) { return s.replace(/\x1b\[[0-9;]*m/g, '') }
111
+ // ANSI-aware truncate: cut to `w` VISIBLE columns while keeping color escapes
112
+ // intact (escapes don't count toward width, and we never slice mid-sequence).
113
+ // Replaces raw `.slice(0, w)`, which corrupts color codes + miscounts width.
114
+ function ansiTrunc(s, w) {
115
+ if (stripAnsi(s).length <= w) return s
116
+ let out = '', vis = 0, i = 0
117
+ while (i < s.length && vis < w) {
118
+ if (s[i] === '\x1b') {
119
+ const m = s.slice(i).match(/^\x1b\[[0-9;]*m/)
120
+ if (m) { out += m[0]; i += m[0].length; continue }
121
+ }
122
+ out += s[i]; vis++; i++
123
+ }
124
+ return out + '\x1b[0m' // reset so a cut mid-color doesn't bleed into next line
125
+ }
126
+ function padEnd(s, w) { const v = stripAnsi(s).length; return v >= w ? s : s + ' '.repeat(w - v) }
127
+ function padStart(s,w) { const v = stripAnsi(s).length; return v >= w ? s : ' '.repeat(w - v) + s }
128
+ function trunc(s, w) { return stripAnsi(s).length <= w ? s : s.slice(0, w - 1) + '…' }
129
+ function hr(ch = '─') { return dim(ch.repeat(Math.max(0, W() - 4))) }
130
+
131
+ // ── Number formatters ───────────────────────────────────────────────────────
132
+ const fmtY = (y) => y == null ? '—' : y >= 10000 ? `${(y/1000).toFixed(1)}K` : y >= 1000 ? `${(y/1000).toFixed(2)}K` : y.toFixed(1)
133
+ const fmtLev = (l) => l == null ? '—' : l >= 1000 ? `${(l/1000).toFixed(1)}K` : l.toFixed(0)
134
+ const fmtSNR = (n) => n == null ? '—' : `${(n*100).toFixed(1)}%`
135
+ const fmtTok = (n) => n == null ? '—' : n >= 1e9 ? `${(n/1e9).toFixed(1)}B` : n >= 1e6 ? `${(n/1e6).toFixed(1)}M` : n >= 1e3 ? `${(n/1e3).toFixed(1)}K` : String(n)
136
+ const fmtMov = (n) => (n == null || n === 0) ? dim('—') : n > 0 ? green(`+${n}`) : red(`${n}`)
137
+
138
+ // ── Class tier colors ────────────────────────────────────────────────────────
139
+ const CLS = {
140
+ TRANSMITTER: (s) => paint(c.boldGold, s),
141
+ 'ARCH+': (s) => paint(c.boldCyan, s),
142
+ ARCH: (s) => paint(c.cyan, s),
143
+ POWER: (s) => paint(c.boldWhite, s),
144
+ BASE: (s) => paint(c.white, s),
145
+ SEEKER: (s) => paint(c.magenta, s),
146
+ REFINER: (s) => paint(c.blue, s),
147
+ BEARER: (s) => paint(c.dim, s),
148
+ IGNITER: (s) => paint(c.dim, s),
149
+ }
150
+ const colorCls = (cls) => (CLS[cls] ?? ((s) => s))(cls)
151
+
152
+ // ── Cascade math (inline, no dep) ───────────────────────────────────────────
153
+ function cascadeFrom(p) {
154
+ if (!p) return null
155
+ const i = p.input ?? 0, o = p.output ?? 0, cr = p.cacheRead ?? 0
156
+ if (i === 0 || o === 0) return null
157
+ const leverage = cr / i
158
+ const velocity = o / i
159
+ const yld = leverage * velocity
160
+ const dev10x = (i > 0 && o > 0 && (p.cacheCreate ?? 0) > 0 && cr > 0)
161
+ ? Math.log10(cr / i) : null
162
+ const snr = o / (i + o)
163
+
164
+ let cls = 'IGNITER'
165
+ if (yld >= 1000 || dev10x >= 3) cls = 'TRANSMITTER'
166
+ else if (dev10x != null && dev10x >= 1.45) cls = 'ARCH+'
167
+ else if (dev10x != null && dev10x >= 1.35) cls = 'ARCH'
168
+ else if (dev10x != null && dev10x >= 1.2) cls = 'POWER'
169
+ else if (dev10x != null && dev10x >= 1.0) cls = 'BASE'
170
+ else if (dev10x != null && dev10x >= 0) cls = 'SEEKER'
171
+ else if (dev10x != null && dev10x >= -0.3) cls = 'REFINER'
172
+
173
+ return { yield: yld, snr, leverage, velocity, dev10x, class: cls }
174
+ }
175
+
176
+ // ── Unicode bar chart (no dep) ───────────────────────────────────────────────
177
+ const BLOCKS = ' ▏▎▍▌▋▊▉█'
178
+
179
+ // Linear bar — use when all values are the same order of magnitude.
180
+ function barChart(values, labels, opts = {}) {
181
+ const { width = 30, colorFn = (s) => s, maxVal } = opts
182
+ const max = maxVal ?? Math.max(...values.filter(Number.isFinite), 1)
183
+ const lines = []
184
+ for (let i = 0; i < values.length; i++) {
185
+ const v = values[i] ?? 0
186
+ const pct = Math.min(v / max, 1)
187
+ const full = Math.floor(pct * width)
188
+ const frac = Math.floor((pct * width - full) * 8)
189
+ const bar = colorFn('█'.repeat(full) + (frac > 0 ? BLOCKS[frac] : ''))
190
+ const lbl = padEnd(dim(labels[i] ?? ''), 10)
191
+ const val = padStart(fmtTok(v), 8)
192
+ lines.push(` ${lbl} ${padEnd(bar, width)} ${val}`)
193
+ }
194
+ return lines
195
+ }
196
+
197
+ // Log-scale bar — use when values span multiple orders of magnitude (e.g. token pillars
198
+ // where cacheRead >> input). Maps log10(v) to bar width so each 10x = same visual step.
199
+ // minLog floor prevents zero/tiny values from going negative.
200
+ function logBar(v, maxLog, width = 40, colorCode = c.cyan) {
201
+ if (!v || v <= 0) return { bar: dim('·'.repeat(width)), pct: 0 }
202
+ const log = Math.log10(v)
203
+ const pct = Math.min(log / maxLog, 1)
204
+ const full = Math.floor(pct * width)
205
+ const frac = Math.floor((pct * width - full) * 8)
206
+ const bar = paint(colorCode, '█'.repeat(full) + (frac > 0 ? BLOCKS[frac] : ''))
207
+ return { bar, pct }
208
+ }
209
+
210
+ // ── Sparkline (no dep) ────────────────────────────────────────────────────────
211
+ const SPARK = '▁▂▃▄▅▆▇█'
212
+ function sparkline(values) {
213
+ const valid = values.filter(Number.isFinite)
214
+ if (valid.length === 0) return dim('no data')
215
+ const min = Math.min(...valid), max = Math.max(...valid)
216
+ return values.map(v => {
217
+ if (!Number.isFinite(v)) return dim('·')
218
+ const idx = max === min ? 7 : Math.round(((v - min) / (max - min)) * 7)
219
+ return SPARK[idx]
220
+ }).join('')
221
+ }
222
+
223
+ // ── Data sources (same as cli.mjs) ──────────────────────────────────────────
224
+ function ccusagePillars(platform = 'claude') {
225
+ try {
226
+ const cmd = platform === 'claude' ? 'ccusage claude daily --json' : `ccusage ${platform} daily --json`
227
+ const raw = execSync(cmd, { timeout: 15000, stdio: ['ignore', 'pipe', 'ignore'] }).toString()
228
+ const data = JSON.parse(raw)
229
+ const rows = data.daily ?? data
230
+ const now = Date.now()
231
+ const cutoff = { '7d': 7, '30d': 30, '90d': 90 }
232
+ const result = {}
233
+ for (const [win, days] of Object.entries(cutoff)) {
234
+ const since = new Date(now - days * 86400000)
235
+ let i = 0, o = 0, cw = 0, cr = 0
236
+ for (const row of rows) {
237
+ if (new Date(row.date ?? row.day ?? '1970') >= since) {
238
+ i += row.inputTokens ?? row.input_tokens ?? 0
239
+ o += row.outputTokens ?? row.output_tokens ?? 0
240
+ cw += row.cacheCreationTokens ?? row.cache_create_tokens ?? 0
241
+ cr += row.cacheReadTokens ?? row.cache_read_tokens ?? 0
242
+ }
243
+ }
244
+ result[win] = { input: i, output: o, cacheCreate: cw, cacheRead: cr }
245
+ }
246
+ let i = 0, o = 0, cw = 0, cr = 0
247
+ for (const row of rows) {
248
+ i += row.inputTokens ?? 0
249
+ o += row.outputTokens ?? 0
250
+ cw += row.cacheCreationTokens ?? 0
251
+ cr += row.cacheReadTokens ?? 0
252
+ }
253
+ result['all'] = { input: i, output: o, cacheCreate: cw, cacheRead: cr }
254
+ return result
255
+ } catch { return null }
256
+ }
257
+
258
+ function tokscalePillars(platform = 'claude') {
259
+ const p = path.join(os.homedir(), 'tokscale_report.json')
260
+ if (!existsSync(p)) return null
261
+ try {
262
+ const data = JSON.parse(readFileSync(p, 'utf8'))
263
+ const rows = (data.entries ?? []).filter(e =>
264
+ e.client === platform && e.model !== '<synthetic>' && e.model !== 'unknown' && (e.input > 0 || e.output > 0)
265
+ )
266
+ if (!rows.length) return null
267
+ const acc = rows.reduce((a, e) => ({
268
+ input: a.input + (e.input ?? 0), output: a.output + (e.output ?? 0),
269
+ cacheCreate: a.cacheCreate + (e.cacheWrite ?? 0), cacheRead: a.cacheRead + (e.cacheRead ?? 0),
270
+ }), { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 })
271
+ return { all: acc }
272
+ } catch { return null }
273
+ }
274
+
275
+ function tokenDashPillars() {
276
+ const dbPath = path.join(os.homedir(), '.claude', 'token-dashboard.db')
277
+ if (!existsSync(dbPath)) return null
278
+ try {
279
+ const raw = execSync(
280
+ `sqlite3 "${dbPath}" "SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m_tokens)+SUM(cache_create_1h_tokens),SUM(cache_read_tokens) FROM messages"`,
281
+ { timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] }
282
+ ).toString().trim()
283
+ const [i, o, cw, cr] = raw.split('|').map(Number)
284
+ return { all: { input: i || 0, output: o || 0, cacheCreate: cw || 0, cacheRead: cr || 0 } }
285
+ } catch { return null }
286
+ }
287
+
288
+ async function loadDashboardData() {
289
+ const { tokenpullAny } = await import('./tokenpull.mjs')
290
+ const ALL_PLATFORMS = [
291
+ 'claude','codex','amp','gemini','kimi','qwen','goose','kilo',
292
+ 'hermes','droid','codebuff','copilot','openclaw','pi',
293
+ ]
294
+ const boardPromise = Promise.race([
295
+ fetch(`${DEFAULT_API_BASE}/api/v1/leaderboard?window=30d&metric=yield_`, { headers: { accept: 'application/json' } })
296
+ .then(r => r.ok ? r.json() : null).catch(() => null),
297
+ new Promise(r => setTimeout(() => r(null), 5000)),
298
+ ])
299
+
300
+ // FIX 0: progressive load — render the primary platform (claude) FAST first,
301
+ // then fill the other 13 as they resolve. The caller passes an onFirst callback
302
+ // that fires when claude is ready so the user sees their cascade within ~1 read,
303
+ // not after a 14-platform barrier.
304
+ const [claudeResult] = await Promise.all([
305
+ tokenpullAny('claude').catch(() => null),
306
+ Promise.resolve(tokenDashPillars()),
307
+ ])
308
+
309
+ const active = []
310
+ if (claudeResult) {
311
+ const all = claudeResult.windows?.find(w => w.window === 'all')
312
+ if (all && (all.pillars.input ?? 0) + (all.pillars.output ?? 0) > 0) active.push(claudeResult)
313
+ }
314
+
315
+ const verifierMap = {}
316
+ for (const platform of ALL_PLATFORMS) {
317
+ verifierMap[platform] = { cc: ccusagePillars(platform), ts: tokscalePillars(platform) }
318
+ }
319
+
320
+ const boardData = await boardPromise
321
+ return { active, verifierMap, tdPillars: tokenDashPillars(), boardData, _loading: true, _remaining: ALL_PLATFORMS.slice(1) }
322
+ }
323
+
324
+ /** FIX 0: fill the remaining platforms after the primary render. Mutates dashData.active. */
325
+ async function fillDashboardRest(dashData) {
326
+ if (!dashData?._remaining) return false
327
+ const { tokenpullAny } = await import('./tokenpull.mjs')
328
+ const rest = await Promise.allSettled(dashData._remaining.map(p => tokenpullAny(p)))
329
+ for (const r of rest) {
330
+ if (r.status !== 'fulfilled') continue
331
+ const d = r.value
332
+ const all = d.windows?.find(w => w.window === 'all')
333
+ if (!all) continue
334
+ if ((all.pillars.input ?? 0) + (all.pillars.output ?? 0) === 0) continue
335
+ if (!dashData.active.find(a => a.platform === d.platform)) dashData.active.push(d)
336
+ }
337
+ // sort active by ALL_PLATFORMS order for stable display
338
+ const order = ['claude','codex','amp','gemini','kimi','qwen','goose','kilo','hermes','droid','codebuff','copilot','openclaw','pi']
339
+ dashData.active.sort((a, b) => order.indexOf(a.platform) - order.indexOf(b.platform))
340
+ dashData._loading = false
341
+ dashData._remaining = null
342
+ return true
343
+ }
344
+
345
+ async function loadCompareData(platform = 'claude') {
346
+ const tpData = await callTool('tokenpull', { platform }).catch(() => null)
347
+ const cc = ccusagePillars(platform)
348
+ const ts = tokscalePillars(platform)
349
+ const td = platform === 'claude' ? tokenDashPillars() : null
350
+ return { tpData, cc, ts, td, platform }
351
+ }
352
+
353
+ async function loadBoardData(window = '30d') {
354
+ const res = await fetch(`${DEFAULT_API_BASE}/api/v1/leaderboard?window=${window}&metric=yield_`, {
355
+ headers: { accept: 'application/json' }
356
+ })
357
+ if (!res.ok) return null
358
+ return res.json()
359
+ }
360
+
361
+ // ── TAB BAR ──────────────────────────────────────────────────────────────────
362
+ const TABS = [
363
+ { key: '1', label: 'Dashboard', short: 'D' },
364
+ { key: '2', label: 'Trends', short: 'T' }, // every metric across windows (sub-views: You/Platform/Field)
365
+ { key: '3', label: 'Compare', short: 'C' },
366
+ { key: '4', label: 'Board', short: 'B' },
367
+ { key: '5', label: 'Watch', short: 'W' }, // in-TUI landing panel; [Enter] launches the watcher
368
+ { key: '6', label: 'Connect', short: 'N' }, // sign in / switch device — the whole app is the TUI
369
+ ]
370
+
371
+ // Three Degrees of Leverage — reference values pulled from signalaf.com/wiki (2026-06-25).
372
+ // base = AI avg (modeled 7:2:1 baseline) · field = SigRank avg (wild field median) · top = best operator.
373
+ // STATIC for now; goes live (field/top computed from the board) once there's real user volume.
374
+ const TD = {
375
+ base: { yield: 1.57, snr: 0.33, vel: 0.50, lev: 3.2, d10: 0.50 },
376
+ field: { yield: 1.51, snr: 0.07, vel: 0.08, lev: 22.3, d10: 1.35 },
377
+ top: { yield: 745.40, snr: 0.63, vel: 1.70, lev: 438.6, d10: 2.64 },
378
+ }
379
+
380
+ function renderTabBar(activeIdx) {
381
+ const w = W()
382
+ const ts = new Date().toLocaleTimeString('en-US', { hour12: false })
383
+
384
+ // logo
385
+ const logo = ` ${gold('⊙ SigRank')}`
386
+ // tabs
387
+ const tabStr = TABS.map((t, i) => {
388
+ const lbl = ` ${t.key}:${t.label} `
389
+ return i === activeIdx
390
+ ? `${c.bgCyan}${c.boldCyan}${lbl}${c.reset}`
391
+ : `${c.bgDim}${c.dim}${lbl}${c.reset}`
392
+ }).join('')
393
+ // right side — version (single source of truth) · site · clock
394
+ const right = dim(`${gold('v' + VERSION)}${c.reset}${c.dim} · signalaf.com · ${ts}`)
395
+
396
+ const logoVis = stripAnsi(logo).length
397
+ const tabsVis = TABS.reduce((a, t) => a + t.label.length + 4, 0)
398
+ const rightVis = stripAnsi(right).length
399
+ const gap = Math.max(1, w - logoVis - tabsVis - rightVis - 2)
400
+
401
+ writeln(`${logo} ${tabStr}${' '.repeat(gap)}${right}`)
402
+ writeln(` ${hr()}`)
403
+ }
404
+
405
+ // ── GRAPHICS helpers ─────────────────────────────────────────────────────────
406
+
407
+ // Horizontal stacked bar: input | cacheW | cacheR | output
408
+ function tokenBar(p, width = 40) {
409
+ if (!p) return dim(' no data')
410
+ const total = (p.input ?? 0) + (p.output ?? 0) + (p.cacheCreate ?? 0) + (p.cacheRead ?? 0)
411
+ if (total === 0) return dim(' no data')
412
+ const seg = (val, colorCode) => {
413
+ const w = Math.round((val / total) * width)
414
+ return w > 0 ? paint(colorCode, '█'.repeat(w)) : ''
415
+ }
416
+ const bar = seg(p.input, c.cyan) + seg(p.cacheCreate, c.blue) + seg(p.cacheRead, c.boldGold) + seg(p.output, c.green)
417
+ return bar
418
+ }
419
+
420
+ // Generalized cascade-metric sparkline across windows (7d→30d→90d→all).
421
+ // pick: (cas) => number · fmt: (v) => string. Used by the Trends tab for every metric.
422
+ // FIX G1: window headers are rendered once at the top of each metric block (mirror
423
+ // renderCompare's column layout). metricSpark/yieldSpark now return sparkline + RIGHT-
424
+ // ALIGNED values in fixed COL_W columns — NO per-value `w:` prefix (the header row
425
+ // carries the window labels). Null windows render a dim `—` (columns stay aligned).
426
+ const TREND_COL_W = 10
427
+ const TREND_WINS = ['7d', '30d', '90d', 'all']
428
+
429
+ function trendHeader() {
430
+ return padEnd(dim(''), 12) + TREND_WINS.map(w => padStart(dim(w), TREND_COL_W)).join(' ')
431
+ }
432
+
433
+ function metricSpark(d, pick, fmt) {
434
+ const vals = TREND_WINS.map(w => {
435
+ const wd = d.windows?.find(x => x.window === w)
436
+ if (!wd) return null
437
+ const cas = cascadeFrom(wd.pillars)
438
+ return cas ? pick(cas, wd.pillars) : null // pick gets (cascade, pillars)
439
+ })
440
+ const cols = vals.map(v => v != null ? padStart(fmt(v), TREND_COL_W) : padStart(dim('—'), TREND_COL_W))
441
+ return sparkline(vals) + ` ${cols.join(' ')}`
442
+ }
443
+
444
+ // Yield sparkline across windows
445
+ function yieldSpark(d) {
446
+ const vals = TREND_WINS.map(w => {
447
+ const wd = d.windows?.find(x => x.window === w)
448
+ if (!wd) return null
449
+ const cas = cascadeFrom(wd.pillars)
450
+ return cas?.yield ?? null
451
+ })
452
+ const cols = vals.map(v => v != null ? padStart(fmtY(v), TREND_COL_W) : padStart(dim('—'), TREND_COL_W))
453
+ return sparkline(vals) + ` ${cols.join(' ')}`
454
+ }
455
+
456
+ // ── TAB 1: DASHBOARD ─────────────────────────────────────────────────────────
457
+ function renderDashboard(data, status = '') {
458
+ const { active, verifierMap, tdPillars, boardData } = data
459
+ const w = W()
460
+ const budget = H() - 4 // 2 tab bar + 2 footer
461
+ const WINS = ['7d', '30d', '90d', 'all']
462
+ let used = 0
463
+ const emit = (s = '') => { if (used < budget) { writeln(s); used++ } }
464
+
465
+ // Responsive: the full 12-col table is ~124 wide. On terminals narrower than
466
+ // that, drop the two derived columns (Vel, 10x) and tighten the inter-column
467
+ // gap from 2 spaces to 1, so the core pillars + Υ/SNR/Lev/Class still fit.
468
+ const narrow = w < 124
469
+ const gap = narrow ? ' ' : ' '
470
+ const renderRow = (label, colorFn, winKey, p, est = false) => {
471
+ const cas = cascadeFrom(p)
472
+ if (!cas) return
473
+ const clsFn = CLS[cas.class] ?? ((s) => s)
474
+ const cols = [
475
+ padEnd(colorFn(label), 12), padEnd(dim(winKey), 5),
476
+ padStart(est ? dim('~')+fmtTok(p.input) : fmtTok(p.input), 8),
477
+ padStart(fmtTok(p.output), 8),
478
+ padStart((p.cacheCreate??0) > 0 ? (est ? dim('~')+fmtTok(p.cacheCreate) : fmtTok(p.cacheCreate)) : dim('—'), 8),
479
+ padStart((p.cacheRead??0) > 0 ? fmtTok(p.cacheRead) : dim('—'), 9),
480
+ padStart(cas.yield > 10000 ? gold(fmtY(cas.yield)) : fmtY(cas.yield), 9),
481
+ padStart(fmtSNR(cas.snr), 7),
482
+ padStart(fmtLev(cas.leverage)+'×', 7),
483
+ ...(narrow ? [] : [
484
+ padStart(cas.velocity?.toFixed(2) ?? '—', 6),
485
+ padStart(cas.dev10x?.toFixed(2) ?? '—', 6),
486
+ ]),
487
+ padEnd(clsFn(cas.class), 13),
488
+ ]
489
+ emit(` ${cols.join(gap)}`)
490
+ }
491
+
492
+ // ── Cascade table
493
+ emit()
494
+ emit(` ${bold('Your Cascade')} ${dim('all platforms · all windows')}`)
495
+ emit()
496
+
497
+ // header (matches renderRow's responsive column set + gap)
498
+ const CH = [
499
+ padEnd(hdr('Platform'), 12), padEnd(hdr('Win'), 5),
500
+ padStart(hdr('Input'), 8), padStart(hdr('Output'), 8),
501
+ padStart(hdr('CacheW'), 8), padStart(hdr('CacheR'), 9),
502
+ padStart(hdr('Υ Yield'), 9), padStart(hdr('SNR'), 7),
503
+ padStart(hdr('Lev'), 7),
504
+ ...(narrow ? [] : [padStart(hdr('Vel'), 6), padStart(hdr('10x'), 6)]),
505
+ padEnd(hdr('Class'), 13),
506
+ ]
507
+ emit(` ${CH.join(gap)}`)
508
+ emit(` ${dim('·'.repeat(Math.max(0, Math.min(w - 4, narrow ? 96 : 114))))}`)
509
+
510
+ if (active.length === 0) {
511
+ emit(` ${dim(' reading token logs… (14 platforms, ~5s · press [R] to refresh)')}`)
512
+ }
513
+
514
+ // Calculate how many cascade rows we can fit — reserve space for lower sections.
515
+ // (Υ-trend section moved to the Trends tab; its old `sparkLines` reservation is gone,
516
+ // which is what was starving codex's cascade rows after the Your-Read/Three-Degrees panels landed.)
517
+ const platformCount = active.length
518
+ const barLines = 4 + platformCount + (platformCount > 1 ? 1 : 0) // Token Composition: hr + header + platforms + combined + note
519
+ // Your Read (hr+header+~3 wins+1 gap = 6) + Three Degrees (blank+hr+header+col-header+5 rows+note = 10) + status
520
+ const boardLines = 17
521
+ const sectionsBelow = barLines + boardLines
522
+ const maxCascadeRows = Math.max(8, budget - used - sectionsBelow)
523
+
524
+ let firstWin = {}
525
+ let cascadeRowCount = 0
526
+ for (const d of active) {
527
+ firstWin[d.platform] = WINS.find(wk => {
528
+ const wd = d.windows?.find(w => w.window === wk)
529
+ return wd && (wd.pillars.input + wd.pillars.output) > 0
530
+ })
531
+ for (const wk of WINS) {
532
+ if (cascadeRowCount >= maxCascadeRows || used >= budget - sectionsBelow) break
533
+ const wd = d.windows?.find(x => x.window === wk)
534
+ if (!wd || (wd.pillars.input + wd.pillars.output) === 0) continue
535
+ renderRow(d.platform, (s) => wk === firstWin[d.platform] ? cyan(s) : dim(s), wk, wd.pillars, d.estimated)
536
+ cascadeRowCount++
537
+ }
538
+ emit()
539
+ }
540
+
541
+ // combined
542
+ if (active.length > 1 && used < budget - sectionsBelow) {
543
+ const lbl = active.map(d => d.platform).join('+')
544
+ const hasEst = active.some(d => d.estimated)
545
+ let fst = true
546
+ for (const wk of WINS) {
547
+ const cp = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 }
548
+ let any = false
549
+ for (const d of active) {
550
+ const wd = d.windows?.find(x => x.window === wk)
551
+ if (!wd || (wd.pillars.input + wd.pillars.output) === 0) continue
552
+ cp.input += wd.pillars.input ?? 0; cp.output += wd.pillars.output ?? 0
553
+ cp.cacheCreate += wd.pillars.cacheCreate ?? 0; cp.cacheRead += wd.pillars.cacheRead ?? 0
554
+ any = true
555
+ }
556
+ if (!any) continue
557
+ renderRow(lbl, (s) => fst ? bold(cyan(s)) : dim(s), wk, cp, hasEst)
558
+ fst = false
559
+ }
560
+ emit()
561
+ }
562
+
563
+ // (Υ Trend moved to the dedicated Trends tab [2] — every metric, with sub-views.)
564
+
565
+ // ── Token bar charts (skip if no room)
566
+ if (used < budget - boardLines) {
567
+ emit(` ${hr()}`)
568
+ emit(` ${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')}`)
569
+ for (const d of active) {
570
+ if (used >= budget - boardLines) break
571
+ const all = d.windows?.find(w => w.window === 'all')
572
+ if (!all) continue
573
+ emit(` ${padEnd(cyan(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)))}`)
574
+ }
575
+ if (active.length > 1) {
576
+ const cp = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 }
577
+ for (const d of active) {
578
+ const all = d.windows?.find(w => w.window === 'all')
579
+ if (!all) continue
580
+ cp.input += all.pillars.input??0; cp.output += all.pillars.output??0
581
+ cp.cacheCreate += all.pillars.cacheCreate??0; cp.cacheRead += all.pillars.cacheRead??0
582
+ }
583
+ emit(` ${padEnd(bold(active.map(d => d.platform).join('+')), 10)} ${tokenBar(cp, 50)} ${dim(fmtTok(cp.input+cp.output+cp.cacheCreate+cp.cacheRead))}`)
584
+ }
585
+ }
586
+
587
+ // ── Your insights + the Three Degrees panel (replaces the old top-3 mini board)
588
+ // The user's own cascade (the firstWin/combined row computed above) read against the
589
+ // Three Degrees reference so the Dashboard says "here's you, here's the field, here's the ceiling."
590
+ const you = (() => {
591
+ // Best available cascade for the user: prefer claude's first-window, else any computed row.
592
+ for (const d of active) {
593
+ const wk = firstWin[d.platform]
594
+ const wd = d.windows?.find((x) => x.window === wk)
595
+ const cas = wd && cascadeFrom(wd.pillars)
596
+ if (cas) return cas // cascadeFrom already returns null for non-compounding rows
597
+ }
598
+ return null
599
+ })()
600
+
601
+ if (used < budget - 8) {
602
+ // ── Custom insights — comparison vs the field + a concrete tip for the weakest metric
603
+ emit(` ${hr()}`)
604
+ emit(` ${bold('Your Read')} ${dim('how your cascade sits vs the field')}`)
605
+ if (you) {
606
+ const wins = [], gaps = []
607
+ const chk = (label, val, fieldAvg, top, fmt, tip) => {
608
+ if (val == null) return
609
+ if (val >= top * 0.9) wins.push(`${label} ${fmt(val)} — ${gold('top tier')}`)
610
+ else if (val >= fieldAvg) wins.push(`${label} ${fmt(val)} — above field avg`)
611
+ else gaps.push({ label, txt: `${label} ${fmt(val)} — below field (${fmt(fieldAvg)}): ${tip}`, val, fieldAvg })
612
+ }
613
+ chk('Υ Yield', you.yield, TD.field.yield, TD.top.yield, fmtY, 'compound more — reuse cache, raise output')
614
+ chk('SNR', you.snr, TD.field.snr, TD.top.snr, fmtSNR, 'tighten prompts — less input per unit output')
615
+ chk('Leverage', you.leverage, TD.field.lev, TD.top.lev, (v) => fmtLev(v) + '×', 'lean on cache-read — amplify prior context')
616
+ chk('Velocity', you.velocity, TD.field.vel, TD.top.vel, (v) => v.toFixed(2), 'more output per input token')
617
+ for (const w of wins.slice(0, 3)) emit(` ${paint(c.green, '▲')} ${w}`)
618
+ // weakest gap gets the prescriptive tip
619
+ const weakest = gaps.sort((a, b) => (a.val / a.fieldAvg) - (b.val / b.fieldAvg))[0]
620
+ if (weakest) emit(` ${paint(c.magenta, '▽')} ${dim(weakest.txt)}`)
621
+ if (!wins.length && !weakest) emit(` ${dim(' building your read…')}`)
622
+ } else {
623
+ emit(` ${dim(' reading your cascade… (press [R] to refresh)')}`)
624
+ }
625
+
626
+ // ── Three Degrees — AI avg · SigRank avg · Top (live values from signalaf.com)
627
+ emit()
628
+ emit(` ${hr()}`)
629
+ emit(` ${bold('Three Degrees')} ${dim('AI avg · SigRank avg · top — signalaf.com/wiki')}`)
630
+ const tdHead = [padEnd(hdr('Metric'), 10), padStart(hdr('AI avg'), 9), padStart(hdr('SigRank avg'), 12), padStart(hdr('Top'), 9)]
631
+ emit(` ${tdHead.join(' ')}`)
632
+ const white = (s) => paint(c.white, s)
633
+ const tdRow = (label, b, f, t, fmt) =>
634
+ emit(` ${padEnd(label, 10)} ${padStart(white(fmt(b)), 9)} ${padStart(fmt(f), 12)} ${padStart(gold(fmt(t)), 9)}`)
635
+ tdRow('Υ Yield', TD.base.yield, TD.field.yield, TD.top.yield, fmtY)
636
+ tdRow('SNR', TD.base.snr, TD.field.snr, TD.top.snr, fmtSNR)
637
+ tdRow('Velocity', TD.base.vel, TD.field.vel, TD.top.vel, (v) => v.toFixed(2))
638
+ tdRow('Leverage', TD.base.lev, TD.field.lev, TD.top.lev, (v) => fmtLev(v) + '×')
639
+ tdRow('10xDEV', TD.base.d10, TD.field.d10, TD.top.d10, (v) => v.toFixed(2))
640
+ emit(` ${dim('reference values until live user volume calibrates SigRank avg')}`)
641
+ }
642
+
643
+ if (status && used < budget) emit(` ${dim(status)}`)
644
+ }
645
+
646
+ // ── TAB 2: TRENDS ─────────────────────────────────────────────────────────────
647
+ // Every cascade metric (+ rank) as a 7d→30d→90d→all sparkline. Three sub-tabs:
648
+ // You — your own trajectory (+ rank trend, "calculating" until wired)
649
+ // Platform — per-platform (claude / codex / combined)
650
+ // Field — board-wide trend (calculating until window-history materializes)
651
+ // Switch sub-tabs with [T] (cycles You · Platform · Field). Data: the same `active` the Dashboard loads.
652
+ const TREND_SUBTABS = ['You', 'Platform', 'Field']
653
+ // All leaderboard metrics. Most derive from the per-window cascade (pick takes the
654
+ // cascade obj + its pillars); §IGNA (proprietary composite) and $/1M (needs cost data)
655
+ // aren't computed client-side yet → rendered "calculating", same honest pattern as Rank.
656
+ const AA_EFF_BASELINE = 4.0 // efficiency is measured vs the AA 4.0 baseline (per the wiki)
657
+ const TREND_METRICS = [
658
+ { label: '∑ Total', pick: (c, p) => (p.input ?? 0) + (p.output ?? 0) + (p.cacheCreate ?? 0) + (p.cacheRead ?? 0), fmt: fmtTok },
659
+ { label: 'Υ Yield', pick: (c) => c.yield, fmt: fmtY },
660
+ { label: 'SNR', pick: (c) => c.snr, fmt: fmtSNR },
661
+ { label: 'Velocity', pick: (c) => c.velocity, fmt: (v) => v.toFixed(2) },
662
+ { label: 'Leverage', pick: (c) => c.leverage, fmt: (v) => fmtLev(v) + '×' },
663
+ { label: '10xDEV', pick: (c) => c.dev10x, fmt: (v) => v.toFixed(2) },
664
+ { label: 'Eff', pick: (c) => c.yield != null ? c.yield / AA_EFF_BASELINE : null, fmt: (v) => v.toFixed(1) + '×' },
665
+ { label: '§IGNA', pick: () => null, fmt: () => '—', stub: 'composite calibrating (needs user volume)' },
666
+ { label: 'Op Ratio', pick: (c, p) => (p.input ?? 0) > 0 ? { cacheRead: p.cacheRead ?? 0, input: p.input ?? 0, output: p.output ?? 0 } : null, fmt: (v) => `${fmtTok(v.cacheRead)}:${fmtTok(v.input)}:${fmtTok(v.output)}` },
667
+ { label: '$/1M', pick: () => null, fmt: () => '—', stub: 'cost data wires post-ingest' },
668
+ ]
669
+ function renderTrends(data, subIdx = 0) {
670
+ const { active } = data
671
+ const budget = H() - 4
672
+ let used = 0
673
+ const emit = (s = '') => { if (used < budget) { writeln(s); used++ } }
674
+ const sub = TREND_SUBTABS[subIdx] ?? 'You'
675
+
676
+ // sub-tab switcher row
677
+ const subBar = TREND_SUBTABS.map((t, i) =>
678
+ i === subIdx ? `${c.bgCyan}${c.boldCyan} ${t} ${c.reset}` : `${dim(` ${t} `)}`).join(' ')
679
+ emit()
680
+ emit(` ${bold('Trends')} ${dim('every metric across windows (7d → all)')} ${subBar}`)
681
+ emit(` ${dim('·'.repeat(Math.min(W() - 4, 70)))}`)
682
+ emit()
683
+
684
+ if (sub === 'You' || sub === 'Platform') {
685
+ const rows = sub === 'You'
686
+ ? (active.length ? [{ platform: 'you', windows: active[0]?.windows }] : []) // primary cascade
687
+ : active
688
+ if (!rows.length) { emit(` ${dim(' reading your cascade… (press [R] to refresh)')}`); }
689
+ for (const d of rows) {
690
+ if (used >= budget - 2) break
691
+ emit(` ${bold(sub === 'You' ? 'Your trajectory' : cyan(d.platform))}`)
692
+ // FIX G1: window column headers (mirror renderCompare) — one header row, then
693
+ // values in fixed-width columns below. No per-value `w:` prefix.
694
+ emit(` ${trendHeader()}`)
695
+ for (const m of TREND_METRICS) {
696
+ if (used >= budget - 2) break
697
+ if (m.stub) {
698
+ emit(` ${padEnd(dim(m.label), 10)} ${dim('▁▁▁▁ calculating… (' + m.stub + ')')}`)
699
+ } else {
700
+ emit(` ${padEnd(cyan(m.label), 10)} ${metricSpark(d, m.pick, m.fmt)}`)
701
+ }
702
+ }
703
+ // rank trend — wired later; honest placeholder for now
704
+ if (used < budget - 1) emit(` ${padEnd(dim('Rank'), 10)} ${dim('▁▁▁▁ calculating… (rank history wires post-ingest)')}`)
705
+ emit()
706
+ }
707
+ } else { // Field
708
+ emit(` ${dim(' Field-wide trends — calculating…')}`)
709
+ emit(` ${dim(' (needs per-window board history; materializes once live ingest lands)')}`)
710
+ }
711
+ }
712
+
713
+ // ── TAB 3: COMPARE ───────────────────────────────────────────────────────────
714
+ function renderCompare(data) {
715
+ const { tpData, cc, ts, td, platform } = data
716
+ const WINS = ['7d', '30d', '90d', 'all']
717
+ const w = W()
718
+ const budget = H() - 4
719
+ let used = 0
720
+ const emit = (s = '') => { if (used < budget) { writeln(s); used++ } }
721
+
722
+ const tpPillars = {}
723
+ for (const win of (tpData?.windows ?? [])) tpPillars[win.window] = win.pillars
724
+
725
+ const SOURCES = [
726
+ { name: 'tokenpull', color: (s) => paint(c.boldCyan, s), pillars: tpPillars, note: 'JSONL canon' },
727
+ { name: 'ccusage', color: (s) => paint(c.green, s), pillars: cc ?? {}, note: 'ccusage CLI' },
728
+ { name: 'token-dash', color: (s) => paint(c.magenta, s), pillars: td ?? {}, note: 'SQLite' },
729
+ { name: 'tokscale', color: (s) => paint(c.blue, s), pillars: ts ?? {}, note: 'report.json' },
730
+ ].filter(s => Object.keys(s.pillars).length > 0)
731
+
732
+ emit()
733
+ emit(` ${bold('Source Comparison')} ${dim(`platform: ${platform}`)} ${dim('· tokenpull vs ccusage vs token-dash vs tokscale')}`)
734
+
735
+ if (!tpData) {
736
+ emit(` ${dim('tokenpull: no JSONL data found — check ~/.claude/projects/')}`)
737
+ } else {
738
+ const all = tpPillars['all']
739
+ if (all) {
740
+ emit(` ${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))}`)
741
+ }
742
+ }
743
+ emit()
744
+
745
+ const COL_W = 11
746
+ const hcols = [padEnd(dim('Source'), 12), padEnd(dim('Pillar'), 10), ...WINS.map(wn => padStart(dim(wn), COL_W))]
747
+ emit(` ${hcols.join(' ')}`)
748
+ emit(` ${dim('·'.repeat(Math.min(w-4, 12 + 12 + WINS.length*(COL_W+2))))}`)
749
+
750
+ const PILLARS = [
751
+ { key: 'input', label: 'Input' },
752
+ { key: 'output', label: 'Output' },
753
+ { key: 'cacheWrite', label: 'CacheW', dbKey: 'cacheCreate' },
754
+ { key: 'cacheRead', label: 'CacheR' },
755
+ ]
756
+
757
+ // Reserve space for cascade metrics below (~8 lines)
758
+ const metricsLines = 6 + SOURCES.length
759
+ for (const src of SOURCES) {
760
+ let firstRow = true
761
+ for (const pil of PILLARS) {
762
+ if (used >= budget - metricsLines) break
763
+ const dbKey = pil.dbKey ?? pil.key
764
+ const cells = WINS.map(win => {
765
+ const p = src.pillars[win]
766
+ const val = p?.[dbKey] ?? null
767
+ if (val == null) return padStart(dim('—'), COL_W)
768
+ const baseVal = tpPillars[win]?.[dbKey]
769
+ if (src.name !== 'tokenpull' && baseVal != null && baseVal > 0) {
770
+ const delta = (val - baseVal) / baseVal * 100
771
+ const dStr = delta === 0 ? '' : delta > 0 ? green(` +${delta.toFixed(0)}%`) : red(` ${delta.toFixed(0)}%`)
772
+ return padStart(`${fmtTok(val)}${dStr}`, COL_W)
773
+ }
774
+ return padStart(fmtTok(val), COL_W)
775
+ })
776
+ const srcLabel = firstRow ? padEnd(src.color(src.name), 12) : padEnd(dim(''), 12)
777
+ emit(` ${srcLabel} ${padEnd(dim(pil.label), 10)} ${cells.join(' ')}`)
778
+ firstRow = false
779
+ }
780
+ emit()
781
+ }
782
+
783
+ if (used < budget - 4) {
784
+ emit(` ${hr()}`)
785
+ emit(` ${bold('Cascade Metrics')} ${dim('all-time · computed from each source')}`)
786
+ const MCH = [padEnd(hdr('Source'),12), padStart(hdr('Υ Yield'),9), padStart(hdr('SNR'),7), padStart(hdr('Lev'),8), padStart(hdr('Vel'),6), padStart(hdr('10x'),6), padEnd(hdr('Class'),13)]
787
+ emit(` ${MCH.join(' ')}`)
788
+ emit(` ${dim('·'.repeat(Math.min(w-4, 72)))}`)
789
+ for (const src of SOURCES) {
790
+ if (used >= budget) break
791
+ const p = src.pillars['all']
792
+ if (!p) continue
793
+ const cas = cascadeFrom(p)
794
+ if (!cas) continue
795
+ const clsFn = CLS[cas.class] ?? ((s) => s)
796
+ const cols = [
797
+ padEnd(src.color(src.name), 12),
798
+ padStart(cas.yield > 10000 ? gold(fmtY(cas.yield)) : fmtY(cas.yield), 9),
799
+ padStart(fmtSNR(cas.snr), 7),
800
+ padStart(fmtLev(cas.leverage)+'×', 8),
801
+ padStart(cas.velocity?.toFixed(2) ?? '—', 6),
802
+ padStart(cas.dev10x?.toFixed(2) ?? '—', 6),
803
+ padEnd(clsFn(cas.class), 13),
804
+ ]
805
+ emit(` ${cols.join(' ')}`)
806
+ }
807
+ }
808
+ }
809
+
810
+ // ── TAB 3: BOARD ─────────────────────────────────────────────────────────────
811
+ // FIX I2: hybrid board model. `filterCodename` (non-null = "just me" mode) shows
812
+ // ONLY the signed-in operator's rows. `highlightCodename` (non-null = global mode)
813
+ // highlights the signed-in operator's row in the global board. Both come from the
814
+ // signed-in identity's codename; the [Y] key toggles between the two modes.
815
+ function renderBoard(boardData, window = '30d', filterCodename = null, highlightCodename = null) {
816
+ const entries = boardData?.entries ?? boardData?.operators ?? boardData ?? []
817
+ const w = W()
818
+ const budget = H() - 4
819
+ let used = 0
820
+ const emit = (s = '') => { if (used < budget) { writeln(s); used++ } }
821
+
822
+ const modeLabel = filterCodename ? `${dim(' · just you')}` : highlightCodename ? `${dim(' · you highlighted')}` : ''
823
+ emit()
824
+ emit(` ${bold('Leaderboard')} ${dim(`window: ${window} · sorted by Υ Yield · signalaf.com/leaderboard`)}${modeLabel}`)
825
+ emit()
826
+
827
+ if (!Array.isArray(entries) || entries.length === 0) {
828
+ emit(` ${dim(' board unavailable')}`)
829
+ return
830
+ }
831
+
832
+ // FIX I2: "just me" mode — filter to the signed-in operator's rows only.
833
+ let sorted = [...entries].sort((a, b) => (b.yield_ ?? 0) - (a.yield_ ?? 0))
834
+ if (filterCodename) {
835
+ sorted = sorted.filter(e => (e.codename ?? '').toLowerCase() === filterCodename.toLowerCase())
836
+ if (sorted.length === 0) {
837
+ emit(` ${dim(` you have no ranked rows in the ${window} window yet`)}`)
838
+ emit(` ${dim(' press [S] from a read tab to submit your cascade')}`)
839
+ return
840
+ }
841
+ }
842
+
843
+ // Per-column top-3 "shadowbox": medal bg-tint (gold/silver/bronze) on the 1st/2nd/3rd
844
+ // best DISTINCT value in each metric column, mirroring the website's podium boxes.
845
+ const MEDAL_BG = { 1: 220, 2: 250, 3: 130 } // gold · silver · bronze (256-color)
846
+ const top3 = (pick) => {
847
+ const distinct = [...new Set(sorted.map(pick).filter((v) => v != null && isFinite(v)))].sort((a, b) => b - a)
848
+ const m = new Map(); distinct.slice(0, 3).forEach((v, i) => m.set(v, i + 1)); return m
849
+ }
850
+ const pods = {
851
+ yield: top3((e) => e.yield_), snr: top3((e) => e.snr ?? e.compression_ratio),
852
+ lev: top3((e) => e.leverage), vel: top3((e) => e.velocity), d10: top3((e) => e.dev10x),
853
+ }
854
+ // Wrap a padded cell in a medal bg-tint (dark fg on the medal color) when its value places top-3.
855
+ const medal = (map, val, padded) => {
856
+ const place = val == null ? null : map.get(val)
857
+ if (!place) return padded
858
+ return `${ESC}48;5;${MEDAL_BG[place]}m${ESC}38;5;232m${padded}${c.reset}`
859
+ }
860
+
861
+ const BH = [
862
+ padStart(hdr('#'), 4), padEnd(hdr('Codename'), 22), padEnd(hdr('Class'), 13),
863
+ padStart(hdr('Υ Yield'), 9), padStart(hdr('SNR'), 7), padStart(hdr('Lev'), 7),
864
+ padStart(hdr('Vel'), 6), padStart(hdr('10x'), 6), padStart(hdr('Pct'), 5), padStart(hdr('7d↕'), 5),
865
+ ]
866
+ emit(` ${BH.join(' ')}`)
867
+ emit(` ${dim('·'.repeat(Math.min(w-4, 98)))}`)
868
+
869
+ for (let idx = 0; idx < sorted.length; idx++) {
870
+ if (used >= budget) break
871
+ const e = sorted[idx]
872
+ // FIX I2: highlight the signed-in operator's row (global mode) with a cyan bg-tint.
873
+ const isYou = highlightCodename && (e.codename ?? '').toLowerCase() === highlightCodename.toLowerCase()
874
+ const rk = idx === 0 ? gold(`#${idx+1}`) : idx < 3 ? cyan(`#${idx+1}`) : `#${idx+1}`
875
+ const nmRaw = trunc(e.codename ?? '—', 22)
876
+ // FIX I2: pad the visible content INSIDE the ANSI codes so the cyan bg fills
877
+ // the full column width (padEnd after reset would leave uncolored trailing spaces).
878
+ const nm = isYou
879
+ ? `${c.bgCyan}${c.boldCyan}${padEnd(` ${nmRaw} `, 22)}${c.reset}`
880
+ : padEnd(nmRaw, 22)
881
+ const cls = padEnd(colorCls(e.class_tier ?? '—'), 13)
882
+ const snrVal = e.snr ?? e.compression_ratio
883
+ // value cells: pad first, then medal-wrap so the tint fills the whole column width
884
+ const yld = medal(pods.yield, e.yield_, padStart(e.yield_ != null ? fmtY(e.yield_) : '—', 9))
885
+ const snr = medal(pods.snr, snrVal, padStart(snrVal != null ? fmtSNR(snrVal) : '—', 7))
886
+ const lev = medal(pods.lev, e.leverage, padStart(e.leverage != null ? fmtLev(e.leverage)+'×' : '—', 7))
887
+ const vel = medal(pods.vel, e.velocity, padStart(e.velocity != null ? e.velocity.toFixed(2) : '—', 6))
888
+ const d10 = medal(pods.d10, e.dev10x, padStart(e.dev10x != null ? e.dev10x.toFixed(2) : '—', 6))
889
+ const pct = padStart(e.percentile != null ? `${e.percentile}%` : '—', 5)
890
+ const mv = padStart(fmtMov(e.movement_7d), 5)
891
+ const youMark = isYou ? ` ${c.bgCyan}${c.boldCyan}YOU${c.reset}` : ''
892
+ emit(` ${padStart(rk,4)} ${nm} ${cls} ${yld} ${snr} ${lev} ${vel} ${d10} ${pct} ${mv}${youMark}`)
893
+ }
894
+ }
895
+
896
+ // ── TAB 4: WATCH — landing panel (instructions + why it matters) ─────────────
897
+ // Explains what the live watcher does and why it's the point of the agent: it
898
+ // re-reads your local logs on an interval and feeds your verified cascade to the
899
+ // leaderboard. Launched (in its own window) with [Enter]; interval is tunable.
900
+ function renderWatchInfo(platform, win, refresh) {
901
+ writeln()
902
+ writeln(` ${bold('Live Watch')} ${dim('the agent that keeps your rank current')}`)
903
+ writeln()
904
+ writeln(` ${dim('What it does')}`)
905
+ writeln(` Re-reads your local ${cyan(platform)} token logs every ${gold(refresh + 's')} and recomputes`)
906
+ writeln(` your cascade (Υ Yield · SNR · Leverage · class) live, on this machine.`)
907
+ writeln()
908
+ writeln(` ${dim('Why it matters')}`)
909
+ writeln(` Watch is how the board stays ${bold('current')}: each refresh submits your latest`)
910
+ writeln(` verified cascade so ${cyan('signalaf.com/leaderboard')} ${bold('auto-updates')} as you work —`)
911
+ writeln(` no manual re-submit. Tokens never leave your machine; only the metrics post.`)
912
+ writeln()
913
+ writeln(` ${dim('Settings')}`)
914
+ writeln(` ${dim('Platform')} ${cyan(platform)} ${dim('Window')} ${cyan(win)} ${dim('Refresh')} ${gold(refresh + 's')}`)
915
+ writeln()
916
+ writeln(` ${dim('[Enter]')} launch watcher (new window) ${dim('[+]/[-]')} refresh ±5s ${dim('[P]')} platform ${dim('[W]')} window`)
917
+ writeln()
918
+ }
919
+
920
+ // ── TAB 6: CONNECT ───────────────────────────────────────────────────────────
921
+ // Sign in (paste a connect code) / show signed-in status. The whole app lives in
922
+ // the TUI; this replaces every "go run the CLI to enroll" hint. Uses only helpers
923
+ // already defined above (bold/dim/cyan/green/red/hr/writeln).
924
+ function renderConnect(id, codeBuf = '', msg = '') {
925
+ writeln()
926
+ if (isSignedIn(id)) {
927
+ writeln(` ${bold('Connect')} ${dim('your account')}`)
928
+ writeln(` ${hr()}`)
929
+ writeln(` ${green('✓')} Signed in as ${cyan(id.codename)}`)
930
+ writeln(` ${dim(`device ${id.device_id}`)}`)
931
+ writeln()
932
+ writeln(` ${dim('Press')} ${bold('[S]')} ${dim('from any read tab to submit your runs to the board.')}`)
933
+ writeln()
934
+ writeln(` ${dim('Need a new key? Click "New key" at signalaf.com → Settings, then paste it here.')}`)
935
+ writeln(` ${dim('Signed in on the wrong device, or want a fresh start?')} ${bold('[X]')} ${dim('signs out — next paste provisions a fresh device.')}`)
936
+ } else {
937
+ writeln(` ${bold('Log in to submit to board')}`)
938
+ writeln(` ${hr()}`)
939
+ writeln(` ${dim('status:')} ${red('not signed in')}`)
940
+ writeln()
941
+ writeln(` ${dim('Paste your key, then')} ${bold('[Enter]')}${dim(':')}`)
942
+ }
943
+ writeln(` ${cyan('>')} ${codeBuf}${dim('▏')}`)
944
+ writeln()
945
+ writeln(` ${dim('Get a key at signalaf.com → Settings → "New key" (or "Generate connect code").')}`)
946
+ if (msg) { writeln(); writeln(` ${msg}`) }
947
+ }
948
+
949
+ // ── TAB 4: WATCH ─────────────────────────────────────────────────────────────
950
+ async function renderWatch(platform = 'claude', win = '7d') {
951
+ const { tokenpullAny } = await import('./tokenpull.mjs')
952
+ const d = await tokenpullAny(platform).catch(() => null)
953
+ if (!d) { writeln(` ${dim(`no data for ${platform}`)}`); return }
954
+
955
+ const wdata = d.windows?.find(x => x.window === win)
956
+ if (!wdata) { writeln(` ${dim(`no data for ${win} window`)}`); return }
957
+
958
+ const p = wdata.pillars
959
+ const cas = cascadeFrom(p)
960
+ const w = W()
961
+
962
+ writeln()
963
+ writeln(` ${bold('Live Watch')} ${dim(`${platform} · ${win} window · ${d.files} files · ${wdata.messages?.toLocaleString()} msgs`)}`)
964
+ writeln()
965
+
966
+ if (!cas) { writeln(` ${dim(' insufficient data to compute cascade')}`); return }
967
+
968
+ const clsFn = CLS[cas.class] ?? ((s) => s)
969
+
970
+ // big numbers
971
+ const metrics = [
972
+ { label: 'Υ Yield', val: fmtY(cas.yield), color: cas.yield > 1000 ? c.boldGold : c.boldCyan },
973
+ { label: 'Class', val: cas.class, color: CLS[cas.class] ? c.boldGold : c.reset },
974
+ { label: 'SNR', val: fmtSNR(cas.snr), color: c.green },
975
+ { label: 'Leverage', val: fmtLev(cas.leverage)+'×', color: c.cyan },
976
+ { label: 'Velocity', val: cas.velocity?.toFixed(2), color: c.white },
977
+ { label: '10xDEV', val: cas.dev10x?.toFixed(2), color: c.magenta },
978
+ ]
979
+
980
+ for (const m of metrics) {
981
+ writeln(` ${padEnd(dim(m.label), 12)} ${paint(m.color, m.val ?? '—')}`)
982
+ }
983
+ writeln()
984
+
985
+ // token pillars
986
+ writeln(` ${hr()}`)
987
+ writeln()
988
+ writeln(` ${bold('Token Pillars')} ${dim(`${win} window`)}`)
989
+ writeln()
990
+
991
+ const pillarsData = [
992
+ { label: 'Input', val: p.input, color: c.cyan, est: d.estimated },
993
+ { label: 'Output', val: p.output, color: c.green, est: false },
994
+ { label: 'Cache Write', val: p.cacheCreate, color: c.blue, est: d.estimated },
995
+ { label: 'Cache Read', val: p.cacheRead, color: c.boldGold,est: false },
996
+ ]
997
+ // Log scale: token pillars span 2–3 orders of magnitude (input ~8M, cacheRead ~1.9B).
998
+ // Linear scale crushes input/output bars to near-invisible. Log10 gives equal visual
999
+ // weight per order-of-magnitude step.
1000
+ const maxLog = Math.log10(Math.max(...pillarsData.map(x => x.val ?? 0), 10))
1001
+ for (const item of pillarsData) {
1002
+ const v = item.val ?? 0
1003
+ const { bar } = logBar(v, maxLog, 40, item.color)
1004
+ const est = item.est ? dim('~') : ''
1005
+ writeln(` ${padEnd(dim(item.label), 12)} ${padEnd(bar, 40)} ${est}${fmtTok(v)}`)
1006
+ }
1007
+ writeln(` ${dim('log₁₀ scale — each step = 10× · absolute values right')}`)
1008
+ writeln()
1009
+
1010
+ // trend across windows
1011
+ writeln(` ${hr()}`)
1012
+ writeln()
1013
+ writeln(` ${bold('Υ Trend')} ${dim('7d → 30d → 90d → all')}`)
1014
+ writeln()
1015
+ writeln(` ${yieldSpark(d)}`)
1016
+ writeln()
1017
+ }
1018
+
1019
+ // ── SUBMIT PREVIEW (FIX I1 / FIX E) — see→confirm→send ───────────────────────
1020
+ // Renders a platform × window grid of what submit_verified WOULD send, using the
1021
+ // already-loaded dashData. [Enter] confirms → fires submit_verified per platform;
1022
+ // [Esc] cancels → back to the read tab. Mirrors the Connect focused-field pattern.
1023
+ function renderSubmitPreview(dashData, id) {
1024
+ const budget = H() - 4
1025
+ let used = 0
1026
+ const emit = (s = '') => { if (used < budget) { writeln(s); used++ } }
1027
+ const WINS = ['7d', '30d', '90d', 'all']
1028
+ const COL_W = 14
1029
+
1030
+ emit()
1031
+ emit(` ${bold('Submit Preview')} ${dim('what will publish to the board')}`)
1032
+ emit(` ${dim(`signed in as ${id?.codename ?? '—'} · device ${id?.device_id?.slice(0,8) ?? '—'}…`)}`)
1033
+ emit()
1034
+ // header row: platform | 7d | 30d | 90d | all
1035
+ const hcols = [padEnd(hdr('Platform'), 12), ...WINS.map(w => padStart(hdr(w), COL_W))]
1036
+ emit(` ${hcols.join(' ')}`)
1037
+ emit(` ${dim('·'.repeat(Math.min(W() - 4, 12 + WINS.length * (COL_W + 2))))}`)
1038
+
1039
+ const active = dashData?.active ?? []
1040
+ for (const d of active) {
1041
+ if (used >= budget - 3) break
1042
+ const cells = WINS.map(w => {
1043
+ const wd = d.windows?.find(x => x.window === w)
1044
+ if (!wd || (wd.pillars.input + wd.pillars.output) === 0) return padStart(dim('—'), COL_W)
1045
+ const cas = cascadeFrom(wd.pillars)
1046
+ if (!cas) return padStart(dim('—'), COL_W)
1047
+ return padStart(gold(fmtY(cas.yield)), COL_W)
1048
+ })
1049
+ emit(` ${padEnd(cyan(d.platform), 12)} ${cells.join(' ')}`)
1050
+ }
1051
+ if (active.length === 0) {
1052
+ emit(` ${dim(' no platforms with data — press [R] to refresh')}`)
1053
+ }
1054
+ emit()
1055
+ emit(` ${dim('[Enter]')} confirm + submit all platforms ${dim('[Esc]')} cancel`)
1056
+ }
1057
+
1058
+ // ── DEBUG: render a tab once (no TTY/alt-screen) + audit each line's visible
1059
+ // width vs terminal columns. Usage: `node tui.mjs --render [0|1|2]`. Prints a
1060
+ // width report (>w = would overflow/wrap) then the raw frame. For diagnosing
1061
+ // layout overflow without an interactive session.
1062
+ async function renderOnce(tabIdx = 0) {
1063
+ const w = W()
1064
+ const data = {
1065
+ 0: await loadDashboardData().catch((e) => ({ error: e.message })),
1066
+ 1: await loadCompareData('claude').catch(() => null),
1067
+ 2: await loadBoardData('30d').catch(() => null),
1068
+ }[tabIdx]
1069
+ startBuffer()
1070
+ if (tabIdx === 0) renderDashboard(data, 'debug')
1071
+ else if (tabIdx === 1) renderCompare(data)
1072
+ else if (tabIdx === 2) renderBoard(data, '30d')
1073
+ else if (tabIdx === 5) renderConnect(loadIdentity(), '', '')
1074
+ const lines = _screenBuf || []
1075
+ _screenBuf = null; _footerBuf = null
1076
+ process.stdout.write(`\n=== WIDTH AUDIT (terminal w=${w}) — lines exceeding w wrap/overflow ===\n`)
1077
+ lines.forEach((ln, i) => {
1078
+ const vis = stripAnsi(ln).length
1079
+ if (vis > w) process.stdout.write(` OVERFLOW line ${i}: visible=${vis} (>${w} by ${vis - w}) «${stripAnsi(ln).slice(0, 60)}…»\n`)
1080
+ })
1081
+ const maxVis = Math.max(0, ...lines.map((l) => stripAnsi(l).length))
1082
+ process.stdout.write(` widest visible line = ${maxVis} (terminal w=${w}) → ${maxVis > w ? 'OVERFLOWS' : 'fits'}\n`)
1083
+ process.stdout.write(`\n=== RAW FRAME (stripAnsi) ===\n`)
1084
+ lines.forEach((ln) => process.stdout.write(stripAnsi(ln) + '\n'))
1085
+ }
1086
+
1087
+ // ── OPENING SPLASH ───────────────────────────────────────────────────────────
1088
+ // Block-letter SIGRANK wordmark that flashes through colors CONTINUOUSLY until a
1089
+ // key is pressed (owner 2026-06-25: "keep the animation going"). Randomizes the
1090
+ // mode each launch (moses gold-shimmer / rainbow / chaos per-letter). Centered to
1091
+ // terminal width. Any key dismisses → the dashboard loads.
1092
+ const SPLASH_ART = [
1093
+ '███████ ██ ██████ ██████ █████ ███ ██ ██ ██',
1094
+ '██ ██ ██ ██ ██ ██ ██ ████ ██ ██ ██ ',
1095
+ '███████ ██ ██ ███ ██████ ███████ ██ ██ ██ █████ ',
1096
+ ' ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ',
1097
+ '███████ ██ ██████ ██ ██ ██ ██ ██ ████ ██ ██',
1098
+ ]
1099
+ const SPLASH_RULE = '◈ ─────────────────────────────────────────── ◈'
1100
+ const MOSES_PAL = [220, 178, 214, 208, 222, 230]
1101
+ const RAINBOW_PAL = [196, 202, 226, 46, 51, 21, 201, 213]
1102
+ const col256 = (n, s) => `${ESC}38;5;${n}m${s}${c.reset}`
1103
+ const stripA = (s) => s.replace(/\x1b\[[0-9;]*m/g, '')
1104
+ const ctr = (s) => { const pad = Math.max(0, Math.floor((W() - stripA(s).length) / 2)); return ' '.repeat(pad) + s }
1105
+
1106
+ function splashFrame(mode) {
1107
+ const pick = (a) => a[Math.floor(Math.random() * a.length)]
1108
+ const out = ['', '', ctr(dim(SPLASH_RULE)), '']
1109
+ for (const line of SPLASH_ART) {
1110
+ if (mode === 'chaos') {
1111
+ out.push(ctr([...line].map((ch) => ch === ' ' ? ' ' : col256(Math.floor(Math.random() * 256), ch)).join('')))
1112
+ } else {
1113
+ const n = mode === 'rainbow' ? pick(RAINBOW_PAL) : pick(MOSES_PAL)
1114
+ out.push(ctr(col256(n, line)))
1115
+ }
1116
+ }
1117
+ out.push('', ctr(dim(SPLASH_RULE)), '')
1118
+ out.push(ctr(bold('For all builders, burners and 10xers')))
1119
+ out.push(ctr(dim('signalaf')), '')
1120
+ out.push(ctr(`${dim('powered by')} ${gold('MO§ES™')}`))
1121
+ out.push(ctr(dim('$ npx sigrank')), '')
1122
+ out.push(ctr(`${gold('▸')} ${dim('press any key')} ${gold('◂')}`))
1123
+ return out.join('\n')
1124
+ }
1125
+
1126
+ // Animated splash. Controls: [P] pause/resume the color animation · [Enter] (or
1127
+ // any other key) enter the TUI · Ctrl-C quit. Mode (moses/rainbow/chaos) varies
1128
+ // per launch. Frozen frame keeps the last colors so a pause reads as intentional.
1129
+ function showSplash() {
1130
+ const modes = ['moses', 'rainbow', 'chaos']
1131
+ const mode = modes[Math.floor((Date.now() / 1000) % modes.length)]
1132
+ return new Promise((resolve) => {
1133
+ let paused = false
1134
+ const interactive = !!process.stdin.isTTY // can we read raw keypresses to dismiss?
1135
+ const hint = () => interactive
1136
+ ? `${dim(' [P]')} ${paused ? 'resume' : 'pause'} ${dim('[Enter]')} enter`
1137
+ : dim(' loading…')
1138
+ const paint = () => { write(CLEAR); write(splashFrame(mode)); write(`\n${ctr(hint())}`) }
1139
+ paint()
1140
+ let timer = setInterval(paint, 110)
1141
+
1142
+ // Non-interactive stdin (some IDE/integrated terminals): still SHOW the animated
1143
+ // wordmark, but auto-advance after a short run instead of waiting for a key (which
1144
+ // would never arrive → hang). ~2.2s of animation, then enter the TUI.
1145
+ if (!interactive) {
1146
+ setTimeout(() => { clearInterval(timer); write(CLEAR); resolve() }, 2200)
1147
+ return
1148
+ }
1149
+
1150
+ process.stdin.setRawMode?.(true)
1151
+ process.stdin.resume()
1152
+ process.stdin.setEncoding('utf8')
1153
+ const onKey = (key) => {
1154
+ const k = key.toLowerCase()
1155
+ if (k === 'p') { // toggle pause — don't enter
1156
+ paused = !paused
1157
+ if (paused) { clearInterval(timer); timer = null; paint() } // freeze on current frame
1158
+ else { timer = setInterval(paint, 110) } // resume animating
1159
+ return
1160
+ }
1161
+ if (timer) clearInterval(timer)
1162
+ process.stdin.removeListener('data', onKey)
1163
+ if (key === '\x03') { write(SHOW); write(EXIT_ALT); process.exit(0) } // Ctrl-C
1164
+ resolve() // Enter / any other key → enter the TUI
1165
+ }
1166
+ process.stdin.on('data', onKey)
1167
+ })
1168
+ }
1169
+
1170
+ // ── MAIN TUI LOOP ─────────────────────────────────────────────────────────────
1171
+ export async function runTui({ platform: initPlatform = 'claude', window: win = '7d' } = {}) {
1172
+ // FIX J: platform is mutable — [P] cycles it on Compare + Watch tabs. Was a const
1173
+ // param (permanently locked to 'claude'); now a let so the user can switch + [S]
1174
+ // submits the selected platform (needed for Phase 4 multi-platform submit).
1175
+ let platform = initPlatform
1176
+ // Debug render mode — non-interactive, dumps a tab + width audit, then exits.
1177
+ const ri = process.argv.indexOf('--render')
1178
+ if (ri !== -1) {
1179
+ const tab = parseInt(process.argv[ri + 1] ?? '0', 10) || 0
1180
+ await renderOnce(tab)
1181
+ return
1182
+ }
1183
+
1184
+ write(ENTER_ALT) // switch to alternate screen — original terminal state preserved on exit
1185
+ write(HIDE)
1186
+ write(CLEAR)
1187
+
1188
+ // Opening splash — animated SIGRANK wordmark.
1189
+ // Gate on stdOUT.isTTY (can we draw the animation?), matching the launch guard —
1190
+ // NOT stdin.isTTY, which was skipping the splash in terminals whose input isn't a
1191
+ // raw TTY (some IDE/integrated terminals). showSplash() itself auto-advances when
1192
+ // stdin can't be read raw, so it never hangs. (Skippable with --no-splash.)
1193
+ if (!process.argv.includes('--no-splash') && process.stdout.isTTY) {
1194
+ await showSplash()
1195
+ write(CLEAR)
1196
+ }
1197
+
1198
+ // ── State
1199
+ let activeTab = 0
1200
+ let dashData = null
1201
+ let compareData = null
1202
+ let boardData = null
1203
+ let boardWindow = '30d'
1204
+ let watchPlatform = platform
1205
+ let watchWindow = win
1206
+ let watchRefresh = 30 // [5] Watch poll interval (seconds) — [+]/[-] adjust
1207
+ let trendSub = 0 // [2] Trends sub-view index (You/Platform/Field) — [T] cycles
1208
+ let loading = true
1209
+ let status = 'loading…'
1210
+ let refreshTimer = null
1211
+ let codeBuf = '' // [6] Connect tab in-place code field buffer
1212
+ let connectMsg = '' // [6] Connect last-action message (signed in / invalid code)
1213
+ let submitMsg = '' // [S] in-place submit result, shown in the read-tab footer
1214
+ let submitPreview = false // FIX I1: [S] opens a preview grid before sending (see→confirm→send)
1215
+ let boardYouOnly = false // FIX I2: [Y] toggles the Board to "just me" (hybrid model)
1216
+
1217
+ // ── Redraw (buffered: renders into memory, then paints as a locked frame)
1218
+ const redraw = async () => {
1219
+ startBuffer()
1220
+ renderTabBar(activeTab)
1221
+
1222
+ const hint = ` ${dim('← → or 1-6')} switch tabs ${dim('[R]')} refresh ${dim('[Q]')} quit`
1223
+ const submitHint = ` ${dim('[S]')} submit to board · ${dim('[C]')} sign in`
1224
+ // Read-tab footer: hr + hint, plus the in-place submit result line when present.
1225
+ const readFooter = (hintLine) => submitMsg
1226
+ ? [` ${hr()}`, hintLine, ` ${submitMsg}`]
1227
+ : [` ${hr()}`, hintLine]
1228
+
1229
+ // FIX I1: submit preview is a focused overlay — renders INSTEAD of the tab
1230
+ // content, with its own footer. [Enter] sends, [Esc] cancels.
1231
+ if (submitPreview) {
1232
+ renderSubmitPreview(dashData, loadIdentity())
1233
+ setFooter([` ${hr()}`, ` ${dim('[Enter]')} submit all platforms ${dim('[Esc]')} cancel ${dim('[Q]')} quit`])
1234
+ flushScreen()
1235
+ return
1236
+ }
1237
+
1238
+ if (activeTab === 0) { // Dashboard
1239
+ if (!dashData) {
1240
+ writeln(`\n ${dim('loading dashboard…')}`)
1241
+ } else {
1242
+ renderDashboard(dashData, status)
1243
+ }
1244
+ setFooter(readFooter(`${hint}${submitHint}`))
1245
+ } else if (activeTab === 1) { // Trends
1246
+ if (!dashData) {
1247
+ writeln(`\n ${dim('loading trends…')}`)
1248
+ } else {
1249
+ renderTrends(dashData, trendSub)
1250
+ }
1251
+ setFooter(readFooter(`${hint} ${dim('[T]')} view${submitHint}`))
1252
+ } else if (activeTab === 2) { // Compare
1253
+ if (!compareData) {
1254
+ writeln(`\n ${dim(`loading compare (${platform})…`)}`)
1255
+ } else {
1256
+ renderCompare(compareData)
1257
+ }
1258
+ setFooter(readFooter(`${hint} ${dim('[P]')} switch platform${submitHint}`))
1259
+ } else if (activeTab === 3) { // Board
1260
+ if (!boardData) {
1261
+ writeln(`\n ${dim(`loading board (${boardWindow})…`)}`)
1262
+ } else {
1263
+ // FIX I2: hybrid board model — global board + your row highlighted, [Y]
1264
+ // toggles "just me" (your placements only). Pass the signed-in codename so
1265
+ // renderBoard can highlight/filter.
1266
+ const id = loadIdentity()
1267
+ renderBoard(boardData, boardWindow, boardYouOnly ? id?.codename : null, id?.codename ?? null)
1268
+ }
1269
+ const youHint = isSignedIn(loadIdentity()) ? ` ${dim('[Y]')} ${boardYouOnly ? 'all' : 'just me'}` : ''
1270
+ setFooter(readFooter(`${hint} ${dim('[W]')} window${youHint}${submitHint}`))
1271
+ } else if (activeTab === 4) { // Watch
1272
+ renderWatchInfo(watchPlatform, watchWindow, watchRefresh)
1273
+ setFooter(readFooter(`${hint}${submitHint}`))
1274
+ } else if (activeTab === 5) { // Connect
1275
+ const connectId = loadIdentity()
1276
+ renderConnect(connectId, codeBuf, connectMsg)
1277
+ const signOutHint = isSignedIn(connectId) ? ` ${dim('[X]')} sign out` : ''
1278
+ setFooter([` ${hr()}`, ` ${dim('[Enter]')} sign in ${dim('[Esc]')} clear/back${signOutHint} ${dim('← →')} tabs ${dim('[Q]')} quit`])
1279
+ }
1280
+ flushScreen()
1281
+ }
1282
+
1283
+ // ── Initial data load
1284
+ const loadAll = async () => {
1285
+ status = 'loading…'
1286
+ // FIX 0: progressive Dashboard load — claude first (fast), render it, THEN fill
1287
+ // the other 13 platforms in the background + redraw. The user sees their cascade
1288
+ // within ~1 read instead of a 7s blank "loading dashboard…".
1289
+ dashData = await loadDashboardData().catch(e => { status = `dashboard error: ${e.message}`; return null })
1290
+ status = `last refreshed ${new Date().toLocaleTimeString('en-US', { hour12: false })}`
1291
+ await redraw()
1292
+ // Kick off the rest + compare/board in parallel
1293
+ ;[compareData, boardData] = await Promise.all([
1294
+ loadCompareData(platform).catch(() => null),
1295
+ loadBoardData(boardWindow).catch(() => null),
1296
+ ])
1297
+ if (dashData?._remaining) {
1298
+ await fillDashboardRest(dashData)
1299
+ await redraw()
1300
+ }
1301
+ }
1302
+
1303
+ // Always restore terminal on unexpected exit
1304
+ const cleanup = () => { write(SHOW); write(EXIT_ALT); process.exit(0) }
1305
+ process.once('SIGINT', cleanup)
1306
+ process.once('SIGTERM', cleanup)
1307
+
1308
+ // FIX 0b: re-render on terminal resize so the frame refits the new dimensions.
1309
+ process.stdout.on('resize', () => { redraw().catch(() => {}) })
1310
+
1311
+ // Soft-landing: if not signed in, open on the Connect tab (a prompt, not a gate —
1312
+ // the user can still tab away to read Board/Dashboard while signed out).
1313
+ if (!isSignedIn(loadIdentity())) activeTab = 5
1314
+
1315
+ // Draw loading state immediately so user sees tab bar + border
1316
+ await redraw()
1317
+ await loadAll()
1318
+
1319
+ // auto-refresh board every 30s
1320
+ const startAutoRefresh = () => {
1321
+ if (refreshTimer) clearInterval(refreshTimer)
1322
+ refreshTimer = setInterval(async () => {
1323
+ if (activeTab === 3) { // Board tab (now index 3)
1324
+ boardData = await loadBoardData(boardWindow).catch(() => null)
1325
+ await redraw()
1326
+ }
1327
+ }, 30000)
1328
+ }
1329
+ startAutoRefresh()
1330
+
1331
+ // ── Keyboard
1332
+ if (!process.stdin.isTTY) return
1333
+
1334
+ process.stdin.setRawMode(true)
1335
+ process.stdin.resume()
1336
+ process.stdin.setEncoding('utf8')
1337
+
1338
+ await new Promise((resolve) => {
1339
+ process.stdin.on('data', async (key) => {
1340
+ const k = key.toLowerCase()
1341
+
1342
+ if (k === 'q' || k === '\x03') {
1343
+ if (refreshTimer) clearInterval(refreshTimer)
1344
+ write(SHOW)
1345
+ write(EXIT_ALT)
1346
+ resolve()
1347
+ return
1348
+ }
1349
+
1350
+ // ── Connect tab is a FOCUSED code field (the one modal tab). Printable code
1351
+ // chars feed the buffer; Enter signs in; Esc clears or leaves; arrows switch.
1352
+ // Sits AFTER the quit block (so Q/Ctrl-C always escape) and BEFORE everything
1353
+ // else (so single-letter hotkeys don't fire while you're typing a code).
1354
+ if (activeTab === 5) {
1355
+ if (key === '\r' || key === '\n') { // [Enter] → sign in
1356
+ const code = codeBuf.trim()
1357
+ if (!code) { connectMsg = dim('paste a connect code first'); await redraw(); return }
1358
+ connectMsg = dim('signing in…'); await redraw()
1359
+ try {
1360
+ const out = await callTool('enroll', { code })
1361
+ if (out.status === 'enrolled') {
1362
+ connectMsg = `${green('✓')} signed in as ${cyan(out.codename || '(operator)')}`
1363
+ codeBuf = ''
1364
+ } else {
1365
+ const reasons = {
1366
+ code_invalid: 'that code is invalid, expired, or already used — generate a fresh one.',
1367
+ device_already_enrolled: 'this device is already signed in. Need a new key? Click "New key" at signalaf.com → Settings, then paste it here.',
1368
+ bad_request: 'the code or device key was malformed.',
1369
+ rate_limited: 'too many attempts — wait a few minutes and retry.',
1370
+ persistence_unavailable: 'sign-in is temporarily unavailable — try again shortly.',
1371
+ }
1372
+ connectMsg = `${red('✗')} ${reasons[out.reason] || `sign-in failed (${out.reason || 'unknown'}).`}`
1373
+ }
1374
+ } catch (e) {
1375
+ connectMsg = `${red('✗')} ${e.message}`
1376
+ }
1377
+ await redraw(); return
1378
+ }
1379
+ if (key === '\x1b') { // [Esc] → clear, or leave when empty
1380
+ if (codeBuf) { codeBuf = ''; connectMsg = '' } else { activeTab = 0 }
1381
+ await redraw(); return
1382
+ }
1383
+ if (key === '\x1b[C') { activeTab = Math.min(5, activeTab + 1); await redraw(); return } // → tab
1384
+ if (key === '\x1b[D') { activeTab = Math.max(0, activeTab - 1); await redraw(); return } // ← tab
1385
+ if (key === '\x7f' || key === '\b') { codeBuf = codeBuf.slice(0, -1); await redraw(); return } // backspace
1386
+ // [X] sign out / reset — only when signed in AND the code field is empty (so an
1387
+ // 'x' typed mid-code still feeds the buffer). Drops the stale local credential so
1388
+ // a server-revoked device stops reading as "signed in" + the next enroll provisions
1389
+ // a fresh device_id (escapes the Frankenstein-identity / 409-re-enroll deadlock).
1390
+ if (k === 'x' && !codeBuf && isSignedIn(loadIdentity())) {
1391
+ clearIdentity()
1392
+ codeBuf = ''
1393
+ connectMsg = `${green('✓')} signed out — paste a new code to sign in`
1394
+ await redraw(); return
1395
+ }
1396
+ // A typed code char (len 1) or a pasted code (one multi-char chunk). Exclude any
1397
+ // escape sequence (↑/↓/etc.) so stray control bytes never pollute the buffer.
1398
+ if (!key.startsWith('\x1b') && (key.length > 1 || isCodeChar(key))) {
1399
+ codeBuf += key.split('').filter(isCodeChar).join('')
1400
+ await redraw(); return
1401
+ }
1402
+ return // swallow any other key while focused (never falls through to global hotkeys)
1403
+ }
1404
+
1405
+ // FIX I1: submit preview is a focused overlay (like Connect). [Enter] sends
1406
+ // all platforms, [Esc] cancels. Intercepts keys BEFORE the tab/ESC handlers.
1407
+ if (submitPreview) {
1408
+ if (key === '\r' || key === '\n') { // [Enter] → confirm + submit all platforms
1409
+ submitPreview = false
1410
+ submitMsg = dim('submitting…')
1411
+ await redraw()
1412
+ const id = loadIdentity()
1413
+ if (!isSignedIn(id)) {
1414
+ submitMsg = `${red('✗')} sign in to submit`
1415
+ activeTab = 5
1416
+ await redraw(); return
1417
+ }
1418
+ // FIX I1: submit every active platform (data already loaded for the Dashboard).
1419
+ // Each platform is a separate submit_verified call → its own (platform, window) slot.
1420
+ const platforms = (dashData?.active ?? []).map(d => d.platform)
1421
+ let totalVerified = 0, totalReceived = 0, hadFail = false, didMulti = false
1422
+ for (const p of platforms) {
1423
+ try {
1424
+ const out = await callTool('submit_verified', { platform: p })
1425
+ const ws = out.windows || []
1426
+ const received = ws.filter(w => w.status === 'received')
1427
+ const verified = received.filter(w => w.verification_tier === 'verified')
1428
+ totalReceived += received.length
1429
+ totalVerified += verified.length
1430
+ if (received.length > 0 && verified.length < received.length) hadFail = true
1431
+ } catch { hadFail = true }
1432
+ }
1433
+ // MULTI: the combined cross-platform cascade (claude+codex+… summed). Only
1434
+ // meaningful with 2+ active platforms; submit_verified('multi') aggregates them.
1435
+ if (platforms.length >= 2) {
1436
+ try {
1437
+ const out = await callTool('submit_verified', { platform: 'multi' })
1438
+ const ws = out.windows || []
1439
+ const received = ws.filter(w => w.status === 'received')
1440
+ const verified = received.filter(w => w.verification_tier === 'verified')
1441
+ totalReceived += received.length
1442
+ totalVerified += verified.length
1443
+ if (received.length > 0 && verified.length < received.length) hadFail = true
1444
+ if (received.length > 0) didMulti = true
1445
+ } catch { hadFail = true }
1446
+ }
1447
+ if (totalReceived && !hadFail && totalVerified === totalReceived) {
1448
+ submitMsg = `${green('✓')} submitted · ${totalVerified} window${totalVerified > 1 ? 's' : ''} across ${platforms.length} platform${platforms.length > 1 ? 's' : ''}${didMulti ? ' + combined' : ''} · verified`
1449
+ } else if (hadFail && totalReceived) {
1450
+ submitMsg = `${red('✗')} device not verified — sign in again ([C])`
1451
+ } else if (totalReceived === 0) {
1452
+ submitMsg = `${red('✗')} nothing to submit — no windows with data`
1453
+ } else {
1454
+ submitMsg = `${red('✗')} submit failed`
1455
+ }
1456
+ await redraw(); return
1457
+ }
1458
+ if (key === '\x1b') { // [Esc] → cancel
1459
+ submitPreview = false
1460
+ await redraw(); return
1461
+ }
1462
+ return // swallow all other keys while the preview is open
1463
+ }
1464
+
1465
+ // ESC → go back to Dashboard from any tab
1466
+ if (key === '\x1b' && activeTab !== 0) {
1467
+ activeTab = 0
1468
+ await redraw()
1469
+ return
1470
+ }
1471
+
1472
+ // tab switching (6 tabs: 0=Dashboard 1=Trends 2=Compare 3=Board 4=Watch 5=Connect)
1473
+ let switched = false
1474
+ if (key === '\x1b[C') { activeTab = Math.min(5, activeTab + 1); switched = true }
1475
+ if (key === '\x1b[D') { activeTab = Math.max(0, activeTab - 1); switched = true }
1476
+ if (k === '1') { activeTab = 0; switched = true }
1477
+ if (k === '2') { activeTab = 1; switched = true }
1478
+ if (k === '3') { activeTab = 2; switched = true }
1479
+ if (k === '4') { activeTab = 3; switched = true }
1480
+ if (k === '5') { activeTab = 4; switched = true } // Watch = an in-TUI landing panel
1481
+ if (k === '6') { activeTab = 5; switched = true } // Connect = sign in / switch device
1482
+ if (k === 'c' && activeTab !== 5) { activeTab = 5; switched = true } // [C] → Connect from any read tab
1483
+
1484
+ // Trends tab: [T] cycles the sub-view (You · Platform · Field)
1485
+ if (activeTab === 1 && k === 't') { trendSub = (trendSub + 1) % 3; await redraw(); return }
1486
+
1487
+ // Watch tab: [+]/[-] tune the refresh interval (5–600s), [Enter] launches the watcher
1488
+ if (activeTab === 4 && (k === '+' || k === '=' )) { watchRefresh = Math.min(600, watchRefresh + 5); await redraw(); return }
1489
+ if (activeTab === 4 && (k === '-' || k === '_')) { watchRefresh = Math.max(5, watchRefresh - 5); await redraw(); return }
1490
+
1491
+ // FIX J: [P] cycles platform on Compare + Watch tabs (was a dead hinted key).
1492
+ // Compare reloads compareData for the new platform; Watch just re-renders.
1493
+ const CYCLE_PLATFORMS = ['claude','codex','amp','gemini','kimi','qwen','goose','kilo','hermes','droid','codebuff','copilot','openclaw','pi']
1494
+ if (k === 'p' && (activeTab === 2 || activeTab === 4)) {
1495
+ const cur = activeTab === 2 ? platform : watchPlatform
1496
+ const idx = CYCLE_PLATFORMS.indexOf(cur)
1497
+ const next = CYCLE_PLATFORMS[(idx + 1) % CYCLE_PLATFORMS.length]
1498
+ if (activeTab === 2) {
1499
+ platform = next
1500
+ status = `loading ${platform}…`
1501
+ await redraw()
1502
+ compareData = await loadCompareData(platform).catch(() => null)
1503
+ status = `last refreshed ${new Date().toLocaleTimeString('en-US', { hour12: false })}`
1504
+ } else {
1505
+ watchPlatform = next
1506
+ }
1507
+ await redraw(); return
1508
+ }
1509
+
1510
+ // FIX J: [W] cycles window on Watch tab (was a dead hinted key). Board tab's
1511
+ // [W] (activeTab === 3) is handled below — it reloads boardData.
1512
+ if (k === 'w' && activeTab === 4) {
1513
+ const windows = ['7d', '30d', '90d', 'all']
1514
+ const idx = windows.indexOf(watchWindow)
1515
+ watchWindow = windows[(idx + 1) % windows.length]
1516
+ await redraw(); return
1517
+ }
1518
+
1519
+ if (activeTab === 4 && (key === '\r' || key === '\n')) {
1520
+ // Launch the live watcher in its own window, passing the chosen refresh interval.
1521
+ try {
1522
+ const watchCmd = `sigrank watch --platform ${watchPlatform} --window ${watchWindow} --refresh ${watchRefresh}`
1523
+ execSync(`osascript -e 'tell application "Terminal" to do script "${watchCmd}"'`, { stdio: 'ignore' })
1524
+ status = `watcher launched (${watchRefresh}s) in a new window`
1525
+ } catch { status = 'could not open Terminal.app — run: sigrank watch' }
1526
+ await redraw()
1527
+ return
1528
+ }
1529
+
1530
+ if (k === 'r') {
1531
+ status = 'refreshing…'
1532
+ await redraw()
1533
+ await loadAll()
1534
+ return
1535
+ }
1536
+
1537
+ if (k === 's' && activeTab !== 5) {
1538
+ // FIX I1/E: [S] opens a submit PREVIEW (see→confirm→send) instead of firing
1539
+ // blind. The preview shows a platform × window grid of what will publish;
1540
+ // [Enter] sends all platforms, [Esc] cancels. (On the Connect tab, 's' is a
1541
+ // code char handled by the focused field above.)
1542
+ const id = loadIdentity()
1543
+ if (!isSignedIn(id)) { // not signed in → open Connect, never error
1544
+ activeTab = 5
1545
+ connectMsg = dim('sign in to submit to board')
1546
+ await redraw()
1547
+ return
1548
+ }
1549
+ submitPreview = true
1550
+ submitMsg = ''
1551
+ await redraw()
1552
+ return
1553
+ }
1554
+
1555
+ // FIX I2: [Y] toggles the Board between global+highlighted and "just me"
1556
+ // (hybrid model — owner decision 2026-06-26). Only when signed in.
1557
+ if (k === 'y' && activeTab === 3 && isSignedIn(loadIdentity())) {
1558
+ boardYouOnly = !boardYouOnly
1559
+ await redraw(); return
1560
+ }
1561
+
1562
+ if (k === 'w' && activeTab === 3) { // Board tab (now index 3)
1563
+ const windows = ['7d', '30d', '90d', 'all']
1564
+ const idx = windows.indexOf(boardWindow)
1565
+ boardWindow = windows[(idx + 1) % windows.length]
1566
+ boardData = await loadBoardData(boardWindow).catch(() => null)
1567
+ }
1568
+
1569
+ if (switched) submitMsg = '' // submit result is tied to the tab it was sent from
1570
+ if (switched || k === 'w') await redraw()
1571
+ })
1572
+ })
1573
+
1574
+ process.stdin.setRawMode(false)
1575
+ process.stdin.pause()
1576
+ writeln()
1577
+ }
1578
+
1579
+ // Direct-run entry (e.g. `node tui.mjs --render 0`). Normal launch is via cli.mjs.
1580
+ if (import.meta.url === `file://${process.argv[1]}`) {
1581
+ runTui().catch((e) => { console.error(e); process.exit(1) })
1582
+ }