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/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 (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
- }
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 [claudeResult] = await Promise.all([
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
- const verifierMap = {}
316
- for (const platform of ALL_PLATFORMS) {
317
- verifierMap[platform] = { cc: ccusagePillars(platform), ts: tokscalePillars(platform) }
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: tokenDashPillars(), boardData, _loading: true, _remaining: ALL_PLATFORMS.slice(1) }
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
- const cc = ccusagePillars(platform)
348
- const ts = tokscalePillars(platform)
349
- const td = platform === 'claude' ? tokenDashPillars() : null
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
- 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()
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
- function renderDashboard(data, status = '') {
458
- const { active, verifierMap, tdPillars, boardData } = data
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
- let cascadeRowCount = 0
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
- renderRow(d.platform, (s) => wk === firstWin[d.platform] ? cyan(s) : dim(s), wk, wd.pillars, d.estimated)
536
- cascadeRowCount++
548
+ allCascadeRows.push({ platform: d.platform, winKey: wk, pillars: wd.pillars, estimated: d.estimated })
537
549
  }
538
- emit()
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
- { 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)}` },
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.length ? [{ platform: 'you', windows: active[0]?.windows }] : []) // primary cascade
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
- for (const d of rows) {
690
- if (used >= budget - 2) break
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
- for (const m of TREND_METRICS) {
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(platform)} token logs every ${gold(refresh + 's')} and recomputes`)
906
- writeln(` your cascade (Υ Yield · SNR · Leverage · class) live, on this machine.`)
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(platform)} ${dim('Window')} ${cyan(win)} ${dim('Refresh')} ${gold(refresh + 's')}`)
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
- async function renderWatch(platform = 'claude', win = '7d') {
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 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 },
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
- for (const m of metrics) {
981
- writeln(` ${padEnd(dim(m.label), 12)} ${paint(m.color, m.val ?? '—')}`)
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
- writeln()
1124
+ // stable display order
1125
+ active.sort((a, b) => ALL_PLATFORMS.indexOf(a.platform) - ALL_PLATFORMS.indexOf(b.platform))
984
1126
 
985
- // token pillars
986
- writeln(` ${hr()}`)
987
- writeln()
988
- writeln(` ${bold('Token Pillars')} ${dim(`${win} window`)}`)
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
- 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)}`)
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
- // 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()
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
- let watchPlatform = platform
1205
- let watchWindow = win
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
- setFooter(readFooter(`${hint}${submitHint}`))
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
- writeln(`\n ${dim(`loading compare (${platform})…`)}`)
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
- status = 'loading…'
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
- // 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
- ])
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
- await fillDashboardRest(dashData)
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 = next
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
- watchPlatform = next
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 (was a dead hinted key). Board tab's
1511
- // [W] (activeTab === 3) is handled below — it reloads boardData.
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 windows = ['7d', '30d', '90d', 'all']
1514
- const idx = windows.indexOf(watchWindow)
1515
- watchWindow = windows[(idx + 1) % windows.length]
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
- // Launch the live watcher in its own window, passing the chosen refresh interval.
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
- const watchCmd = `sigrank watch --platform ${watchPlatform} --window ${watchWindow} --refresh ${watchRefresh}`
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
- status = `watcher launched (${watchRefresh}s) in a new window`
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