sigrank 0.11.5 → 0.12.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/README.md +2 -2
- package/cli.mjs +150 -65
- package/package.json +1 -1
- package/tokenpull.mjs +118 -0
- package/tools.mjs +42 -6
- package/tui.mjs +413 -186
package/tui.mjs
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { callTool, DEFAULT_API_BASE } from './tools.mjs'
|
|
16
|
+
import { freshVerifierPillars } from './tokenpull.mjs'
|
|
16
17
|
import { isSignedIn, isCodeChar } from './connect.mjs'
|
|
17
18
|
import { loadIdentity, clearIdentity } from './keystore.mjs'
|
|
18
19
|
import { execSync } from 'child_process'
|
|
@@ -220,70 +221,13 @@ function sparkline(values) {
|
|
|
220
221
|
}).join('')
|
|
221
222
|
}
|
|
222
223
|
|
|
223
|
-
// ── Data sources
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
}
|
|
224
|
+
// ── Data sources ─────────────────────────────────────────────────────────────
|
|
225
|
+
// (0.12.1 FIX 4) The local verifier readers (ccusage / tokscale / token-dashboard)
|
|
226
|
+
// moved to `freshVerifierPillars()` in tokenpull.mjs — FRESH on-demand pulls used by
|
|
227
|
+
// the Compare tab (#9). The old in-tui copies were dead after A1 removed the Dashboard
|
|
228
|
+
// verifierMap loop and #9 routed Compare through the fresh helper; removed here.
|
|
229
|
+
// (tools.mjs keeps its own `_ccusagePillars`/`_tokscalePillars`/`_tokenDashPillars` —
|
|
230
|
+
// still used by the `tokenpull_compare` MCP tool.)
|
|
287
231
|
|
|
288
232
|
async function loadDashboardData() {
|
|
289
233
|
const { tokenpullAny } = await import('./tokenpull.mjs')
|
|
@@ -301,10 +245,7 @@ async function loadDashboardData() {
|
|
|
301
245
|
// then fill the other 13 as they resolve. The caller passes an onFirst callback
|
|
302
246
|
// that fires when claude is ready so the user sees their cascade within ~1 read,
|
|
303
247
|
// not after a 14-platform barrier.
|
|
304
|
-
const
|
|
305
|
-
tokenpullAny('claude').catch(() => null),
|
|
306
|
-
Promise.resolve(tokenDashPillars()),
|
|
307
|
-
])
|
|
248
|
+
const claudeResult = await tokenpullAny('claude').catch(() => null)
|
|
308
249
|
|
|
309
250
|
const active = []
|
|
310
251
|
if (claudeResult) {
|
|
@@ -312,13 +253,13 @@ async function loadDashboardData() {
|
|
|
312
253
|
if (all && (all.pillars.input ?? 0) + (all.pillars.output ?? 0) > 0) active.push(claudeResult)
|
|
313
254
|
}
|
|
314
255
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
256
|
+
// FIX A1 (2026-06-27): removed the 14× SYNCHRONOUS ccusage `verifierMap` build (measured 39,816 ms)
|
|
257
|
+
// that blocked the very first Dashboard paint — and the render never read `verifierMap` or `tdPillars`.
|
|
258
|
+
// (Also dropped the discarded second `tokenDashPillars()` call above.) Verifier comparison
|
|
259
|
+
// (ccusage / tokscale / token-dashboard) now runs FRESH + on-demand in the Compare tab, scoped to
|
|
260
|
+
// active platforms — see loadCompareData. Dashboard paints from tokenpull in ~2s.
|
|
320
261
|
const boardData = await boardPromise
|
|
321
|
-
return { active, verifierMap, tdPillars:
|
|
262
|
+
return { active, verifierMap: {}, tdPillars: null, boardData, _loading: true, _remaining: ALL_PLATFORMS.slice(1) }
|
|
322
263
|
}
|
|
323
264
|
|
|
324
265
|
/** FIX 0: fill the remaining platforms after the primary render. Mutates dashData.active. */
|
|
@@ -342,20 +283,42 @@ async function fillDashboardRest(dashData) {
|
|
|
342
283
|
return true
|
|
343
284
|
}
|
|
344
285
|
|
|
286
|
+
// #9 / C1 (2026-06-27): Compare pulls the three external verifier sources FRESH + on-demand
|
|
287
|
+
// (ccusage + tokscale models + a token-dashboard rescan), via tokenpull.freshVerifierPillars —
|
|
288
|
+
// NOT the old stale snapshot readers (static tokscale_report.json / 3-day-old db). This runs ONLY
|
|
289
|
+
// when the Compare tab opens (it's a 5–60s scan), never on the Dashboard load path. Scoped to the
|
|
290
|
+
// current platform. tokenpull (the canonical board/cascade source) stays the fast/fresh tpData.
|
|
345
291
|
async function loadCompareData(platform = 'claude') {
|
|
346
292
|
const tpData = await callTool('tokenpull', { platform }).catch(() => null)
|
|
347
|
-
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
return { tpData, cc, ts, td, platform }
|
|
293
|
+
// Fresh pull — { ccusage, tokscale, tokendash }, each null or window-keyed pillars.
|
|
294
|
+
const fresh = await freshVerifierPillars(platform).catch(() => ({ ccusage: null, tokscale: null, tokendash: null }))
|
|
295
|
+
return { tpData, cc: fresh.ccusage, ts: fresh.tokscale, td: fresh.tokendash, platform }
|
|
351
296
|
}
|
|
352
297
|
|
|
298
|
+
// #8 (2026-06-27): the Board tab shows ALL SUBMISSIONS ranked (raw submission rows), not operator
|
|
299
|
+
// aggregates. Try the NEW GET /api/v1/submissions first; if it's not deployed yet (non-200 / 404),
|
|
300
|
+
// FALL BACK to the existing /api/v1/leaderboard so nothing breaks pre-deploy. The return is tagged
|
|
301
|
+
// with `_source` ('submissions' | 'leaderboard') so renderBoard picks the right column layout.
|
|
353
302
|
async function loadBoardData(window = '30d') {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
303
|
+
// Primary: submissions endpoint (ranked submission rows).
|
|
304
|
+
try {
|
|
305
|
+
const res = await fetch(`${DEFAULT_API_BASE}/api/v1/submissions?window=${window}&metric=yield_`, {
|
|
306
|
+
headers: { accept: 'application/json' },
|
|
307
|
+
})
|
|
308
|
+
if (res.ok) {
|
|
309
|
+
const data = await res.json()
|
|
310
|
+
return { ...data, _source: 'submissions' }
|
|
311
|
+
}
|
|
312
|
+
} catch { /* fall through to leaderboard */ }
|
|
313
|
+
// Fallback: existing leaderboard endpoint + current render (pre-deploy safety).
|
|
314
|
+
try {
|
|
315
|
+
const res = await fetch(`${DEFAULT_API_BASE}/api/v1/leaderboard?window=${window}&metric=yield_`, {
|
|
316
|
+
headers: { accept: 'application/json' },
|
|
317
|
+
})
|
|
318
|
+
if (!res.ok) return null
|
|
319
|
+
const data = await res.json()
|
|
320
|
+
return { ...data, _source: 'leaderboard' }
|
|
321
|
+
} catch { return null }
|
|
359
322
|
}
|
|
360
323
|
|
|
361
324
|
// ── TAB BAR ──────────────────────────────────────────────────────────────────
|
|
@@ -454,14 +417,60 @@ function yieldSpark(d) {
|
|
|
454
417
|
}
|
|
455
418
|
|
|
456
419
|
// ── TAB 1: DASHBOARD ─────────────────────────────────────────────────────────
|
|
457
|
-
|
|
458
|
-
|
|
420
|
+
|
|
421
|
+
// SCROLL-VIEW (2026-06-27): section-scoped scroll for the cascade table.
|
|
422
|
+
// The cascade section gets a real scroll viewport — up/down arrows scroll just
|
|
423
|
+
// the cascade rows while the rest of the dashboard (header, token composition,
|
|
424
|
+
// board, footer) stays pinned. This replaces the misleading "+N more — resize /
|
|
425
|
+
// scroll" text (A3) which implied scroll existed but didn't (alt-screen locked
|
|
426
|
+
// frame = no terminal scroll). The scroll is IN-SECTION only, on the Dashboard tab.
|
|
427
|
+
|
|
428
|
+
const DASH_WINS = ['7d', '30d', '90d', 'all']
|
|
429
|
+
|
|
430
|
+
// Count how many cascade rows the active platforms would produce (every window
|
|
431
|
+
// with data across all active platforms). This is the "total wanted" count.
|
|
432
|
+
function cascadeScrollableCount(active) {
|
|
433
|
+
return active.reduce((n, d) =>
|
|
434
|
+
n + DASH_WINS.filter(wk => {
|
|
435
|
+
const wd = d.windows?.find(x => x.window === wk)
|
|
436
|
+
return wd && (wd.pillars.input + wd.pillars.output) > 0
|
|
437
|
+
}).length, 0)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Compute the max cascade rows that fit in the current terminal height budget.
|
|
441
|
+
// Mirrors the calculation in renderDashboard (budget - used - sectionsBelow).
|
|
442
|
+
function maxCascadeRowsFor(active) {
|
|
443
|
+
const budget = H() - 4 // 2 tab bar + 2 footer
|
|
444
|
+
const platformCount = active.length
|
|
445
|
+
const barLines = 4 + platformCount + (platformCount > 1 ? 1 : 0)
|
|
446
|
+
const boardLines = 17
|
|
447
|
+
const sectionsBelow = barLines + boardLines
|
|
448
|
+
// used before cascade = header(2) + blank(1) + title(1) + blank(1) + col-header(1) + separator(1) = 7
|
|
449
|
+
const usedBeforeCascade = 7
|
|
450
|
+
return Math.max(8, budget - usedBeforeCascade - sectionsBelow)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function renderDashboard(data, status = '', scrollOffset = 0) {
|
|
459
454
|
const w = W()
|
|
460
455
|
const budget = H() - 4 // 2 tab bar + 2 footer
|
|
461
456
|
const WINS = ['7d', '30d', '90d', 'all']
|
|
462
457
|
let used = 0
|
|
463
458
|
const emit = (s = '') => { if (used < budget) { writeln(s); used++ } }
|
|
464
459
|
|
|
460
|
+
// A2 (2026-06-27): tolerate null/empty dashData so the FIRST paint can happen BEFORE the
|
|
461
|
+
// initial load resolves (loadAll renders an empty frame + "reading your cascade…" immediately,
|
|
462
|
+
// then re-renders with real data). Guard the destructure: no data → header + status + return.
|
|
463
|
+
if (!data || !Array.isArray(data.active)) {
|
|
464
|
+
emit()
|
|
465
|
+
emit(` ${bold('Your Cascade')} ${dim('all platforms · all windows')}`)
|
|
466
|
+
emit()
|
|
467
|
+
emit(` ${dim(' reading token logs… (press [R] to refresh)')}`)
|
|
468
|
+
if (status && used < budget) emit(` ${dim(status)}`)
|
|
469
|
+
return
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const { active } = data
|
|
473
|
+
|
|
465
474
|
// Responsive: the full 12-col table is ~124 wide. On terminals narrower than
|
|
466
475
|
// that, drop the two derived columns (Vel, 10x) and tighten the inter-column
|
|
467
476
|
// gap from 2 spaces to 1, so the core pillars + Υ/SNR/Lev/Class still fit.
|
|
@@ -521,21 +530,50 @@ function renderDashboard(data, status = '') {
|
|
|
521
530
|
const sectionsBelow = barLines + boardLines
|
|
522
531
|
const maxCascadeRows = Math.max(8, budget - used - sectionsBelow)
|
|
523
532
|
|
|
533
|
+
// SCROLL-VIEW (2026-06-27): Build the full list of cascade rows first, then
|
|
534
|
+
// slice by [scrollOffset, scrollOffset + maxCascadeRows]. This gives a real
|
|
535
|
+
// in-section scroll viewport — up/down arrows scroll just the cascade rows
|
|
536
|
+
// while the rest of the dashboard stays pinned. Replaces the A3 "+N more —
|
|
537
|
+
// resize / scroll" text which implied scroll existed but didn't.
|
|
524
538
|
let firstWin = {}
|
|
525
|
-
|
|
539
|
+
const allCascadeRows = [] // flat list: { platform, winKey, pillars, estimated }
|
|
526
540
|
for (const d of active) {
|
|
527
541
|
firstWin[d.platform] = WINS.find(wk => {
|
|
528
542
|
const wd = d.windows?.find(w => w.window === wk)
|
|
529
543
|
return wd && (wd.pillars.input + wd.pillars.output) > 0
|
|
530
544
|
})
|
|
531
545
|
for (const wk of WINS) {
|
|
532
|
-
if (cascadeRowCount >= maxCascadeRows || used >= budget - sectionsBelow) break
|
|
533
546
|
const wd = d.windows?.find(x => x.window === wk)
|
|
534
547
|
if (!wd || (wd.pillars.input + wd.pillars.output) === 0) continue
|
|
535
|
-
|
|
536
|
-
cascadeRowCount++
|
|
548
|
+
allCascadeRows.push({ platform: d.platform, winKey: wk, pillars: wd.pillars, estimated: d.estimated })
|
|
537
549
|
}
|
|
538
|
-
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const totalWanted = allCascadeRows.length
|
|
553
|
+
const canScroll = totalWanted > maxCascadeRows
|
|
554
|
+
// Clamp scroll offset to valid range [0, totalWanted - maxCascadeRows]
|
|
555
|
+
const maxOffset = Math.max(0, totalWanted - maxCascadeRows)
|
|
556
|
+
const effectiveOffset = Math.min(scrollOffset, maxOffset)
|
|
557
|
+
const visibleRows = allCascadeRows.slice(effectiveOffset, effectiveOffset + maxCascadeRows)
|
|
558
|
+
|
|
559
|
+
// ▲ indicator: rows above the current viewport
|
|
560
|
+
if (canScroll && effectiveOffset > 0) {
|
|
561
|
+
emit(` ${dim(` ▲ ${effectiveOffset} row${effectiveOffset > 1 ? 's' : ''} above`)}`)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Render the visible slice — group by platform (emit a blank line between platforms)
|
|
565
|
+
let lastPlatform = null
|
|
566
|
+
for (const row of visibleRows) {
|
|
567
|
+
if (lastPlatform !== null && row.platform !== lastPlatform) emit()
|
|
568
|
+
renderRow(row.platform, (s) => row.winKey === firstWin[row.platform] ? cyan(s) : dim(s), row.winKey, row.pillars, row.estimated)
|
|
569
|
+
lastPlatform = row.platform
|
|
570
|
+
}
|
|
571
|
+
if (visibleRows.length > 0) emit()
|
|
572
|
+
|
|
573
|
+
// ▼ indicator: rows below the current viewport
|
|
574
|
+
const rowsBelow = totalWanted - effectiveOffset - visibleRows.length
|
|
575
|
+
if (canScroll && rowsBelow > 0) {
|
|
576
|
+
emit(` ${dim(` ▼ ${rowsBelow} row${rowsBelow > 1 ? 's' : ''} below · ↑↓ scroll`)}${effectiveOffset > 0 ? dim(` · ${effectiveOffset}/${maxOffset}`) : ''}`)
|
|
539
577
|
}
|
|
540
578
|
|
|
541
579
|
// combined
|
|
@@ -663,11 +701,14 @@ const TREND_METRICS = [
|
|
|
663
701
|
{ label: '10xDEV', pick: (c) => c.dev10x, fmt: (v) => v.toFixed(2) },
|
|
664
702
|
{ label: 'Eff', pick: (c) => c.yield != null ? c.yield / AA_EFF_BASELINE : null, fmt: (v) => v.toFixed(1) + '×' },
|
|
665
703
|
{ label: '§IGNA', pick: () => null, fmt: () => '—', stub: 'composite calibrating (needs user volume)' },
|
|
666
|
-
|
|
704
|
+
// FIX A4 (2026-06-27): operating ratio is INPUT-NORMALIZED `cache : 1 : output` (matches web canon —
|
|
705
|
+
// ThreeDegreesChart / OperatingRatioBar, e.g. "3.5 : 1 : 0.50"), not raw token counts (which also
|
|
706
|
+
// overflowed the Trends columns).
|
|
707
|
+
{ 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) => { const i = v.input || 1; return `${(v.cacheRead / i).toFixed(1)} : 1 : ${(v.output / i).toFixed(2)}` } },
|
|
667
708
|
{ label: '$/1M', pick: () => null, fmt: () => '—', stub: 'cost data wires post-ingest' },
|
|
668
709
|
]
|
|
669
710
|
function renderTrends(data, subIdx = 0) {
|
|
670
|
-
const { active } = data
|
|
711
|
+
const { active } = data ?? {}
|
|
671
712
|
const budget = H() - 4
|
|
672
713
|
let used = 0
|
|
673
714
|
const emit = (s = '') => { if (used < budget) { writeln(s); used++ } }
|
|
@@ -683,16 +724,23 @@ function renderTrends(data, subIdx = 0) {
|
|
|
683
724
|
|
|
684
725
|
if (sub === 'You' || sub === 'Platform') {
|
|
685
726
|
const rows = sub === 'You'
|
|
686
|
-
? (active
|
|
687
|
-
: active
|
|
727
|
+
? ((active?.length) ? [{ platform: 'you', windows: active[0]?.windows }] : []) // primary cascade
|
|
728
|
+
: (active ?? [])
|
|
688
729
|
if (!rows.length) { emit(` ${dim(' reading your cascade… (press [R] to refresh)')}`); }
|
|
689
|
-
|
|
690
|
-
|
|
730
|
+
// A3 (2026-06-27): count metric/rank rows dropped by the height budget so we can show a
|
|
731
|
+
// "+N more — resize / scroll" note instead of silently truncating. Each rendered block wants
|
|
732
|
+
// TREND_METRICS.length metric rows + 1 rank row. (Fit logic below unchanged — observe only.)
|
|
733
|
+
let trendsDropped = 0
|
|
734
|
+
for (let ri = 0; ri < rows.length; ri++) {
|
|
735
|
+
const d = rows[ri]
|
|
736
|
+
if (used >= budget - 2) { trendsDropped += (rows.length - ri) * (TREND_METRICS.length + 1); break }
|
|
691
737
|
emit(` ${bold(sub === 'You' ? 'Your trajectory' : cyan(d.platform))}`)
|
|
692
738
|
// FIX G1: window column headers (mirror renderCompare) — one header row, then
|
|
693
739
|
// values in fixed-width columns below. No per-value `w:` prefix.
|
|
694
740
|
emit(` ${trendHeader()}`)
|
|
695
|
-
|
|
741
|
+
let mi = 0
|
|
742
|
+
for (; mi < TREND_METRICS.length; mi++) {
|
|
743
|
+
const m = TREND_METRICS[mi]
|
|
696
744
|
if (used >= budget - 2) break
|
|
697
745
|
if (m.stub) {
|
|
698
746
|
emit(` ${padEnd(dim(m.label), 10)} ${dim('▁▁▁▁ calculating… (' + m.stub + ')')}`)
|
|
@@ -700,10 +748,16 @@ function renderTrends(data, subIdx = 0) {
|
|
|
700
748
|
emit(` ${padEnd(cyan(m.label), 10)} ${metricSpark(d, m.pick, m.fmt)}`)
|
|
701
749
|
}
|
|
702
750
|
}
|
|
751
|
+
trendsDropped += (TREND_METRICS.length - mi) // metrics this block couldn't fit
|
|
703
752
|
// rank trend — wired later; honest placeholder for now
|
|
704
753
|
if (used < budget - 1) emit(` ${padEnd(dim('Rank'), 10)} ${dim('▁▁▁▁ calculating… (rank history wires post-ingest)')}`)
|
|
754
|
+
else trendsDropped += 1
|
|
705
755
|
emit()
|
|
706
756
|
}
|
|
757
|
+
// A3: surface the dropped count instead of hiding it.
|
|
758
|
+
// SCROLL-VIEW (2026-06-27): changed "resize / scroll" → "resize to see more"
|
|
759
|
+
// because these tabs don't have a real scroll viewport (only Dashboard cascade does).
|
|
760
|
+
if (trendsDropped > 0) emit(` ${dim(` +${trendsDropped} more — resize to see more`)}`)
|
|
707
761
|
} else { // Field
|
|
708
762
|
emit(` ${dim(' Field-wide trends — calculating…')}`)
|
|
709
763
|
emit(` ${dim(' (needs per-window board history; materializes once live ingest lands)')}`)
|
|
@@ -807,12 +861,97 @@ function renderCompare(data) {
|
|
|
807
861
|
}
|
|
808
862
|
}
|
|
809
863
|
|
|
864
|
+
// ── TAB 3: BOARD — submissions view (#8) ──────────────────────────────────────
|
|
865
|
+
// Renders ALL submissions ranked (raw submission rows from GET /api/v1/submissions), not operator
|
|
866
|
+
// aggregates. Columns: rank · codename · platform · window · Υ Yield · op_ratio · class · tokens.
|
|
867
|
+
// Field names are read defensively (the endpoint is new) so a slightly different server shape still
|
|
868
|
+
// renders. [Y] "just me" filtering + the you-highlight carry over from the leaderboard view.
|
|
869
|
+
function renderSubmissions(boardData, window = '30d', filterCodename = null, highlightCodename = null) {
|
|
870
|
+
const rows = boardData?.submissions ?? boardData?.entries ?? boardData?.rows ??
|
|
871
|
+
(Array.isArray(boardData) ? boardData : [])
|
|
872
|
+
const w = W()
|
|
873
|
+
const budget = H() - 4
|
|
874
|
+
let used = 0
|
|
875
|
+
const emit = (s = '') => { if (used < budget) { writeln(s); used++ } }
|
|
876
|
+
|
|
877
|
+
// Defensive field readers (server shape is new / not yet pinned).
|
|
878
|
+
const gName = (e) => e.codename ?? e.operator_codename ?? '—'
|
|
879
|
+
const gPlat = (e) => e.platform ?? e.platform_primary ?? '—'
|
|
880
|
+
const gWin = (e) => e.window ?? e.window_type ?? '—'
|
|
881
|
+
const gYield = (e) => e.yield_ ?? e.yield ?? e.y ?? null
|
|
882
|
+
const gOpRatio = (e) => e.op_ratio ?? e.operating_ratio ?? e.opRatio ?? null
|
|
883
|
+
const gClass = (e) => e.class_tier ?? e.class ?? e.class_ ?? '—'
|
|
884
|
+
const gTokens = (e) => e.tokens ?? e.total_tokens ?? e.token_total ?? null
|
|
885
|
+
|
|
886
|
+
const modeLabel = filterCodename ? `${dim(' · just you')}` : highlightCodename ? `${dim(' · you highlighted')}` : ''
|
|
887
|
+
emit()
|
|
888
|
+
emit(` ${bold('Submissions')} ${dim(`window: ${window} · all submissions ranked by Υ Yield · signalaf.com`)}${modeLabel}`)
|
|
889
|
+
emit()
|
|
890
|
+
|
|
891
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
892
|
+
emit(` ${dim(' no submissions for this window')}`)
|
|
893
|
+
return
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
let sorted = [...rows].sort((a, b) => (gYield(b) ?? 0) - (gYield(a) ?? 0))
|
|
897
|
+
if (filterCodename) {
|
|
898
|
+
sorted = sorted.filter(e => (gName(e) ?? '').toLowerCase() === filterCodename.toLowerCase())
|
|
899
|
+
if (sorted.length === 0) {
|
|
900
|
+
emit(` ${dim(` you have no submissions in the ${window} window yet`)}`)
|
|
901
|
+
emit(` ${dim(' press [S] from a read tab to submit your cascade')}`)
|
|
902
|
+
return
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// op_ratio may arrive as a preformatted string ("3.5 : 1 : 0.50") or a number — render either.
|
|
907
|
+
const fmtOp = (v) => v == null ? '—' : (typeof v === 'string' ? v : (typeof v === 'number' ? v.toFixed(2) : String(v)))
|
|
908
|
+
|
|
909
|
+
const SH = [
|
|
910
|
+
padStart(hdr('#'), 4), padEnd(hdr('Codename'), 20), padEnd(hdr('Platform'), 10),
|
|
911
|
+
padEnd(hdr('Win'), 6), padStart(hdr('Υ Yield'), 9), padEnd(hdr('Op Ratio'), 18),
|
|
912
|
+
padEnd(hdr('Class'), 12), padStart(hdr('Tokens'), 9),
|
|
913
|
+
]
|
|
914
|
+
emit(` ${SH.join(' ')}`)
|
|
915
|
+
emit(` ${dim('·'.repeat(Math.min(w - 4, 100)))}`)
|
|
916
|
+
|
|
917
|
+
let shown = 0
|
|
918
|
+
for (let idx = 0; idx < sorted.length; idx++) {
|
|
919
|
+
if (used >= budget) break
|
|
920
|
+
const e = sorted[idx]
|
|
921
|
+
const isYou = highlightCodename && (gName(e) ?? '').toLowerCase() === highlightCodename.toLowerCase()
|
|
922
|
+
const rk = idx === 0 ? gold(`#${idx+1}`) : idx < 3 ? cyan(`#${idx+1}`) : `#${idx+1}`
|
|
923
|
+
const nmRaw = trunc(gName(e), 20)
|
|
924
|
+
const nm = isYou
|
|
925
|
+
? `${c.bgCyan}${c.boldCyan}${padEnd(` ${trunc(gName(e), 18)} `, 20)}${c.reset}`
|
|
926
|
+
: padEnd(nmRaw, 20)
|
|
927
|
+
const yv = gYield(e)
|
|
928
|
+
const yld = padStart(yv != null ? (yv > 10000 ? gold(fmtY(yv)) : fmtY(yv)) : '—', 9)
|
|
929
|
+
const cls = padEnd(colorCls(gClass(e)), 12)
|
|
930
|
+
const tk = gTokens(e)
|
|
931
|
+
const youMark = isYou ? ` ${c.bgCyan}${c.boldCyan}YOU${c.reset}` : ''
|
|
932
|
+
emit(` ${padStart(rk, 4)} ${nm} ${padEnd(cyan(trunc(gPlat(e), 10)), 10)} ${padEnd(dim(trunc(gWin(e), 6)), 6)} ${yld} ${padEnd(dim(fmtOp(gOpRatio(e))), 18)} ${cls} ${padStart(tk != null ? fmtTok(tk) : '—', 9)}${youMark}`)
|
|
933
|
+
shown++
|
|
934
|
+
}
|
|
935
|
+
// A3-style: if the height budget dropped rows, say so instead of silently truncating.
|
|
936
|
+
// SCROLL-VIEW (2026-06-27): changed "resize / scroll" → "resize to see more"
|
|
937
|
+
// (Board tab doesn't have a real scroll viewport — only Dashboard cascade does).
|
|
938
|
+
const dropped = sorted.length - shown
|
|
939
|
+
if (dropped > 0 && used < budget) emit(` ${dim(` +${dropped} more — resize to see more`)}`)
|
|
940
|
+
}
|
|
941
|
+
|
|
810
942
|
// ── TAB 3: BOARD ─────────────────────────────────────────────────────────────
|
|
811
943
|
// FIX I2: hybrid board model. `filterCodename` (non-null = "just me" mode) shows
|
|
812
944
|
// ONLY the signed-in operator's rows. `highlightCodename` (non-null = global mode)
|
|
813
945
|
// highlights the signed-in operator's row in the global board. Both come from the
|
|
814
946
|
// signed-in identity's codename; the [Y] key toggles between the two modes.
|
|
815
947
|
function renderBoard(boardData, window = '30d', filterCodename = null, highlightCodename = null) {
|
|
948
|
+
// #8 (2026-06-27): when the data came from the NEW /api/v1/submissions endpoint, render ALL
|
|
949
|
+
// submissions ranked (raw rows). Otherwise fall through to the existing leaderboard render
|
|
950
|
+
// (pre-deploy fallback). loadBoardData tags the payload with `_source`.
|
|
951
|
+
if (boardData?._source === 'submissions') {
|
|
952
|
+
renderSubmissions(boardData, window, filterCodename, highlightCodename)
|
|
953
|
+
return
|
|
954
|
+
}
|
|
816
955
|
const entries = boardData?.entries ?? boardData?.operators ?? boardData ?? []
|
|
817
956
|
const w = W()
|
|
818
957
|
const budget = H() - 4
|
|
@@ -897,13 +1036,20 @@ function renderBoard(boardData, window = '30d', filterCodename = null, highlight
|
|
|
897
1036
|
// Explains what the live watcher does and why it's the point of the agent: it
|
|
898
1037
|
// re-reads your local logs on an interval and feeds your verified cascade to the
|
|
899
1038
|
// leaderboard. Launched (in its own window) with [Enter]; interval is tunable.
|
|
1039
|
+
// A5 (2026-06-27): Watch defaults to ALL active platforms × ALL windows (mirror the cli.mjs watch
|
|
1040
|
+
// change). `platform`/`win` are OPTIONAL focus filters — null/'all' means "everything". [P]/[W]
|
|
1041
|
+
// cycle the focus (including back to "all"); the spawned watcher + this copy show everything by default.
|
|
900
1042
|
function renderWatchInfo(platform, win, refresh) {
|
|
1043
|
+
const platLabel = (!platform || platform === 'all') ? 'all active platforms' : platform
|
|
1044
|
+
const winLabel = (!win || win === 'all-windows') ? 'all windows' : win
|
|
1045
|
+
const allDefault = platLabel === 'all active platforms' && winLabel === 'all windows'
|
|
901
1046
|
writeln()
|
|
902
1047
|
writeln(` ${bold('Live Watch')} ${dim('the agent that keeps your rank current')}`)
|
|
903
1048
|
writeln()
|
|
904
1049
|
writeln(` ${dim('What it does')}`)
|
|
905
|
-
writeln(` Re-reads your local ${cyan(
|
|
906
|
-
writeln(`
|
|
1050
|
+
writeln(` Re-reads your local token logs for ${cyan(platLabel)} across ${cyan(winLabel)} every`)
|
|
1051
|
+
writeln(` ${gold(refresh + 's')} and recomputes each cascade (Υ Yield · SNR · Leverage · class) live, on`)
|
|
1052
|
+
writeln(` this machine. ${dim('By default it watches everything — every platform you actually use.')}`)
|
|
907
1053
|
writeln()
|
|
908
1054
|
writeln(` ${dim('Why it matters')}`)
|
|
909
1055
|
writeln(` Watch is how the board stays ${bold('current')}: each refresh submits your latest`)
|
|
@@ -911,9 +1057,10 @@ function renderWatchInfo(platform, win, refresh) {
|
|
|
911
1057
|
writeln(` no manual re-submit. Tokens never leave your machine; only the metrics post.`)
|
|
912
1058
|
writeln()
|
|
913
1059
|
writeln(` ${dim('Settings')}`)
|
|
914
|
-
writeln(` ${dim('Platform')} ${cyan(
|
|
1060
|
+
writeln(` ${dim('Platform')} ${cyan(platLabel)} ${dim('Window')} ${cyan(winLabel)} ${dim('Refresh')} ${gold(refresh + 's')}`)
|
|
1061
|
+
writeln(` ${dim(allDefault ? 'watching everything (default)' : 'focused — press [P]/[W] to widen back to all')}`)
|
|
915
1062
|
writeln()
|
|
916
|
-
writeln(` ${dim('[Enter]')} launch watcher (new window) ${dim('[+]/[-]')} refresh ±5s ${dim('[P]')} platform ${dim('[W]')} window`)
|
|
1063
|
+
writeln(` ${dim('[Enter]')} launch watcher (new window) ${dim('[+]/[-]')} refresh ±5s ${dim('[P]')} platform focus ${dim('[W]')} window focus`)
|
|
917
1064
|
writeln()
|
|
918
1065
|
}
|
|
919
1066
|
|
|
@@ -946,74 +1093,66 @@ function renderConnect(id, codeBuf = '', msg = '') {
|
|
|
946
1093
|
if (msg) { writeln(); writeln(` ${msg}`) }
|
|
947
1094
|
}
|
|
948
1095
|
|
|
949
|
-
// ── TAB 4: WATCH
|
|
950
|
-
|
|
1096
|
+
// ── TAB 4: WATCH (live grid) ──────────────────────────────────────────────────
|
|
1097
|
+
// A5 (2026-06-27): renders ALL active platforms × ALL windows by default (mirror the cli.mjs
|
|
1098
|
+
// watch grid), instead of a single platform/window. `platform`/`win` are OPTIONAL focus filters —
|
|
1099
|
+
// null/'all'/'all-windows' means "everything". Each row is a (platform, window) cascade cell:
|
|
1100
|
+
// Υ Yield + SNR/Lev/Vel/class. Iterates active platforms (input+output > 0 in any window).
|
|
1101
|
+
async function renderWatch(platform = 'all', win = 'all-windows') {
|
|
951
1102
|
const { tokenpullAny } = await import('./tokenpull.mjs')
|
|
952
|
-
const
|
|
953
|
-
|
|
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 },
|
|
1103
|
+
const ALL_PLATFORMS = [
|
|
1104
|
+
'claude','codex','amp','gemini','kimi','qwen','goose','kilo',
|
|
1105
|
+
'hermes','droid','codebuff','copilot','openclaw','pi',
|
|
978
1106
|
]
|
|
1107
|
+
const platFilter = (platform && platform !== 'all') ? platform : null
|
|
1108
|
+
const winFilter = (win && win !== 'all-windows') ? win : null
|
|
1109
|
+
const WINS = winFilter ? [winFilter] : ['7d', '30d', '90d', 'all']
|
|
1110
|
+
const w = W()
|
|
1111
|
+
const budget = H() - 4
|
|
1112
|
+
let used = 0
|
|
1113
|
+
const emit = (s = '') => { if (used < budget) { writeln(s); used++ } }
|
|
979
1114
|
|
|
980
|
-
|
|
981
|
-
|
|
1115
|
+
// Detect active platforms (any window with input+output > 0), respecting an optional [P] focus.
|
|
1116
|
+
const candidates = platFilter ? [platFilter] : ALL_PLATFORMS
|
|
1117
|
+
const settled = await Promise.allSettled(candidates.map(p => tokenpullAny(p)))
|
|
1118
|
+
const active = []
|
|
1119
|
+
for (const r of settled) {
|
|
1120
|
+
if (r.status !== 'fulfilled' || !r.value) continue
|
|
1121
|
+
const d = r.value
|
|
1122
|
+
if ((d.windows || []).some(ww => (ww.pillars.input + ww.pillars.output) > 0)) active.push(d)
|
|
982
1123
|
}
|
|
983
|
-
|
|
1124
|
+
// stable display order
|
|
1125
|
+
active.sort((a, b) => ALL_PLATFORMS.indexOf(a.platform) - ALL_PLATFORMS.indexOf(b.platform))
|
|
984
1126
|
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
writeln()
|
|
1127
|
+
const scopeLabel = (platFilter || 'all active platforms') + ' · ' + (winFilter ? `${winFilter} window` : 'all windows')
|
|
1128
|
+
emit()
|
|
1129
|
+
emit(` ${bold('Live Watch')} ${dim(scopeLabel)}`)
|
|
1130
|
+
emit(` ${dim('·'.repeat(Math.min(w - 4, 80)))}`)
|
|
990
1131
|
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
{
|
|
994
|
-
|
|
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)}`)
|
|
1132
|
+
if (active.length === 0) {
|
|
1133
|
+
emit()
|
|
1134
|
+
emit(` ${dim(' no active platforms detected — run some sessions, this picks them up automatically')}`)
|
|
1135
|
+
return
|
|
1006
1136
|
}
|
|
1007
|
-
writeln(` ${dim('log₁₀ scale — each step = 10× · absolute values right')}`)
|
|
1008
|
-
writeln()
|
|
1009
1137
|
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1138
|
+
for (const d of active) {
|
|
1139
|
+
if (used >= budget - 1) break
|
|
1140
|
+
const byWin = {}
|
|
1141
|
+
for (const ww of (d.windows || [])) byWin[ww.window] = ww
|
|
1142
|
+
emit()
|
|
1143
|
+
emit(` ${cyan(d.platform)}${d.estimated ? dim(' (est)') : ''}`)
|
|
1144
|
+
for (const wk of WINS) {
|
|
1145
|
+
if (used >= budget - 1) break
|
|
1146
|
+
const ww = byWin[wk]
|
|
1147
|
+
const cas = ww ? cascadeFrom(ww.pillars) : null
|
|
1148
|
+
const winLabel = wk === 'all' ? 'all-time' : wk
|
|
1149
|
+
if (!cas) { emit(` ${padEnd(dim(winLabel), 9)} ${dim('no data')}`); continue }
|
|
1150
|
+
const clsFn = CLS[cas.class] ?? ((s) => s)
|
|
1151
|
+
const yDisplay = cas.yield > 1000 ? gold(fmtY(cas.yield)) : cyan(fmtY(cas.yield))
|
|
1152
|
+
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)}`
|
|
1153
|
+
emit(` ${padEnd(dim(winLabel), 9)} ${bold('Υ')} ${yDisplay} ${metrics}`)
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1017
1156
|
}
|
|
1018
1157
|
|
|
1019
1158
|
// ── SUBMIT PREVIEW (FIX I1 / FIX E) — see→confirm→send ───────────────────────
|
|
@@ -1070,6 +1209,7 @@ async function renderOnce(tabIdx = 0) {
|
|
|
1070
1209
|
if (tabIdx === 0) renderDashboard(data, 'debug')
|
|
1071
1210
|
else if (tabIdx === 1) renderCompare(data)
|
|
1072
1211
|
else if (tabIdx === 2) renderBoard(data, '30d')
|
|
1212
|
+
else if (tabIdx === 4) await renderWatch('all', 'all-windows')
|
|
1073
1213
|
else if (tabIdx === 5) renderConnect(loadIdentity(), '', '')
|
|
1074
1214
|
const lines = _screenBuf || []
|
|
1075
1215
|
_screenBuf = null; _footerBuf = null
|
|
@@ -1201,8 +1341,11 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
|
|
|
1201
1341
|
let compareData = null
|
|
1202
1342
|
let boardData = null
|
|
1203
1343
|
let boardWindow = '30d'
|
|
1204
|
-
|
|
1205
|
-
|
|
1344
|
+
// A5 (2026-06-27): Watch defaults to "all" focus — all active platforms × all windows. [P]/[W]
|
|
1345
|
+
// optionally narrow the focus and cycle back to 'all'. ('all-windows' is the window sentinel so
|
|
1346
|
+
// it never collides with the real 'all' window bucket in the cli watcher's optional --window.)
|
|
1347
|
+
let watchPlatform = 'all'
|
|
1348
|
+
let watchWindow = 'all-windows'
|
|
1206
1349
|
let watchRefresh = 30 // [5] Watch poll interval (seconds) — [+]/[-] adjust
|
|
1207
1350
|
let trendSub = 0 // [2] Trends sub-view index (You/Platform/Field) — [T] cycles
|
|
1208
1351
|
let loading = true
|
|
@@ -1213,6 +1356,7 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
|
|
|
1213
1356
|
let submitMsg = '' // [S] in-place submit result, shown in the read-tab footer
|
|
1214
1357
|
let submitPreview = false // FIX I1: [S] opens a preview grid before sending (see→confirm→send)
|
|
1215
1358
|
let boardYouOnly = false // FIX I2: [Y] toggles the Board to "just me" (hybrid model)
|
|
1359
|
+
let cascadeScroll = 0 // SCROLL-VIEW (2026-06-27): cascade-section scroll offset (Dashboard only)
|
|
1216
1360
|
|
|
1217
1361
|
// ── Redraw (buffered: renders into memory, then paints as a locked frame)
|
|
1218
1362
|
const redraw = async () => {
|
|
@@ -1239,9 +1383,13 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
|
|
|
1239
1383
|
if (!dashData) {
|
|
1240
1384
|
writeln(`\n ${dim('loading dashboard…')}`)
|
|
1241
1385
|
} else {
|
|
1242
|
-
renderDashboard(dashData, status)
|
|
1386
|
+
renderDashboard(dashData, status, cascadeScroll)
|
|
1243
1387
|
}
|
|
1244
|
-
|
|
1388
|
+
// SCROLL-VIEW: show ↑↓ hint only when there are cascade rows to scroll
|
|
1389
|
+
const scrollHint = dashData && Array.isArray(dashData.active)
|
|
1390
|
+
? cascadeScrollableCount(dashData.active) > maxCascadeRowsFor(dashData.active) ? ` ${dim('↑↓ scroll cascade')}` : ''
|
|
1391
|
+
: ''
|
|
1392
|
+
setFooter(readFooter(`${hint}${scrollHint}${submitHint}`))
|
|
1245
1393
|
} else if (activeTab === 1) { // Trends
|
|
1246
1394
|
if (!dashData) {
|
|
1247
1395
|
writeln(`\n ${dim('loading trends…')}`)
|
|
@@ -1251,7 +1399,11 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
|
|
|
1251
1399
|
setFooter(readFooter(`${hint} ${dim('[T]')} view${submitHint}`))
|
|
1252
1400
|
} else if (activeTab === 2) { // Compare
|
|
1253
1401
|
if (!compareData) {
|
|
1254
|
-
|
|
1402
|
+
// #9 (2026-06-27): Compare runs a FRESH on-demand pull of the external verifier sources
|
|
1403
|
+
// (ccusage + tokscale + token-dashboard rescan) — it can take several seconds. Show a clear
|
|
1404
|
+
// spinner line while it runs, instead of a bare "loading…".
|
|
1405
|
+
writeln(`\n ${bold('Source Comparison')} ${dim(`platform: ${platform}`)}`)
|
|
1406
|
+
writeln(`\n ${gold('◇')} ${dim(`pulling fresh sources… (ccusage · tokscale · token-dash — this can take several seconds)`)}`)
|
|
1255
1407
|
} else {
|
|
1256
1408
|
renderCompare(compareData)
|
|
1257
1409
|
}
|
|
@@ -1282,22 +1434,33 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
|
|
|
1282
1434
|
|
|
1283
1435
|
// ── Initial data load
|
|
1284
1436
|
const loadAll = async () => {
|
|
1285
|
-
|
|
1437
|
+
// A2 (2026-06-27): paint the frame + a clear "reading your cascade…" status IMMEDIATELY,
|
|
1438
|
+
// BEFORE the await — so the very first thing on screen is the table header + a live status,
|
|
1439
|
+
// not a blank "loading dashboard…". renderDashboard tolerates this empty dashData (guarded).
|
|
1440
|
+
status = 'reading your cascade…'
|
|
1441
|
+
dashData = { active: [], _loading: true }
|
|
1442
|
+
await redraw()
|
|
1286
1443
|
// FIX 0: progressive Dashboard load — claude first (fast), render it, THEN fill
|
|
1287
1444
|
// the other 13 platforms in the background + redraw. The user sees their cascade
|
|
1288
1445
|
// within ~1 read instead of a 7s blank "loading dashboard…".
|
|
1289
1446
|
dashData = await loadDashboardData().catch(e => { status = `dashboard error: ${e.message}`; return null })
|
|
1290
1447
|
status = `last refreshed ${new Date().toLocaleTimeString('en-US', { hour12: false })}`
|
|
1291
1448
|
await redraw()
|
|
1292
|
-
//
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1449
|
+
// Keep a "filling other platforms…" status visible while fillDashboardRest runs.
|
|
1450
|
+
// #9 (2026-06-27): Compare is NO LONGER eagerly loaded here — loadCompareData now runs the
|
|
1451
|
+
// FRESH verifier pull (ccusage + tokscale + token-dash rescan, 5–60s), which would re-introduce
|
|
1452
|
+
// a slow Dashboard load path. Compare loads lazily ON-DEMAND when the Compare tab is opened.
|
|
1453
|
+
// Board still preloads (fast, cached) in parallel with the platform fill.
|
|
1297
1454
|
if (dashData?._remaining) {
|
|
1298
|
-
|
|
1455
|
+
status = 'claude ready · filling other platforms…'
|
|
1299
1456
|
await redraw()
|
|
1300
1457
|
}
|
|
1458
|
+
;[, boardData] = await Promise.all([
|
|
1459
|
+
dashData?._remaining ? fillDashboardRest(dashData) : Promise.resolve(false),
|
|
1460
|
+
loadBoardData(boardWindow).catch(() => null),
|
|
1461
|
+
])
|
|
1462
|
+
status = `last refreshed ${new Date().toLocaleTimeString('en-US', { hour12: false })}`
|
|
1463
|
+
await redraw()
|
|
1301
1464
|
}
|
|
1302
1465
|
|
|
1303
1466
|
// Always restore terminal on unexpected exit
|
|
@@ -1469,6 +1632,33 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
|
|
|
1469
1632
|
return
|
|
1470
1633
|
}
|
|
1471
1634
|
|
|
1635
|
+
// SCROLL-VIEW (2026-06-27): ↑/↓ (or j/k) scroll the cascade section on the
|
|
1636
|
+
// Dashboard tab ONLY. The rest of the dashboard stays pinned. Only active
|
|
1637
|
+
// when there are more cascade rows than fit in the viewport.
|
|
1638
|
+
if (activeTab === 0 && dashData && Array.isArray(dashData.active)) {
|
|
1639
|
+
const total = cascadeScrollableCount(dashData.active)
|
|
1640
|
+
const maxRows = maxCascadeRowsFor(dashData.active)
|
|
1641
|
+
if (total > maxRows) {
|
|
1642
|
+
const maxOff = total - maxRows
|
|
1643
|
+
if (key === '\x1b[A' || k === 'k') { // ↑ / k → scroll up
|
|
1644
|
+
cascadeScroll = Math.max(0, cascadeScroll - 1)
|
|
1645
|
+
await redraw(); return
|
|
1646
|
+
}
|
|
1647
|
+
if (key === '\x1b[B' || k === 'j') { // ↓ / j → scroll down
|
|
1648
|
+
cascadeScroll = Math.min(maxOff, cascadeScroll + 1)
|
|
1649
|
+
await redraw(); return
|
|
1650
|
+
}
|
|
1651
|
+
if (key === '\x1b[5~') { // PageUp → scroll up by viewport
|
|
1652
|
+
cascadeScroll = Math.max(0, cascadeScroll - maxRows)
|
|
1653
|
+
await redraw(); return
|
|
1654
|
+
}
|
|
1655
|
+
if (key === '\x1b[6~') { // PageDown → scroll down by viewport
|
|
1656
|
+
cascadeScroll = Math.min(maxOff, cascadeScroll + maxRows)
|
|
1657
|
+
await redraw(); return
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1472
1662
|
// tab switching (6 tabs: 0=Dashboard 1=Trends 2=Compare 3=Board 4=Watch 5=Connect)
|
|
1473
1663
|
let switched = false
|
|
1474
1664
|
if (key === '\x1b[C') { activeTab = Math.min(5, activeTab + 1); switched = true }
|
|
@@ -1481,6 +1671,20 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
|
|
|
1481
1671
|
if (k === '6') { activeTab = 5; switched = true } // Connect = sign in / switch device
|
|
1482
1672
|
if (k === 'c' && activeTab !== 5) { activeTab = 5; switched = true } // [C] → Connect from any read tab
|
|
1483
1673
|
|
|
1674
|
+
// SCROLL-VIEW: reset cascade scroll when leaving the Dashboard tab
|
|
1675
|
+
if (switched && activeTab !== 0) cascadeScroll = 0
|
|
1676
|
+
|
|
1677
|
+
// #9 (2026-06-27): Compare loads its FRESH verifier pull ON-DEMAND when the tab opens
|
|
1678
|
+
// (NOT on the Dashboard load path — that pull is a 5–60s scan). Paint the "pulling fresh
|
|
1679
|
+
// sources…" spinner first (compareData null → redraw shows it), then run the pull + re-render.
|
|
1680
|
+
if (switched && activeTab === 2 && !compareData) {
|
|
1681
|
+
submitMsg = ''
|
|
1682
|
+
await redraw() // show the spinner line before the (slow) fresh pull
|
|
1683
|
+
compareData = await loadCompareData(platform).catch(() => null)
|
|
1684
|
+
await redraw()
|
|
1685
|
+
return
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1484
1688
|
// Trends tab: [T] cycles the sub-view (You · Platform · Field)
|
|
1485
1689
|
if (activeTab === 1 && k === 't') { trendSub = (trendSub + 1) % 3; await redraw(); return }
|
|
1486
1690
|
|
|
@@ -1492,36 +1696,48 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
|
|
|
1492
1696
|
// Compare reloads compareData for the new platform; Watch just re-renders.
|
|
1493
1697
|
const CYCLE_PLATFORMS = ['claude','codex','amp','gemini','kimi','qwen','goose','kilo','hermes','droid','codebuff','copilot','openclaw','pi']
|
|
1494
1698
|
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
1699
|
if (activeTab === 2) {
|
|
1499
|
-
platform
|
|
1700
|
+
// Compare: real platforms only (no 'all' — Compare is per-platform by design).
|
|
1701
|
+
const idx = CYCLE_PLATFORMS.indexOf(platform)
|
|
1702
|
+
platform = CYCLE_PLATFORMS[(idx + 1) % CYCLE_PLATFORMS.length]
|
|
1703
|
+
// #9: clearing compareData makes redraw show the "pulling fresh sources…" spinner
|
|
1704
|
+
// for the new platform while the fresh on-demand verifier pull runs.
|
|
1705
|
+
compareData = null
|
|
1500
1706
|
status = `loading ${platform}…`
|
|
1501
1707
|
await redraw()
|
|
1502
1708
|
compareData = await loadCompareData(platform).catch(() => null)
|
|
1503
1709
|
status = `last refreshed ${new Date().toLocaleTimeString('en-US', { hour12: false })}`
|
|
1504
1710
|
} else {
|
|
1505
|
-
|
|
1711
|
+
// A5: Watch [P] cycles 'all' → claude → codex → … → 'all' (optional focus; default 'all').
|
|
1712
|
+
const WATCH_PLAT_CYCLE = ['all', ...CYCLE_PLATFORMS]
|
|
1713
|
+
const idx = WATCH_PLAT_CYCLE.indexOf(watchPlatform)
|
|
1714
|
+
watchPlatform = WATCH_PLAT_CYCLE[(idx + 1) % WATCH_PLAT_CYCLE.length]
|
|
1506
1715
|
}
|
|
1507
1716
|
await redraw(); return
|
|
1508
1717
|
}
|
|
1509
1718
|
|
|
1510
|
-
// FIX J: [W] cycles window on Watch tab (
|
|
1511
|
-
// [W] (activeTab === 3) is handled below
|
|
1719
|
+
// FIX J / A5: [W] cycles window FOCUS on the Watch tab. 'all-windows' (default) → 7d → 30d →
|
|
1720
|
+
// 90d → all → back to 'all-windows'. Board tab's [W] (activeTab === 3) is handled below.
|
|
1512
1721
|
if (k === 'w' && activeTab === 4) {
|
|
1513
|
-
const
|
|
1514
|
-
const idx =
|
|
1515
|
-
watchWindow =
|
|
1722
|
+
const WATCH_WIN_CYCLE = ['all-windows', '7d', '30d', '90d', 'all']
|
|
1723
|
+
const idx = WATCH_WIN_CYCLE.indexOf(watchWindow)
|
|
1724
|
+
watchWindow = WATCH_WIN_CYCLE[(idx + 1) % WATCH_WIN_CYCLE.length]
|
|
1516
1725
|
await redraw(); return
|
|
1517
1726
|
}
|
|
1518
1727
|
|
|
1519
1728
|
if (activeTab === 4 && (key === '\r' || key === '\n')) {
|
|
1520
|
-
//
|
|
1729
|
+
// A5 (2026-06-27): launch the live watcher in its own window. By default (focus = 'all')
|
|
1730
|
+
// pass NO --platform/--window so the cli watcher auto-loads ALL active platforms × ALL
|
|
1731
|
+
// windows. Only add a filter flag when the user has set an explicit [P]/[W] focus.
|
|
1521
1732
|
try {
|
|
1522
|
-
|
|
1733
|
+
let watchCmd = 'sigrank watch'
|
|
1734
|
+
if (watchPlatform && watchPlatform !== 'all') watchCmd += ` --platform ${watchPlatform}`
|
|
1735
|
+
if (watchWindow && watchWindow !== 'all-windows') watchCmd += ` --window ${watchWindow}`
|
|
1736
|
+
watchCmd += ` --refresh ${watchRefresh}`
|
|
1523
1737
|
execSync(`osascript -e 'tell application "Terminal" to do script "${watchCmd}"'`, { stdio: 'ignore' })
|
|
1524
|
-
|
|
1738
|
+
const scope = (!watchPlatform || watchPlatform === 'all') && (!watchWindow || watchWindow === 'all-windows')
|
|
1739
|
+
? 'all platforms × all windows' : 'focused'
|
|
1740
|
+
status = `watcher launched (${scope}, ${watchRefresh}s) in a new window`
|
|
1525
1741
|
} catch { status = 'could not open Terminal.app — run: sigrank watch' }
|
|
1526
1742
|
await redraw()
|
|
1527
1743
|
return
|
|
@@ -1529,6 +1745,17 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
|
|
|
1529
1745
|
|
|
1530
1746
|
if (k === 'r') {
|
|
1531
1747
|
status = 'refreshing…'
|
|
1748
|
+
cascadeScroll = 0 // SCROLL-VIEW: reset scroll on refresh
|
|
1749
|
+
// #9: on the Compare tab, [R] re-runs the FRESH verifier pull for the current platform
|
|
1750
|
+
// (loadAll no longer touches Compare). Clearing compareData shows the spinner first.
|
|
1751
|
+
if (activeTab === 2) {
|
|
1752
|
+
compareData = null
|
|
1753
|
+
await redraw()
|
|
1754
|
+
compareData = await loadCompareData(platform).catch(() => null)
|
|
1755
|
+
status = `last refreshed ${new Date().toLocaleTimeString('en-US', { hour12: false })}`
|
|
1756
|
+
await redraw()
|
|
1757
|
+
return
|
|
1758
|
+
}
|
|
1532
1759
|
await redraw()
|
|
1533
1760
|
await loadAll()
|
|
1534
1761
|
return
|