sigrank 0.12.0 → 0.12.2
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 +33 -16
- package/package.json +1 -1
- package/tokenpull.mjs +37 -14
- package/tools.mjs +6 -0
- package/tui.mjs +139 -93
package/README.md
CHANGED
|
@@ -67,7 +67,7 @@ sigrank
|
|
|
67
67
|
## Commands
|
|
68
68
|
|
|
69
69
|
```
|
|
70
|
-
⊙ SigRank CLI v0.
|
|
70
|
+
⊙ SigRank CLI v0.12.1
|
|
71
71
|
|
|
72
72
|
Default (no args)
|
|
73
73
|
sigrank unified dashboard: cascade + token pillars + board
|
|
@@ -258,7 +258,7 @@ All adapters are token-only (no message content, no cost fields, no credentials)
|
|
|
258
258
|
| Var | Default | Description |
|
|
259
259
|
|---|---|---|
|
|
260
260
|
| `SIGRANK_API_BASE` | `https://signalaf.com` | Override the board host |
|
|
261
|
-
| `
|
|
261
|
+
| `SIGRANK_FETCH_TIMEOUT` | `10000` | Board API fetch timeout (ms) |
|
|
262
262
|
|
|
263
263
|
---
|
|
264
264
|
|
package/cli.mjs
CHANGED
|
@@ -396,6 +396,16 @@ async function runMe({ platform = 'claude', compare = false } = {}) {
|
|
|
396
396
|
|
|
397
397
|
// ── COMPARE command ───────────────────────────────────────────────────────────
|
|
398
398
|
// Side-by-side: ccusage (JSON) vs tokenpull vs token-dashboard (SQLite)
|
|
399
|
+
//
|
|
400
|
+
// NOTE (P3 2026-06-27): These sync verifier readers (ccusagePillars / tokscalePillars /
|
|
401
|
+
// tokenDashPillars / appPillars) are INTENTIONALLY separate from tokenpull.mjs
|
|
402
|
+
// `freshVerifierPillars()`. The compare command uses cached/file-based data sources
|
|
403
|
+
// (tokscale_report.json, direct db read with windowed+model-filtered queries) for a
|
|
404
|
+
// quick side-by-side, while freshVerifierPillars runs all three live (bunx tokscale,
|
|
405
|
+
// scan+read tokendash) for the dashboard. Merging them would change compare's data
|
|
406
|
+
// source and lose the windowed tokendash breakdown. ccusagePillars IS functionally
|
|
407
|
+
// identical to _freshCcusage and could be consolidated in a future pass if compare
|
|
408
|
+
// switches to live data — but that's a behavior change, not a refactor.
|
|
399
409
|
|
|
400
410
|
function ccusagePillars(platform = 'claude') {
|
|
401
411
|
// ccusage <platform> daily --json → sum by window
|
|
@@ -831,7 +841,7 @@ async function runSigRank() {
|
|
|
831
841
|
writeln(` ${gold('⊙ SigRank')} ${bold('Operator Dashboard')}`)
|
|
832
842
|
writeln(` ${dim('reading local data…')}`)
|
|
833
843
|
|
|
834
|
-
const { tokenpullAny } = await import('./tokenpull.mjs')
|
|
844
|
+
const { tokenpullAny, freshVerifierPillars } = await import('./tokenpull.mjs')
|
|
835
845
|
|
|
836
846
|
// Local sources first (fast) — board fetch with 5s timeout runs in parallel
|
|
837
847
|
const boardPromise = Promise.race([
|
|
@@ -839,24 +849,16 @@ async function runSigRank() {
|
|
|
839
849
|
new Promise(r => setTimeout(() => r(null), 5000)),
|
|
840
850
|
])
|
|
841
851
|
|
|
842
|
-
|
|
852
|
+
// FIX A1-CLI (2026-06-27): verifiers (ccusage/tokscale/tokendash) are fetched
|
|
853
|
+
// ON-DEMAND for ACTIVE platforms only — not synchronously for all 15 platforms
|
|
854
|
+
// upfront (which blocked Dashboard paint up to ~40s, same class of bug as TUI
|
|
855
|
+
// FIX A1). The sync ccusagePillars()/tokscalePillars()/tokenDashPillars() loop
|
|
856
|
+
// over ALL_PLATFORMS is replaced by parallel freshVerifierPillars() calls after
|
|
857
|
+
// we know which platforms actually have local data.
|
|
858
|
+
const [platformResults] = await Promise.all([
|
|
843
859
|
Promise.allSettled(ALL_PLATFORMS.map(p => tokenpullAny(p))),
|
|
844
|
-
Promise.resolve(tokenDashPillars()),
|
|
845
860
|
])
|
|
846
861
|
|
|
847
|
-
// per-platform verifiers: ccusage + tokscale both support multiple platforms
|
|
848
|
-
// build map: platform → { cc, ts }
|
|
849
|
-
const verifierMap = {}
|
|
850
|
-
for (const platform of ALL_PLATFORMS) {
|
|
851
|
-
verifierMap[platform] = {
|
|
852
|
-
cc: ccusagePillars(platform),
|
|
853
|
-
ts: tokscalePillars(platform),
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
// keep top-level claude refs for combined section
|
|
857
|
-
const ccPillars = verifierMap['claude']?.cc ?? null
|
|
858
|
-
const tsPillars = verifierMap['claude']?.ts ?? null
|
|
859
|
-
|
|
860
862
|
const boardData = await boardPromise
|
|
861
863
|
|
|
862
864
|
// filter to platforms with actual data
|
|
@@ -872,6 +874,21 @@ async function runSigRank() {
|
|
|
872
874
|
active.push(d)
|
|
873
875
|
}
|
|
874
876
|
|
|
877
|
+
// Fetch verifiers on-demand for active platforms only (parallel, never throws).
|
|
878
|
+
// tdPillars (token-dashboard) is claude-only and pulled from the claude fresh result.
|
|
879
|
+
const verifierMap = {}
|
|
880
|
+
let tdPillars = null
|
|
881
|
+
if (active.length > 0) {
|
|
882
|
+
const fresh = await Promise.all(
|
|
883
|
+
active.map(d => freshVerifierPillars(d.platform).catch(() => ({ ccusage: null, tokscale: null, tokendash: null })))
|
|
884
|
+
)
|
|
885
|
+
for (let i = 0; i < active.length; i++) {
|
|
886
|
+
const f = fresh[i]
|
|
887
|
+
verifierMap[active[i].platform] = { cc: f.ccusage, ts: f.tokscale }
|
|
888
|
+
if (active[i].platform === 'claude' && f.tokendash) tdPillars = f.tokendash
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
875
892
|
// clear the 3 loading lines (blank + header + "reading…")
|
|
876
893
|
write(CURSOR_UP(3) + ERASE_LINE + CURSOR_UP(1) + ERASE_LINE + CURSOR_UP(1) + ERASE_LINE)
|
|
877
894
|
|
package/package.json
CHANGED
package/tokenpull.mjs
CHANGED
|
@@ -17,13 +17,27 @@
|
|
|
17
17
|
|
|
18
18
|
import { readdir, readFile, lstat } from 'node:fs/promises'
|
|
19
19
|
import { existsSync } from 'node:fs'
|
|
20
|
-
import { execSync } from 'node:child_process'
|
|
20
|
+
import { execSync, execFile } from 'node:child_process'
|
|
21
21
|
import { join } from 'node:path'
|
|
22
22
|
import { homedir } from 'node:os'
|
|
23
23
|
import { ADAPTERS } from './adapters.mjs'
|
|
24
24
|
|
|
25
25
|
const DAY_MS = 86_400_000
|
|
26
26
|
|
|
27
|
+
// ASYNC FIX (2026-06-27): execFile wrapped in a Promise — replaces execSync in the
|
|
28
|
+
// fresh verifier readers. execSync blocks the entire Node event loop (no key handling,
|
|
29
|
+
// no screen repaint for up to 90s during the tokendash scan). execFile is async: the
|
|
30
|
+
// event loop keeps running, so the TUI stays responsive while external commands run.
|
|
31
|
+
// Returns stdout as a string. Rejects on error or timeout (caller catches → null).
|
|
32
|
+
function execFileAsync(cmd, args, timeoutMs) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
execFile(cmd, args, { timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
|
|
35
|
+
if (err) reject(err)
|
|
36
|
+
else resolve(stdout.toString())
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
27
41
|
// Background tooling that runs under your account but is NOT your work — memory plugins,
|
|
28
42
|
// observers, summarizers, sub-agent fleets. They pad output/Υ (claude-mem did ~27%).
|
|
29
43
|
// Excluded so pillars reflect the operator, not their tools. EXTENSIBLE — add tool
|
|
@@ -297,9 +311,13 @@ export async function tokenpullAny(platform, opts = {}) {
|
|
|
297
311
|
|
|
298
312
|
// ccusage: run `ccusage <platform> daily --json` and bucket the daily rows into the four
|
|
299
313
|
// windows by date. Logic copied from tools.mjs `_ccusagePillars` (do NOT import private fns).
|
|
300
|
-
|
|
314
|
+
//
|
|
315
|
+
// ASYNC FIX (2026-06-27): was execSync (blocked the entire event loop for up to 15s,
|
|
316
|
+
// freezing the TUI locked frame + key handling). Now uses execFile (async) so the
|
|
317
|
+
// event loop keeps running — the TUI stays responsive while the external command runs.
|
|
318
|
+
async function _freshCcusage(platform = 'claude') {
|
|
301
319
|
try {
|
|
302
|
-
const raw =
|
|
320
|
+
const raw = await execFileAsync('ccusage', [platform, 'daily', '--json'], 15000)
|
|
303
321
|
const rows = JSON.parse(raw)?.daily ?? JSON.parse(raw)
|
|
304
322
|
if (!Array.isArray(rows)) return null
|
|
305
323
|
const now = Date.now()
|
|
@@ -329,24 +347,26 @@ function _freshCcusage(platform = 'claude') {
|
|
|
329
347
|
// tokendash: REFRESH ~/.claude/token-dashboard.db by running the dashboard's scan, then read
|
|
330
348
|
// the live db via sqlite3. Claude only (the db only holds Claude Code sessions). The scan is
|
|
331
349
|
// best-effort: if it fails (tool missing, slow), we still read whatever the db currently has.
|
|
332
|
-
|
|
350
|
+
//
|
|
351
|
+
// ASYNC FIX (2026-06-27): was execSync with a 90s timeout — that blocked the ENTIRE event
|
|
352
|
+
// loop for up to 90 seconds, freezing the TUI completely (no key handling, no frame repaint).
|
|
353
|
+
// Now uses execFile (async) so the TUI stays responsive during the scan.
|
|
354
|
+
async function _freshTokendash(platform = 'claude') {
|
|
333
355
|
if (platform !== 'claude') return null
|
|
334
356
|
const dbPath = join(homedir(), '.claude', 'token-dashboard.db')
|
|
335
357
|
const scanCli = join(homedir(), 'Desktop', 'token-dashboard', 'cli.py')
|
|
336
358
|
// Step 1 — refresh the db (best-effort; failures must not abort the read).
|
|
337
359
|
if (existsSync(scanCli)) {
|
|
338
|
-
try {
|
|
339
|
-
|
|
340
|
-
} catch { /* continue: read whatever the db currently has */ }
|
|
360
|
+
try { await execFileAsync('python3', [scanCli, 'scan'], 90000) }
|
|
361
|
+
catch { /* continue: read whatever the db currently has */ }
|
|
341
362
|
}
|
|
342
363
|
// Step 2 — read the (now-refreshed) db. all-time only; the db doesn't expose windowing here.
|
|
343
364
|
if (!existsSync(dbPath)) return null
|
|
344
365
|
try {
|
|
345
|
-
const raw =
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
).
|
|
349
|
-
const [i, o, cw, cr] = raw.split('|').map(Number)
|
|
366
|
+
const raw = await execFileAsync('sqlite3', [dbPath,
|
|
367
|
+
'SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m_tokens)+SUM(cache_create_1h_tokens),SUM(cache_read_tokens) FROM messages'
|
|
368
|
+
], 5000)
|
|
369
|
+
const [i, o, cw, cr] = raw.trim().split('|').map(Number)
|
|
350
370
|
if (![i, o, cw, cr].some((n) => Number.isFinite(n) && n > 0)) return null
|
|
351
371
|
return { all: { input: i || 0, output: o || 0, cacheCreate: cw || 0, cacheRead: cr || 0 } }
|
|
352
372
|
} catch { return null }
|
|
@@ -358,9 +378,12 @@ function _freshTokendash(platform = 'claude') {
|
|
|
358
378
|
// `client` maps directly to our platform name. Synthetic/unknown model rows are dropped (they
|
|
359
379
|
// carry no real usage). all-time only — tokscale's models view is not windowed. If no row
|
|
360
380
|
// matches the platform, return null gracefully.
|
|
361
|
-
|
|
381
|
+
//
|
|
382
|
+
// ASYNC FIX (2026-06-27): was execSync with a 60s timeout — blocked the event loop entirely.
|
|
383
|
+
// Now uses execFile (async) so the TUI stays responsive.
|
|
384
|
+
async function _freshTokscale(platform = 'claude') {
|
|
362
385
|
try {
|
|
363
|
-
const raw =
|
|
386
|
+
const raw = await execFileAsync('bunx', ['tokscale@latest', 'models', '--json'], 60000)
|
|
364
387
|
const data = JSON.parse(raw)
|
|
365
388
|
const entries = Array.isArray(data?.entries) ? data.entries : (Array.isArray(data) ? data : [])
|
|
366
389
|
const rows = entries.filter((e) =>
|
package/tools.mjs
CHANGED
|
@@ -22,6 +22,12 @@ import path from 'node:path'
|
|
|
22
22
|
|
|
23
23
|
// ── Verifier readers (sync, on-device, token-only) ────────────────────────────
|
|
24
24
|
// These mirror the implementations in cli.mjs / tui.mjs without the circular import.
|
|
25
|
+
//
|
|
26
|
+
// NOTE (P3 2026-06-27): Intentionally separate from tokenpull.mjs `freshVerifierPillars()`.
|
|
27
|
+
// The MCP `tokenpull_compare` tool uses these file-based/cached readers (tokscale_report.json,
|
|
28
|
+
// direct db read) for a quick comparison, while freshVerifierPillars runs all sources live
|
|
29
|
+
// (bunx tokscale, scan+read tokendash) for the TUI/CLI dashboard. Different data sources =
|
|
30
|
+
// different behavior; do NOT merge without understanding the trade-off.
|
|
25
31
|
|
|
26
32
|
function _ccusagePillars(platform = 'claude') {
|
|
27
33
|
try {
|
package/tui.mjs
CHANGED
|
@@ -221,70 +221,13 @@ function sparkline(values) {
|
|
|
221
221
|
}).join('')
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
// ── Data sources
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const now = Date.now()
|
|
232
|
-
const cutoff = { '7d': 7, '30d': 30, '90d': 90 }
|
|
233
|
-
const result = {}
|
|
234
|
-
for (const [win, days] of Object.entries(cutoff)) {
|
|
235
|
-
const since = new Date(now - days * 86400000)
|
|
236
|
-
let i = 0, o = 0, cw = 0, cr = 0
|
|
237
|
-
for (const row of rows) {
|
|
238
|
-
if (new Date(row.date ?? row.day ?? '1970') >= since) {
|
|
239
|
-
i += row.inputTokens ?? row.input_tokens ?? 0
|
|
240
|
-
o += row.outputTokens ?? row.output_tokens ?? 0
|
|
241
|
-
cw += row.cacheCreationTokens ?? row.cache_create_tokens ?? 0
|
|
242
|
-
cr += row.cacheReadTokens ?? row.cache_read_tokens ?? 0
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
result[win] = { input: i, output: o, cacheCreate: cw, cacheRead: cr }
|
|
246
|
-
}
|
|
247
|
-
let i = 0, o = 0, cw = 0, cr = 0
|
|
248
|
-
for (const row of rows) {
|
|
249
|
-
i += row.inputTokens ?? 0
|
|
250
|
-
o += row.outputTokens ?? 0
|
|
251
|
-
cw += row.cacheCreationTokens ?? 0
|
|
252
|
-
cr += row.cacheReadTokens ?? 0
|
|
253
|
-
}
|
|
254
|
-
result['all'] = { input: i, output: o, cacheCreate: cw, cacheRead: cr }
|
|
255
|
-
return result
|
|
256
|
-
} catch { return null }
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
function tokscalePillars(platform = 'claude') {
|
|
260
|
-
const p = path.join(os.homedir(), 'tokscale_report.json')
|
|
261
|
-
if (!existsSync(p)) return null
|
|
262
|
-
try {
|
|
263
|
-
const data = JSON.parse(readFileSync(p, 'utf8'))
|
|
264
|
-
const rows = (data.entries ?? []).filter(e =>
|
|
265
|
-
e.client === platform && e.model !== '<synthetic>' && e.model !== 'unknown' && (e.input > 0 || e.output > 0)
|
|
266
|
-
)
|
|
267
|
-
if (!rows.length) return null
|
|
268
|
-
const acc = rows.reduce((a, e) => ({
|
|
269
|
-
input: a.input + (e.input ?? 0), output: a.output + (e.output ?? 0),
|
|
270
|
-
cacheCreate: a.cacheCreate + (e.cacheWrite ?? 0), cacheRead: a.cacheRead + (e.cacheRead ?? 0),
|
|
271
|
-
}), { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 })
|
|
272
|
-
return { all: acc }
|
|
273
|
-
} catch { return null }
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function tokenDashPillars() {
|
|
277
|
-
const dbPath = path.join(os.homedir(), '.claude', 'token-dashboard.db')
|
|
278
|
-
if (!existsSync(dbPath)) return null
|
|
279
|
-
try {
|
|
280
|
-
const raw = execSync(
|
|
281
|
-
`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"`,
|
|
282
|
-
{ timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] }
|
|
283
|
-
).toString().trim()
|
|
284
|
-
const [i, o, cw, cr] = raw.split('|').map(Number)
|
|
285
|
-
return { all: { input: i || 0, output: o || 0, cacheCreate: cw || 0, cacheRead: cr || 0 } }
|
|
286
|
-
} catch { return null }
|
|
287
|
-
}
|
|
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.)
|
|
288
231
|
|
|
289
232
|
async function loadDashboardData() {
|
|
290
233
|
const { tokenpullAny } = await import('./tokenpull.mjs')
|
|
@@ -474,7 +417,40 @@ function yieldSpark(d) {
|
|
|
474
417
|
}
|
|
475
418
|
|
|
476
419
|
// ── TAB 1: DASHBOARD ─────────────────────────────────────────────────────────
|
|
477
|
-
|
|
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) {
|
|
478
454
|
const w = W()
|
|
479
455
|
const budget = H() - 4 // 2 tab bar + 2 footer
|
|
480
456
|
const WINS = ['7d', '30d', '90d', 'all']
|
|
@@ -554,35 +530,51 @@ function renderDashboard(data, status = '') {
|
|
|
554
530
|
const sectionsBelow = barLines + boardLines
|
|
555
531
|
const maxCascadeRows = Math.max(8, budget - used - sectionsBelow)
|
|
556
532
|
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
const wd = d.windows?.find(x => x.window === wk)
|
|
563
|
-
return wd && (wd.pillars.input + wd.pillars.output) > 0
|
|
564
|
-
}).length, 0)
|
|
565
|
-
|
|
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.
|
|
566
538
|
let firstWin = {}
|
|
567
|
-
|
|
539
|
+
const allCascadeRows = [] // flat list: { platform, winKey, pillars, estimated }
|
|
568
540
|
for (const d of active) {
|
|
569
541
|
firstWin[d.platform] = WINS.find(wk => {
|
|
570
542
|
const wd = d.windows?.find(w => w.window === wk)
|
|
571
543
|
return wd && (wd.pillars.input + wd.pillars.output) > 0
|
|
572
544
|
})
|
|
573
545
|
for (const wk of WINS) {
|
|
574
|
-
if (cascadeRowCount >= maxCascadeRows || used >= budget - sectionsBelow) break
|
|
575
546
|
const wd = d.windows?.find(x => x.window === wk)
|
|
576
547
|
if (!wd || (wd.pillars.input + wd.pillars.output) === 0) continue
|
|
577
|
-
|
|
578
|
-
cascadeRowCount++
|
|
548
|
+
allCascadeRows.push({ platform: d.platform, winKey: wk, pillars: wd.pillars, estimated: d.estimated })
|
|
579
549
|
}
|
|
580
|
-
emit()
|
|
581
550
|
}
|
|
582
551
|
|
|
583
|
-
|
|
584
|
-
const
|
|
585
|
-
|
|
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}`) : ''}`)
|
|
577
|
+
}
|
|
586
578
|
|
|
587
579
|
// combined
|
|
588
580
|
if (active.length > 1 && used < budget - sectionsBelow) {
|
|
@@ -763,7 +755,9 @@ function renderTrends(data, subIdx = 0) {
|
|
|
763
755
|
emit()
|
|
764
756
|
}
|
|
765
757
|
// A3: surface the dropped count instead of hiding it.
|
|
766
|
-
|
|
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`)}`)
|
|
767
761
|
} else { // Field
|
|
768
762
|
emit(` ${dim(' Field-wide trends — calculating…')}`)
|
|
769
763
|
emit(` ${dim(' (needs per-window board history; materializes once live ingest lands)')}`)
|
|
@@ -939,8 +933,10 @@ function renderSubmissions(boardData, window = '30d', filterCodename = null, hig
|
|
|
939
933
|
shown++
|
|
940
934
|
}
|
|
941
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).
|
|
942
938
|
const dropped = sorted.length - shown
|
|
943
|
-
if (dropped > 0 && used < budget) emit(` ${dim(` +${dropped} more — resize
|
|
939
|
+
if (dropped > 0 && used < budget) emit(` ${dim(` +${dropped} more — resize to see more`)}`)
|
|
944
940
|
}
|
|
945
941
|
|
|
946
942
|
// ── TAB 3: BOARD ─────────────────────────────────────────────────────────────
|
|
@@ -1360,6 +1356,7 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
|
|
|
1360
1356
|
let submitMsg = '' // [S] in-place submit result, shown in the read-tab footer
|
|
1361
1357
|
let submitPreview = false // FIX I1: [S] opens a preview grid before sending (see→confirm→send)
|
|
1362
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)
|
|
1363
1360
|
|
|
1364
1361
|
// ── Redraw (buffered: renders into memory, then paints as a locked frame)
|
|
1365
1362
|
const redraw = async () => {
|
|
@@ -1386,9 +1383,13 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
|
|
|
1386
1383
|
if (!dashData) {
|
|
1387
1384
|
writeln(`\n ${dim('loading dashboard…')}`)
|
|
1388
1385
|
} else {
|
|
1389
|
-
renderDashboard(dashData, status)
|
|
1386
|
+
renderDashboard(dashData, status, cascadeScroll)
|
|
1390
1387
|
}
|
|
1391
|
-
|
|
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}`))
|
|
1392
1393
|
} else if (activeTab === 1) { // Trends
|
|
1393
1394
|
if (!dashData) {
|
|
1394
1395
|
writeln(`\n ${dim('loading trends…')}`)
|
|
@@ -1631,6 +1632,33 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
|
|
|
1631
1632
|
return
|
|
1632
1633
|
}
|
|
1633
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
|
+
|
|
1634
1662
|
// tab switching (6 tabs: 0=Dashboard 1=Trends 2=Compare 3=Board 4=Watch 5=Connect)
|
|
1635
1663
|
let switched = false
|
|
1636
1664
|
if (key === '\x1b[C') { activeTab = Math.min(5, activeTab + 1); switched = true }
|
|
@@ -1643,14 +1671,23 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
|
|
|
1643
1671
|
if (k === '6') { activeTab = 5; switched = true } // Connect = sign in / switch device
|
|
1644
1672
|
if (k === 'c' && activeTab !== 5) { activeTab = 5; switched = true } // [C] → Connect from any read tab
|
|
1645
1673
|
|
|
1674
|
+
// SCROLL-VIEW: reset cascade scroll when leaving the Dashboard tab
|
|
1675
|
+
if (switched && activeTab !== 0) cascadeScroll = 0
|
|
1676
|
+
|
|
1646
1677
|
// #9 (2026-06-27): Compare loads its FRESH verifier pull ON-DEMAND when the tab opens
|
|
1647
1678
|
// (NOT on the Dashboard load path — that pull is a 5–60s scan). Paint the "pulling fresh
|
|
1648
1679
|
// sources…" spinner first (compareData null → redraw shows it), then run the pull + re-render.
|
|
1680
|
+
// ASYNC FIX (2026-06-27): with execFile (async), the event loop keeps running during the
|
|
1681
|
+
// pull — key presses are NOT blocked. Guard: only render the result if the user is still
|
|
1682
|
+
// on the Compare tab (they may have switched away during the pull).
|
|
1649
1683
|
if (switched && activeTab === 2 && !compareData) {
|
|
1650
1684
|
submitMsg = ''
|
|
1651
1685
|
await redraw() // show the spinner line before the (slow) fresh pull
|
|
1652
|
-
|
|
1653
|
-
|
|
1686
|
+
const result = await loadCompareData(platform).catch(() => null)
|
|
1687
|
+
if (activeTab === 2) { // still on Compare? render the result
|
|
1688
|
+
compareData = result
|
|
1689
|
+
await redraw()
|
|
1690
|
+
}
|
|
1654
1691
|
return
|
|
1655
1692
|
}
|
|
1656
1693
|
|
|
@@ -1674,8 +1711,12 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
|
|
|
1674
1711
|
compareData = null
|
|
1675
1712
|
status = `loading ${platform}…`
|
|
1676
1713
|
await redraw()
|
|
1677
|
-
|
|
1678
|
-
|
|
1714
|
+
// ASYNC FIX: guard — only render if still on Compare (user may have switched away)
|
|
1715
|
+
const result = await loadCompareData(platform).catch(() => null)
|
|
1716
|
+
if (activeTab === 2) {
|
|
1717
|
+
compareData = result
|
|
1718
|
+
status = `last refreshed ${new Date().toLocaleTimeString('en-US', { hour12: false })}`
|
|
1719
|
+
}
|
|
1679
1720
|
} else {
|
|
1680
1721
|
// A5: Watch [P] cycles 'all' → claude → codex → … → 'all' (optional focus; default 'all').
|
|
1681
1722
|
const WATCH_PLAT_CYCLE = ['all', ...CYCLE_PLATFORMS]
|
|
@@ -1714,14 +1755,19 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
|
|
|
1714
1755
|
|
|
1715
1756
|
if (k === 'r') {
|
|
1716
1757
|
status = 'refreshing…'
|
|
1758
|
+
cascadeScroll = 0 // SCROLL-VIEW: reset scroll on refresh
|
|
1717
1759
|
// #9: on the Compare tab, [R] re-runs the FRESH verifier pull for the current platform
|
|
1718
1760
|
// (loadAll no longer touches Compare). Clearing compareData shows the spinner first.
|
|
1719
1761
|
if (activeTab === 2) {
|
|
1720
1762
|
compareData = null
|
|
1721
1763
|
await redraw()
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1764
|
+
// ASYNC FIX: guard — only render if still on Compare
|
|
1765
|
+
const result = await loadCompareData(platform).catch(() => null)
|
|
1766
|
+
if (activeTab === 2) {
|
|
1767
|
+
compareData = result
|
|
1768
|
+
status = `last refreshed ${new Date().toLocaleTimeString('en-US', { hour12: false })}`
|
|
1769
|
+
await redraw()
|
|
1770
|
+
}
|
|
1725
1771
|
return
|
|
1726
1772
|
}
|
|
1727
1773
|
await redraw()
|