free-coding-models 0.1.84 β†’ 0.1.85

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 CHANGED
@@ -72,7 +72,7 @@
72
72
  - **πŸš€ Parallel pings** β€” All models tested simultaneously via native `fetch`
73
73
  - **πŸ“Š Real-time animation** β€” Watch latency appear live in alternate screen buffer
74
74
  - **πŸ† Smart ranking** β€” Top 3 fastest models highlighted with medals πŸ₯‡πŸ₯ˆπŸ₯‰
75
- - **⏱ Continuous monitoring** β€” Pings all models every 3 seconds forever, never stops
75
+ - **⏱ Adaptive monitoring** β€” Starts in a fast 2s cadence for 60s, settles to 10s, slows to 30s after 5 minutes idle, and supports a forced 4s mode
76
76
  - **πŸ“ˆ Rolling averages** β€” Avg calculated from ALL successful pings since start
77
77
  - **πŸ“Š Uptime tracking** β€” Percentage of successful pings shown in real-time
78
78
  - **πŸ“ Stability score** β€” Composite 0–100 score measuring consistency (p95, jitter, spikes, uptime)
@@ -200,7 +200,7 @@ Use `↑↓` arrows to select, `Enter` to confirm. Then the TUI launches with yo
200
200
 
201
201
  **How it works:**
202
202
  1. **Ping phase** β€” All enabled models are pinged in parallel (up to 150 across 20 providers)
203
- 2. **Continuous monitoring** β€” Models are re-pinged every 3 seconds forever
203
+ 2. **Continuous monitoring** β€” Models start at 2s re-pings for 60s, then fall back to 10s automatically
204
204
  3. **Real-time updates** β€” Watch "Latest", "Avg", and "Up%" columns update live
205
205
  4. **Select anytime** β€” Use ↑↓ arrows to navigate, press Enter on a model to act
206
206
  5. **Smart detection** β€” Automatically detects if NVIDIA NIM is configured in OpenCode or OpenClaw
@@ -725,7 +725,7 @@ This script:
725
725
  β”‚ 1. Enter alternate screen buffer (like vim/htop/less) β”‚
726
726
  β”‚ 2. Ping ALL models in parallel β”‚
727
727
  β”‚ 3. Display real-time table with Latest/Avg/Stability/Up% β”‚
728
- β”‚ 4. Re-ping ALL models every 3 seconds (forever) β”‚
728
+ β”‚ 4. Re-ping ALL models at 2s on startup, then 10s steady-state β”‚
729
729
  β”‚ 5. Update rolling averages + stability scores per model β”‚
730
730
  β”‚ 6. User can navigate with ↑↓ and select with Enter β”‚
731
731
  β”‚ 7. On Enter (OpenCode): set model, launch OpenCode β”‚
@@ -803,7 +803,7 @@ This script:
803
803
 
804
804
  **Configuration:**
805
805
  - **Ping timeout**: 15 seconds per attempt (slow models get more time)
806
- - **Ping interval**: 3 seconds between complete re-pings of all models (adjustable with W/X keys)
806
+ - **Ping cadence**: startup burst at 2 seconds for 60s, then 10 seconds normally, 30 seconds when idle for 5 minutes, or forced 4 seconds via `W`
807
807
  - **Monitor mode**: Interface stays open forever, press Ctrl+C to exit
808
808
 
809
809
  **Flags:**
@@ -831,12 +831,12 @@ This script:
831
831
  - **T** β€” Cycle tier filter (All β†’ S+ β†’ S β†’ A+ β†’ A β†’ A- β†’ B+ β†’ B β†’ C β†’ All)
832
832
  - **D** β€” Cycle provider filter (All β†’ NIM β†’ Groq β†’ ...)
833
833
  - **Z** β€” Cycle mode (OpenCode CLI β†’ OpenCode Desktop β†’ OpenClaw)
834
- - **X** β€” **Toggle Log Viewer** (view recent activity and error logs)
834
+ - **X** β€” **Toggle Token Logs** (view recent request/token usage logs)
835
835
  - **P** β€” Open Settings (manage API keys, toggles, updates, profiles)
836
836
  - **Shift+P** β€” Cycle through saved profiles (switches live TUI settings)
837
837
  - **Shift+S** β€” Save current TUI settings as a named profile (inline prompt)
838
838
  - **Q** β€” Open Smart Recommend overlay (find the best model for your task)
839
- - **W / =** β€” Decrease / Increase ping interval
839
+ - **W** β€” Cycle ping mode (`FAST` 2s β†’ `NORMAL` 10s β†’ `SLOW` 30s β†’ `FORCED` 4s)
840
840
  - **J / I** β€” Request feature / Report bug
841
841
  - **K / Esc** β€” Show help overlay / Close overlay
842
842
  - **Ctrl+C** β€” Exit
@@ -862,7 +862,7 @@ Profiles let you save and restore different TUI configurations β€” useful if you
862
862
  - Favorites (starred models)
863
863
  - Sort column and direction
864
864
  - Tier filter
865
- - Ping interval
865
+ - Ping mode
866
866
  - API keys
867
867
 
868
868
  **Saving a profile:**
@@ -288,6 +288,7 @@ async function main() {
288
288
  status: 'pending',
289
289
  pings: [], // πŸ“– All ping results (ms or 'TIMEOUT')
290
290
  httpCode: null,
291
+ isPinging: false, // πŸ“– Per-row live flag so Latest Ping can keep last value and show a spinner during refresh.
291
292
  hidden: false, // πŸ“– Simple flag to hide/show models
292
293
  }))
293
294
  syncFavoriteFlags(results, config)
@@ -305,8 +306,30 @@ async function main() {
305
306
  // πŸ“– Add interactive selection state - cursor index and user's choice
306
307
  // πŸ“– sortColumn: 'rank'|'tier'|'origin'|'model'|'ping'|'avg'|'status'|'verdict'|'uptime'
307
308
  // πŸ“– sortDirection: 'asc' (default) or 'desc'
308
- // πŸ“– pingInterval: current interval in ms (default 2000, adjustable with W/= keys)
309
- // πŸ“– tierFilter: current tier filter letter (null = all, 'S' = S+/S, 'A' = A+/A/A-, etc.)
309
+ // πŸ“– ping cadence is now mode-driven:
310
+ // πŸ“– speed = 2s for 1 minute bursts
311
+ // πŸ“– normal = 10s steady state
312
+ // πŸ“– slow = 30s after 5 minutes of inactivity
313
+ // πŸ“– forced = 4s and ignores inactivity / auto slowdowns
314
+ const PING_MODE_INTERVALS = {
315
+ speed: 2_000,
316
+ normal: 10_000,
317
+ slow: 30_000,
318
+ forced: 4_000,
319
+ }
320
+ const PING_MODE_CYCLE = ['speed', 'normal', 'slow', 'forced']
321
+ const SPEED_MODE_DURATION_MS = 60_000
322
+ const IDLE_SLOW_AFTER_MS = 5 * 60_000
323
+ const now = Date.now()
324
+
325
+ const intervalToPingMode = (intervalMs) => {
326
+ if (intervalMs <= 3000) return 'speed'
327
+ if (intervalMs <= 5000) return 'forced'
328
+ if (intervalMs >= 30000) return 'slow'
329
+ return 'normal'
330
+ }
331
+
332
+ // πŸ“– tierFilter: current tier filter letter (null = all, 'S' = S+/S, 'A' = A+/A/A-, etc.)
310
333
  const state = {
311
334
  results,
312
335
  pendingPings: 0,
@@ -315,8 +338,13 @@ async function main() {
315
338
  selectedModel: null,
316
339
  sortColumn: 'avg',
317
340
  sortDirection: 'asc',
318
- pingInterval: PING_INTERVAL, // πŸ“– Track current interval for W/= keys
319
- lastPingTime: Date.now(), // πŸ“– Track when last ping cycle started
341
+ pingInterval: PING_MODE_INTERVALS.speed, // πŸ“– Effective live interval derived from the active ping mode.
342
+ pingMode: 'speed', // πŸ“– Current ping mode: speed | normal | slow | forced.
343
+ pingModeSource: 'startup', // πŸ“– Why this mode is active: startup | manual | auto | idle | activity.
344
+ speedModeUntil: now + SPEED_MODE_DURATION_MS, // πŸ“– Speed bursts auto-fall back to normal after 60 seconds.
345
+ lastPingTime: now, // πŸ“– Track when last ping cycle started
346
+ lastUserActivityAt: now, // πŸ“– Any keypress refreshes this timer; inactivity can force slow mode.
347
+ resumeSpeedOnActivity: false, // πŸ“– Set after idle slowdown so the next activity restarts a 60s speed burst.
320
348
  mode, // πŸ“– 'opencode' or 'openclaw' β€” controls Enter action
321
349
  tierFilterMode: 0, // πŸ“– Index into TIER_CYCLE (0=All, 1=S+, 2=S, ...)
322
350
  originFilterMode: 0, // πŸ“– Index into ORIGIN_CYCLE (0=All, then providers)
@@ -383,6 +411,53 @@ async function main() {
383
411
  adjustScrollOffset(state)
384
412
  })
385
413
 
414
+ let ticker = null
415
+ let onKeyPress = null
416
+ let pingModel = null
417
+
418
+ const scheduleNextPing = () => {
419
+ clearTimeout(state.pingIntervalObj)
420
+ const elapsed = Date.now() - state.lastPingTime
421
+ const delay = Math.max(0, state.pingInterval - elapsed)
422
+ state.pingIntervalObj = setTimeout(runPingCycle, delay)
423
+ }
424
+
425
+ const setPingMode = (nextMode, source = 'manual') => {
426
+ const modeInterval = PING_MODE_INTERVALS[nextMode] ?? PING_MODE_INTERVALS.normal
427
+ state.pingMode = nextMode
428
+ state.pingModeSource = source
429
+ state.pingInterval = modeInterval
430
+ state.speedModeUntil = nextMode === 'speed' ? Date.now() + SPEED_MODE_DURATION_MS : null
431
+ state.resumeSpeedOnActivity = source === 'idle'
432
+ if (state.pingIntervalObj) scheduleNextPing()
433
+ }
434
+
435
+ const noteUserActivity = () => {
436
+ state.lastUserActivityAt = Date.now()
437
+ if (state.pingMode === 'forced') return
438
+ if (state.resumeSpeedOnActivity) {
439
+ setPingMode('speed', 'activity')
440
+ }
441
+ }
442
+
443
+ const refreshAutoPingMode = () => {
444
+ const currentTime = Date.now()
445
+ if (state.pingMode === 'forced') return
446
+
447
+ if (state.speedModeUntil && currentTime >= state.speedModeUntil) {
448
+ setPingMode('normal', 'auto')
449
+ return
450
+ }
451
+
452
+ if (currentTime - state.lastUserActivityAt >= IDLE_SLOW_AFTER_MS) {
453
+ if (state.pingMode !== 'slow' || state.pingModeSource !== 'idle') {
454
+ setPingMode('slow', 'idle')
455
+ } else {
456
+ state.resumeSpeedOnActivity = true
457
+ }
458
+ }
459
+ }
460
+
386
461
  // πŸ“– Auto-start proxy on launch if OpenCode config already has an fcm-proxy provider.
387
462
  // πŸ“– Fire-and-forget: does not block UI startup. state.proxyStartupStatus is updated async.
388
463
  if (mode === 'opencode' || mode === 'opencode-desktop') {
@@ -425,9 +500,6 @@ async function main() {
425
500
  }
426
501
 
427
502
  // ─── Overlay renderers + key handler ─────────────────────────────────────
428
- let pingModel = null
429
- let ticker = null
430
- let onKeyPress = null
431
503
  const stopUi = ({ resetRawMode = false } = {}) => {
432
504
  if (ticker) clearInterval(ticker)
433
505
  clearTimeout(state.pingIntervalObj)
@@ -518,6 +590,10 @@ async function main() {
518
590
  mergedModels,
519
591
  apiKey,
520
592
  chalk,
593
+ setPingMode,
594
+ noteUserActivity,
595
+ intervalToPingMode,
596
+ PING_MODE_CYCLE,
521
597
  setResults: (next) => { results = next },
522
598
  readline,
523
599
  })
@@ -544,9 +620,11 @@ async function main() {
544
620
  }
545
621
 
546
622
  process.stdin.on('keypress', onKeyPress)
623
+ process.on('SIGCONT', noteUserActivity)
547
624
 
548
625
  // πŸ“– Animation loop: render settings overlay, recommend overlay, help overlay, feature request overlay, bug report overlay, OR main table
549
626
  ticker = setInterval(() => {
627
+ refreshAutoPingMode()
550
628
  state.frame++
551
629
  // πŸ“– Cache visible+sorted models each frame so Enter handler always matches the display
552
630
  if (!state.settingsOpen && !state.recommendOpen && !state.featureRequestOpen && !state.bugReportOpen) {
@@ -561,11 +639,11 @@ async function main() {
561
639
  ? overlays.renderFeatureRequest()
562
640
  : state.bugReportOpen
563
641
  ? overlays.renderBugReport()
564
- : state.helpVisible
565
- ? overlays.renderHelp()
642
+ : state.helpVisible
643
+ ? overlays.renderHelp()
566
644
  : state.logVisible
567
645
  ? overlays.renderLog()
568
- : renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus)
646
+ : renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus, state.pingMode, state.pingModeSource)
569
647
  process.stdout.write(ALT_HOME + content)
570
648
  }, Math.round(1000 / FPS))
571
649
 
@@ -573,7 +651,7 @@ async function main() {
573
651
  const initialVisible = state.results.filter(r => !r.hidden)
574
652
  state.visibleSorted = sortResultsWithPinnedFavorites(initialVisible, state.sortColumn, state.sortDirection)
575
653
 
576
- process.stdout.write(ALT_HOME + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus))
654
+ process.stdout.write(ALT_HOME + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus, state.pingMode, state.pingModeSource))
577
655
 
578
656
  // πŸ“– If --recommend was passed, auto-open the Smart Recommend overlay on start
579
657
  if (cliArgs.recommendMode) {
@@ -593,82 +671,89 @@ async function main() {
593
671
  // πŸ“– Uses per-provider API key and URL from sources.js
594
672
  // πŸ“– If no API key is configured, pings without auth β€” a 401 still tells us latency + server is up
595
673
  pingModel = async (r) => {
596
- const providerApiKey = getApiKey(state.config, r.providerKey) ?? null
597
- const providerUrl = sources[r.providerKey]?.url ?? sources.nvidia.url
598
- let { code, ms, quotaPercent } = await ping(providerApiKey, r.modelId, r.providerKey, providerUrl)
599
-
600
- if ((quotaPercent === null || quotaPercent === undefined) && providerApiKey) {
601
- const providerQuota = await getProviderQuotaPercentCached(r.providerKey, providerApiKey)
602
- if (typeof providerQuota === 'number' && Number.isFinite(providerQuota)) {
603
- quotaPercent = providerQuota
674
+ state.pendingPings += 1
675
+ r.isPinging = true
676
+
677
+ try {
678
+ const providerApiKey = getApiKey(state.config, r.providerKey) ?? null
679
+ const providerUrl = sources[r.providerKey]?.url ?? sources.nvidia.url
680
+ let { code, ms, quotaPercent } = await ping(providerApiKey, r.modelId, r.providerKey, providerUrl)
681
+
682
+ if ((quotaPercent === null || quotaPercent === undefined) && providerApiKey) {
683
+ const providerQuota = await getProviderQuotaPercentCached(r.providerKey, providerApiKey)
684
+ if (typeof providerQuota === 'number' && Number.isFinite(providerQuota)) {
685
+ quotaPercent = providerQuota
686
+ }
604
687
  }
605
- }
606
688
 
607
- // πŸ“– Store ping result as object with ms and code
608
- // πŸ“– ms = actual response time (even for errors like 429)
609
- // πŸ“– code = HTTP status code ('200', '429', '500', '000' for timeout)
610
- r.pings.push({ ms, code })
611
-
612
- // πŸ“– Update status based on latest ping
613
- if (code === '200') {
614
- r.status = 'up'
615
- } else if (code === '000') {
616
- r.status = 'timeout'
617
- } else if (code === '401') {
618
- // πŸ“– 401 = server is reachable but no API key set (or wrong key)
619
- // πŸ“– Treated as 'noauth' β€” server is UP, latency is real, just needs a key
620
- r.status = 'noauth'
621
- r.httpCode = code
622
- } else {
623
- r.status = 'down'
624
- r.httpCode = code
625
- }
689
+ // πŸ“– Store ping result as object with ms and code
690
+ // πŸ“– ms = actual response time (even for errors like 429)
691
+ // πŸ“– code = HTTP status code ('200', '429', '500', '000' for timeout)
692
+ r.pings.push({ ms, code })
693
+
694
+ // πŸ“– Update status based on latest ping
695
+ if (code === '200') {
696
+ r.status = 'up'
697
+ } else if (code === '000') {
698
+ r.status = 'timeout'
699
+ } else if (code === '401') {
700
+ // πŸ“– 401 = server is reachable but no API key set (or wrong key)
701
+ // πŸ“– Treated as 'noauth' β€” server is UP, latency is real, just needs a key
702
+ r.status = 'noauth'
703
+ r.httpCode = code
704
+ } else {
705
+ r.status = 'down'
706
+ r.httpCode = code
707
+ }
626
708
 
627
- if (typeof quotaPercent === 'number' && Number.isFinite(quotaPercent)) {
628
- r.usagePercent = quotaPercent
629
- // Provider-level fallback: apply latest known quota to sibling rows on same provider.
630
- for (const sibling of state.results) {
631
- if (sibling.providerKey === r.providerKey && (sibling.usagePercent === undefined || sibling.usagePercent === null)) {
632
- sibling.usagePercent = quotaPercent
709
+ if (typeof quotaPercent === 'number' && Number.isFinite(quotaPercent)) {
710
+ r.usagePercent = quotaPercent
711
+ // Provider-level fallback: apply latest known quota to sibling rows on same provider.
712
+ for (const sibling of state.results) {
713
+ if (sibling.providerKey === r.providerKey && (sibling.usagePercent === undefined || sibling.usagePercent === null)) {
714
+ sibling.usagePercent = quotaPercent
715
+ }
633
716
  }
634
717
  }
718
+ } finally {
719
+ r.isPinging = false
720
+ state.pendingPings = Math.max(0, state.pendingPings - 1)
635
721
  }
636
722
  }
637
723
 
638
724
  // πŸ“– Initial ping of all models
639
725
  const initialPing = Promise.all(state.results.map(r => pingModel(r)))
640
726
 
641
- // πŸ“– Continuous ping loop with dynamic interval (adjustable with W/= keys)
642
- const schedulePing = () => {
643
- state.pingIntervalObj = setTimeout(async () => {
644
- state.lastPingTime = Date.now()
645
-
646
- // πŸ“– Refresh persisted usage snapshots each cycle so proxy writes appear live in table.
647
- // πŸ“– Freshness-aware: stale snapshots (>30m) are excluded and row reverts to undefined.
648
- for (const r of state.results) {
649
- const pct = _usageForRow(r.providerKey, r.modelId)
650
- if (typeof pct === 'number' && Number.isFinite(pct)) {
651
- r.usagePercent = pct
652
- } else {
653
- // If snapshot is now stale or gone, clear the cached value so UI shows N/A.
654
- r.usagePercent = undefined
655
- }
727
+ // πŸ“– Continuous ping loop with mode-driven cadence.
728
+ const runPingCycle = async () => {
729
+ refreshAutoPingMode()
730
+ state.lastPingTime = Date.now()
731
+
732
+ // πŸ“– Refresh persisted usage snapshots each cycle so proxy writes appear live in table.
733
+ // πŸ“– Freshness-aware: stale snapshots (>30m) are excluded and row reverts to undefined.
734
+ for (const r of state.results) {
735
+ const pct = _usageForRow(r.providerKey, r.modelId)
736
+ if (typeof pct === 'number' && Number.isFinite(pct)) {
737
+ r.usagePercent = pct
738
+ } else {
739
+ // If snapshot is now stale or gone, clear the cached value so UI shows N/A.
740
+ r.usagePercent = undefined
656
741
  }
742
+ }
657
743
 
658
- state.results.forEach(r => {
659
- pingModel(r).catch(() => {
660
- // Individual ping failures don't crash the loop
661
- })
744
+ state.results.forEach(r => {
745
+ pingModel(r).catch(() => {
746
+ // Individual ping failures don't crash the loop
662
747
  })
748
+ })
663
749
 
664
- // πŸ“– Schedule next ping with current interval
665
- schedulePing()
666
- }, state.pingInterval)
750
+ refreshAutoPingMode()
751
+ scheduleNextPing()
667
752
  }
668
753
 
669
754
  // πŸ“– Start the ping loop
670
755
  state.pingIntervalObj = null
671
- schedulePing()
756
+ scheduleNextPing()
672
757
 
673
758
  await initialPing
674
759
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.1.84",
3
+ "version": "0.1.85",
4
4
  "description": "Find the fastest coding LLM models in seconds β€” ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
5
5
  "keywords": [
6
6
  "nvidia",
package/src/config.js CHANGED
@@ -392,7 +392,7 @@ export function _emptyProfileSettings() {
392
392
  tierFilter: null, // πŸ“– null = show all tiers, or 'S'|'A'|'B'|'C'|'D'
393
393
  sortColumn: 'avg', // πŸ“– default sort column
394
394
  sortAsc: true, // πŸ“– true = ascending (fastest first for latency)
395
- pingInterval: 8000, // πŸ“– default ms between pings
395
+ pingInterval: 10000, // πŸ“– default ms between pings in the steady "normal" mode
396
396
  }
397
397
  }
398
398
 
package/src/constants.js CHANGED
@@ -51,7 +51,9 @@ export const ALT_HOME = '\x1b[H'
51
51
 
52
52
  // πŸ“– Timing constants β€” control how fast the health-check loop runs.
53
53
  export const PING_TIMEOUT = 15_000 // πŸ“– 15s per attempt before abort
54
- export const PING_INTERVAL = 3_000 // πŸ“– 3s between pings for fast model selection feedback
54
+ // πŸ“– PING_INTERVAL is the baseline "normal" cadence. Startup can still temporarily
55
+ // πŸ“– boost to faster modes, but steady-state uses 10s unless the user picks another mode.
56
+ export const PING_INTERVAL = 10_000
55
57
 
56
58
  // πŸ“– Animation and column-width constants.
57
59
  export const FPS = 12
@@ -65,6 +65,10 @@ export function createKeyHandler(ctx) {
65
65
  mergedModels,
66
66
  apiKey,
67
67
  chalk,
68
+ setPingMode,
69
+ noteUserActivity,
70
+ intervalToPingMode,
71
+ PING_MODE_CYCLE,
68
72
  setResults,
69
73
  readline,
70
74
  } = ctx
@@ -121,6 +125,7 @@ export function createKeyHandler(ctx) {
121
125
 
122
126
  return async (str, key) => {
123
127
  if (!key) return
128
+ noteUserActivity()
124
129
 
125
130
  // πŸ“– Profile save mode: intercept ALL keys while inline name input is active.
126
131
  // πŸ“– Enter β†’ save, Esc β†’ cancel, Backspace β†’ delete char, printable β†’ append to buffer.
@@ -503,7 +508,7 @@ export function createKeyHandler(ctx) {
503
508
  // πŸ“– Try to reuse existing result to keep ping history
504
509
  const existing = state.results.find(r => r.modelId === modelId && r.providerKey === providerKey)
505
510
  if (existing) return existing
506
- return { idx: i + 1, modelId, label, tier, sweScore, ctx, providerKey, status: 'pending', pings: [], httpCode: null, hidden: false }
511
+ return { idx: i + 1, modelId, label, tier, sweScore, ctx, providerKey, status: 'pending', pings: [], httpCode: null, isPinging: false, hidden: false }
507
512
  })
508
513
  // πŸ“– Re-index results
509
514
  nextResults.forEach((r, i) => { r.idx = i + 1 })
@@ -524,6 +529,7 @@ export function createKeyHandler(ctx) {
524
529
  r.status = 'pending'
525
530
  r.pings = []
526
531
  r.httpCode = null
532
+ r.isPinging = false
527
533
  pingModel(r).catch(() => {})
528
534
  }
529
535
  })
@@ -582,7 +588,7 @@ export function createKeyHandler(ctx) {
582
588
  if (settings) {
583
589
  state.sortColumn = settings.sortColumn || 'avg'
584
590
  state.sortDirection = settings.sortAsc ? 'asc' : 'desc'
585
- state.pingInterval = settings.pingInterval || PING_INTERVAL
591
+ setPingMode(intervalToPingMode(settings.pingInterval || PING_INTERVAL), 'manual')
586
592
  if (settings.tierFilter) {
587
593
  const tierIdx = TIER_CYCLE.indexOf(settings.tierFilter)
588
594
  if (tierIdx >= 0) state.tierFilterMode = tierIdx
@@ -773,7 +779,7 @@ export function createKeyHandler(ctx) {
773
779
  // πŸ“– Apply profile's TUI settings to live state
774
780
  state.sortColumn = settings.sortColumn || 'avg'
775
781
  state.sortDirection = settings.sortAsc ? 'asc' : 'desc'
776
- state.pingInterval = settings.pingInterval || PING_INTERVAL
782
+ setPingMode(intervalToPingMode(settings.pingInterval || PING_INTERVAL), 'manual')
777
783
  if (settings.tierFilter) {
778
784
  const tierIdx = TIER_CYCLE.indexOf(settings.tierFilter)
779
785
  if (tierIdx >= 0) state.tierFilterMode = tierIdx
@@ -871,13 +877,13 @@ export function createKeyHandler(ctx) {
871
877
  return
872
878
  }
873
879
 
874
- // πŸ“– Interval adjustment keys: W=decrease (faster), ==increase (slower)
875
- // πŸ“– X was previously used for interval increase but is now reserved for the log page overlay.
876
- // πŸ“– Minimum 1s, maximum 60s
880
+ // πŸ“– W cycles the supported ping modes:
881
+ // πŸ“– speed (2s) β†’ normal (10s) β†’ slow (30s) β†’ forced (4s) β†’ speed.
882
+ // πŸ“– forced ignores auto speed/slow transitions until the user leaves it manually.
877
883
  if (key.name === 'w') {
878
- state.pingInterval = Math.max(1000, state.pingInterval - 1000)
879
- } else if (str === '=' || key.name === '=') {
880
- state.pingInterval = Math.min(60000, state.pingInterval + 1000)
884
+ const currentIdx = PING_MODE_CYCLE.indexOf(state.pingMode)
885
+ const nextIdx = currentIdx >= 0 ? (currentIdx + 1) % PING_MODE_CYCLE.length : 0
886
+ setPingMode(PING_MODE_CYCLE[nextIdx], 'manual')
881
887
  }
882
888
 
883
889
  // πŸ“– Tier toggle key: T = cycle through each individual tier (All β†’ S+ β†’ S β†’ A+ β†’ A β†’ A- β†’ B+ β†’ B β†’ C β†’ All)
package/src/overlays.js CHANGED
@@ -250,8 +250,8 @@ export function createOverlayRenderers(state, deps) {
250
250
  lines.push(` ${chalk.cyan('Latest')} Most recent ping response time (ms) ${chalk.dim('Sort:')} ${chalk.yellow('L')}`)
251
251
  lines.push(` ${chalk.dim('Shows how fast the server is responding right now β€” useful to catch live slowdowns.')}`)
252
252
  lines.push('')
253
- lines.push(` ${chalk.cyan('Avg Ping')} Average response time across all successful pings (ms) ${chalk.dim('Sort:')} ${chalk.yellow('A')}`)
254
- lines.push(` ${chalk.dim('The long-term truth. Ignore lucky one-off pings, this tells you real everyday speed.')}`)
253
+ lines.push(` ${chalk.cyan('Avg Ping')} Average response time across all measurable pings (200 + 401) (ms) ${chalk.dim('Sort:')} ${chalk.yellow('A')}`)
254
+ lines.push(` ${chalk.dim('The long-term truth. Even without a key, a 401 still gives real latency so the average stays useful.')}`)
255
255
  lines.push('')
256
256
  lines.push(` ${chalk.cyan('Health')} Live status: βœ… UP / πŸ”₯ 429 / ⏳ TIMEOUT / ❌ ERR / πŸ”‘ NO KEY ${chalk.dim('Sort:')} ${chalk.yellow('H')}`)
257
257
  lines.push(` ${chalk.dim('Tells you instantly if a model is reachable or down β€” no guesswork needed.')}`)
@@ -278,10 +278,9 @@ export function createOverlayRenderers(state, deps) {
278
278
  lines.push(` ${chalk.yellow('Enter')} Select model and launch`)
279
279
  lines.push('')
280
280
  lines.push(` ${chalk.bold('Controls')}`)
281
- lines.push(` ${chalk.yellow('W')} Decrease ping interval (faster)`)
282
- lines.push(` ${chalk.yellow('=')} Increase ping interval (slower) ${chalk.dim('(was X β€” X is now the log page)')}`)
283
- lines.push(` ${chalk.yellow('X')} Toggle request log page ${chalk.dim('(shows recent requests from request-log.jsonl)')}`)
284
- lines.push(` ${chalk.yellow('Z')} Cycle launch mode ${chalk.dim('(OpenCode CLI β†’ OpenCode Desktop β†’ OpenClaw)')}`)
281
+ lines.push(` ${chalk.yellow('W')} Toggle ping mode ${chalk.dim('(speed 2s β†’ normal 10s β†’ slow 30s β†’ forced 4s)')}`)
282
+ lines.push(` ${chalk.yellow('X')} Toggle token log page ${chalk.dim('(shows recent request usage from request-log.jsonl)')}`)
283
+ lines.push(` ${chalk.yellow('Z')} Cycle tool mode ${chalk.dim('(OpenCode CLI β†’ OpenCode Desktop β†’ OpenClaw)')}`)
285
284
  lines.push(` ${chalk.yellow('F')} Toggle favorite on selected row ${chalk.dim('(⭐ pinned at top, persisted)')}`)
286
285
  lines.push(` ${chalk.yellow('Q')} Smart Recommend ${chalk.dim('(🎯 find the best model for your task β€” questionnaire + live analysis)')}`)
287
286
  lines.push(` ${chalk.rgb(57, 255, 20).bold('J')} Request Feature ${chalk.dim('(πŸ“ send anonymous feedback to the project team)')}`)
@@ -77,7 +77,7 @@ export function setActiveProxy(proxyInstance) {
77
77
  }
78
78
 
79
79
  // ─── renderTable: mode param controls footer hint text (opencode vs openclaw) ─────────
80
- export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, originFilterMode = 0, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null) {
80
+ export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, originFilterMode = 0, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null, pingMode = 'normal', pingModeSource = 'auto') {
81
81
  // πŸ“– Filter out hidden models for display
82
82
  const visibleResults = results.filter(r => !r.hidden)
83
83
 
@@ -97,6 +97,23 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
97
97
  ? chalk.dim(`pinging β€” ${pendingPings} in flight…`)
98
98
  : chalk.dim(`next ping ${secondsUntilNext}s`)
99
99
 
100
+ const intervalSec = Math.round(pingInterval / 1000)
101
+ const pingModeMeta = {
102
+ speed: { label: `${intervalSec}s speed`, color: chalk.bold.rgb(255, 210, 80) },
103
+ normal: { label: `${intervalSec}s normal`, color: chalk.bold.rgb(120, 210, 255) },
104
+ slow: { label: `${intervalSec}s slow`, color: chalk.bold.rgb(255, 170, 90) },
105
+ forced: { label: `${intervalSec}s forced`, color: chalk.bold.rgb(255, 120, 120) },
106
+ }
107
+ const activePingMode = pingModeMeta[pingMode] ?? pingModeMeta.normal
108
+ const pingModeBadge = activePingMode.color(` [${activePingMode.label}]`)
109
+ const pingModeHint = pingModeSource === 'idle'
110
+ ? chalk.dim(' idle')
111
+ : pingModeSource === 'activity'
112
+ ? chalk.dim(' resumed')
113
+ : pingModeSource === 'startup'
114
+ ? chalk.dim(' startup')
115
+ : ''
116
+
100
117
  // πŸ“– Mode badge shown in header so user knows what Enter will do
101
118
  // πŸ“– Now includes key hint for mode toggle
102
119
  let modeBadge
@@ -160,7 +177,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
160
177
  const sorted = sortResultsWithPinnedFavorites(visibleResults, sortColumn, sortDirection)
161
178
 
162
179
  const lines = [
163
- ` ${chalk.greenBright.bold('βœ… FCM')}${modeBadge}${modeHint}${tierBadge}${originBadge}${profileBadge} ` +
180
+ ` ${chalk.greenBright.bold('βœ… FCM')}${modeBadge}${pingModeBadge}${modeHint}${tierBadge}${originBadge}${profileBadge} ` +
164
181
  chalk.greenBright(`βœ… ${up}`) + chalk.dim(' up ') +
165
182
  chalk.yellow(`⏳ ${timeout}`) + chalk.dim(' timeout ') +
166
183
  chalk.red(`❌ ${down}`) + chalk.dim(' down ') +
@@ -315,22 +332,30 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
315
332
  ? chalk.cyan(ctxRaw.padEnd(W_CTX))
316
333
  : chalk.dim(ctxRaw.padEnd(W_CTX))
317
334
 
335
+ // πŸ“– Keep the row-local spinner small and inline so users can still read the last measured latency.
336
+ const buildLatestPingDisplay = (value) => {
337
+ const spinner = r.isPinging ? ` ${FRAMES[frame % FRAMES.length]}` : ''
338
+ return `${value}${spinner}`.padEnd(W_PING)
339
+ }
340
+
318
341
  // πŸ“– Latest ping - pings are objects: { ms, code }
319
342
  // πŸ“– Show response time for 200 (success) and 401 (no-auth but server is reachable)
320
343
  const latestPing = r.pings.length > 0 ? r.pings[r.pings.length - 1] : null
321
344
  let pingCell
322
345
  if (!latestPing) {
323
- pingCell = chalk.dim('β€”β€”β€”'.padEnd(W_PING))
346
+ const placeholder = r.isPinging ? buildLatestPingDisplay('β€”β€”β€”') : 'β€”β€”β€”'.padEnd(W_PING)
347
+ pingCell = chalk.dim(placeholder)
324
348
  } else if (latestPing.code === '200') {
325
349
  // πŸ“– Success - show response time
326
- const str = String(latestPing.ms).padEnd(W_PING)
350
+ const str = buildLatestPingDisplay(String(latestPing.ms))
327
351
  pingCell = latestPing.ms < 500 ? chalk.greenBright(str) : latestPing.ms < 1500 ? chalk.yellow(str) : chalk.red(str)
328
352
  } else if (latestPing.code === '401') {
329
353
  // πŸ“– 401 = no API key but server IS reachable β€” still show latency in dim
330
- pingCell = chalk.dim(String(latestPing.ms).padEnd(W_PING))
354
+ pingCell = chalk.dim(buildLatestPingDisplay(String(latestPing.ms)))
331
355
  } else {
332
356
  // πŸ“– Error or timeout - show "β€”β€”β€”" (error code is already in Status column)
333
- pingCell = chalk.dim('β€”β€”β€”'.padEnd(W_PING))
357
+ const placeholder = r.isPinging ? buildLatestPingDisplay('β€”β€”β€”') : 'β€”β€”β€”'.padEnd(W_PING)
358
+ pingCell = chalk.dim(placeholder)
334
359
  }
335
360
 
336
361
  // πŸ“– Avg ping (just number, no "ms")
@@ -523,18 +548,17 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
523
548
  } else {
524
549
  lines.push('')
525
550
  }
526
- const intervalSec = Math.round(pingInterval / 1000)
527
-
528
- // πŸ“– Footer hints adapt based on active mode
551
+ // πŸ“– Footer hints adapt based on active mode.
552
+ const hotkey = (keyLabel, text) => chalk.yellow(keyLabel) + chalk.dim(text)
529
553
  const actionHint = mode === 'openclaw'
530
- ? chalk.rgb(255, 100, 50)('Enter→SetOpenClaw')
554
+ ? hotkey('Enter', 'β†’SetOpenClaw')
531
555
  : mode === 'opencode-desktop'
532
- ? chalk.rgb(0, 200, 255)('Enter→OpenDesktop')
533
- : chalk.rgb(0, 200, 255)('Enter→OpenCode')
556
+ ? hotkey('Enter', 'β†’OpenDesktop')
557
+ : hotkey('Enter', 'β†’OpenCode')
534
558
  // πŸ“– Line 1: core navigation + sorting shortcuts
535
- lines.push(chalk.dim(` ↑↓ Navigate β€’ `) + actionHint + chalk.dim(` β€’ `) + chalk.yellow('F') + chalk.dim(` Favorite β€’ R/Y/O/M/L/A/S/C/H/V/B/U/`) + chalk.yellow('G') + chalk.dim(` Sort β€’ `) + chalk.yellow('T') + chalk.dim(` Tier β€’ `) + chalk.yellow('D') + chalk.dim(` Provider β€’ W↓/=↑ (${intervalSec}s) β€’ `) + chalk.rgb(255, 100, 50).bold('Z') + chalk.dim(` Mode β€’ `) + chalk.yellow('X') + chalk.dim(` Logs β€’ `) + chalk.yellow('P') + chalk.dim(` Settings β€’ `) + chalk.rgb(0, 255, 80).bold('K') + chalk.dim(` Help`))
559
+ lines.push(chalk.dim(` ↑↓ Navigate β€’ `) + actionHint + chalk.dim(` β€’ `) + hotkey('F', ' Toggle Favorite') + chalk.dim(` β€’ Press Highlighted letters in column named to sort & filter β€’ `) + hotkey('T', ' Tier') + chalk.dim(` β€’ `) + hotkey('D', ' Provider') + chalk.dim(` β€’ `) + hotkey('W', ' Ping Mode : FAST/NORMAL/SLOW/FORCED') + chalk.dim(` β€’ `) + hotkey('Z', ' Tool Mode') + chalk.dim(` β€’ `) + hotkey('X', ' Token Logs') + chalk.dim(` β€’ `) + hotkey('P', ' Settings') + chalk.dim(` β€’ `) + hotkey('K', ' Help'))
536
560
  // πŸ“– Line 2: profiles, recommend, feature request, bug report, and extended hints β€” gives visibility to less-obvious features
537
- lines.push(chalk.dim(` `) + chalk.rgb(200, 150, 255).bold('⇧P') + chalk.dim(` Cycle profile β€’ `) + chalk.rgb(200, 150, 255).bold('⇧S') + chalk.dim(` Save profile β€’ `) + chalk.rgb(0, 200, 180).bold('Q') + chalk.dim(` Smart Recommend β€’ `) + chalk.rgb(57, 255, 20).bold('J') + chalk.dim(` Request feature β€’ `) + chalk.rgb(255, 87, 51).bold('I') + chalk.dim(` Report bug β€’ `) + chalk.yellow('Esc') + chalk.dim(` Close overlay`))
561
+ lines.push(chalk.dim(` `) + hotkey('⇧P', ' Cycle profile') + chalk.dim(` β€’ `) + hotkey('⇧S', ' Save profile') + chalk.dim(` β€’ `) + hotkey('Q', ' Smart Recommend') + chalk.dim(` β€’ `) + hotkey('J', ' Request feature') + chalk.dim(` β€’ `) + hotkey('I', ' Report bug'))
538
562
  // πŸ“– Proxy status line β€” always rendered with explicit state (starting/running/failed/stopped)
539
563
  lines.push(renderProxyStatusLine(proxyStartupStatus, activeProxyRef))
540
564
  lines.push(
package/src/utils.js CHANGED
@@ -74,18 +74,23 @@ export const TIER_LETTER_MAP = {
74
74
 
75
75
  // ─── Core Logic Functions ────────────────────────────────────────────────────
76
76
 
77
- // πŸ“– getAvg: Calculate average latency from ONLY successful pings (HTTP 200).
78
- // πŸ“– Failed pings (timeouts, 429s, 500s) are excluded to avoid skewing the average.
79
- // πŸ“– Returns Infinity when no successful pings exist β€” this sorts "unknown" models to the bottom.
77
+ // πŸ“– measureablePingCodes: HTTP codes that still give us a real round-trip latency sample.
78
+ // πŸ“– 200 = normal success, 401 = no key / bad key but the provider endpoint is reachable.
79
+ const measurablePingCodes = new Set(['200', '401'])
80
+
81
+ // πŸ“– getAvg: Calculate average latency from pings that produced a real latency sample.
82
+ // πŸ“– HTTP 200 and 401 both count because a 401 still proves the endpoint responded in X ms.
83
+ // πŸ“– Timeouts and server failures are excluded to avoid mixing availability with raw latency.
84
+ // πŸ“– Returns Infinity when no measurable pings exist β€” this sorts "unknown" models to the bottom.
80
85
  // πŸ“– The rounding to integer avoids displaying fractional milliseconds in the TUI.
81
86
  //
82
87
  // πŸ“– Example:
83
- // pings = [{ms: 200, code: '200'}, {ms: 0, code: '429'}, {ms: 400, code: '200'}]
84
- // β†’ getAvg returns 300 (only the two 200s count: (200+400)/2)
88
+ // pings = [{ms: 200, code: '200'}, {ms: 320, code: '401'}, {ms: 999, code: '500'}]
89
+ // β†’ getAvg returns 260 (only the measurable pings count: (200+320)/2)
85
90
  export const getAvg = (r) => {
86
- const successfulPings = (r.pings || []).filter(p => p.code === '200')
87
- if (successfulPings.length === 0) return Infinity
88
- return Math.round(successfulPings.reduce((a, b) => a + b.ms, 0) / successfulPings.length)
91
+ const measurablePings = (r.pings || []).filter(p => measurablePingCodes.has(p.code))
92
+ if (measurablePings.length === 0) return Infinity
93
+ return Math.round(measurablePings.reduce((a, b) => a + b.ms, 0) / measurablePings.length)
89
94
  }
90
95
 
91
96
  // πŸ“– getVerdict: Determine a human-readable health verdict for a model.
@@ -120,16 +125,16 @@ export const getVerdict = (r) => {
120
125
  if (avg === Infinity) return 'Pending'
121
126
 
122
127
  // πŸ“– Stability-aware verdict: penalize models with good avg but terrible tail latency
123
- const successfulPings = (r.pings || []).filter(p => p.code === '200')
128
+ const measurablePings = (r.pings || []).filter(p => measurablePingCodes.has(p.code))
124
129
  const p95 = getP95(r)
125
130
 
126
131
  if (avg < 400) {
127
132
  // πŸ“– Only flag as "Spiky" when we have enough data (β‰₯3 pings) to judge stability
128
- if (successfulPings.length >= 3 && p95 > 3000) return 'Spiky'
133
+ if (measurablePings.length >= 3 && p95 > 3000) return 'Spiky'
129
134
  return 'Perfect'
130
135
  }
131
136
  if (avg < 1000) {
132
- if (successfulPings.length >= 3 && p95 > 5000) return 'Spiky'
137
+ if (measurablePings.length >= 3 && p95 > 5000) return 'Spiky'
133
138
  return 'Normal'
134
139
  }
135
140
  if (avg < 3000) return 'Slow'
@@ -148,30 +153,30 @@ export const getUptime = (r) => {
148
153
  return Math.round((successful / r.pings.length) * 100)
149
154
  }
150
155
 
151
- // πŸ“– getP95: Calculate the 95th percentile latency from successful pings (HTTP 200).
156
+ // πŸ“– getP95: Calculate the 95th percentile latency from measurable pings (HTTP 200/401).
152
157
  // πŸ“– The p95 answers: "95% of requests are faster than this value."
153
158
  // πŸ“– A low p95 means consistently fast responses β€” a high p95 signals tail-latency spikes.
154
- // πŸ“– Returns Infinity when no successful pings exist.
159
+ // πŸ“– Returns Infinity when no measurable pings exist.
155
160
  //
156
161
  // πŸ“– Algorithm: sort latencies ascending, pick the value at ceil(N * 0.95) - 1.
157
162
  // πŸ“– Example: [100, 200, 300, 400, 5000] β†’ p95 index = ceil(5 * 0.95) - 1 = 4 β†’ 5000ms
158
163
  export const getP95 = (r) => {
159
- const successfulPings = (r.pings || []).filter(p => p.code === '200')
160
- if (successfulPings.length === 0) return Infinity
161
- const sorted = successfulPings.map(p => p.ms).sort((a, b) => a - b)
164
+ const measurablePings = (r.pings || []).filter(p => measurablePingCodes.has(p.code))
165
+ if (measurablePings.length === 0) return Infinity
166
+ const sorted = measurablePings.map(p => p.ms).sort((a, b) => a - b)
162
167
  const idx = Math.ceil(sorted.length * 0.95) - 1
163
168
  return sorted[Math.max(0, idx)]
164
169
  }
165
170
 
166
- // πŸ“– getJitter: Calculate latency standard deviation (Οƒ) from successful pings.
171
+ // πŸ“– getJitter: Calculate latency standard deviation (Οƒ) from measurable pings.
167
172
  // πŸ“– Low jitter = predictable response times. High jitter = erratic, spiky latency.
168
- // πŸ“– Returns 0 when fewer than 2 successful pings (can't compute variance from 1 point).
173
+ // πŸ“– Returns 0 when fewer than 2 measurable pings (can't compute variance from 1 point).
169
174
  // πŸ“– Uses population Οƒ (divides by N, not N-1) since we have ALL the data, not a sample.
170
175
  export const getJitter = (r) => {
171
- const successfulPings = (r.pings || []).filter(p => p.code === '200')
172
- if (successfulPings.length < 2) return 0
173
- const mean = successfulPings.reduce((a, b) => a + b.ms, 0) / successfulPings.length
174
- const variance = successfulPings.reduce((sum, p) => sum + (p.ms - mean) ** 2, 0) / successfulPings.length
176
+ const measurablePings = (r.pings || []).filter(p => measurablePingCodes.has(p.code))
177
+ if (measurablePings.length < 2) return 0
178
+ const mean = measurablePings.reduce((a, b) => a + b.ms, 0) / measurablePings.length
179
+ const variance = measurablePings.reduce((sum, p) => sum + (p.ms - mean) ** 2, 0) / measurablePings.length
175
180
  return Math.round(Math.sqrt(variance))
176
181
  }
177
182
 
@@ -190,14 +195,14 @@ export const getJitter = (r) => {
190
195
  // Model B: avg 400ms, p95 650ms (boringly consistent) β†’ score ~85
191
196
  // In real usage, Model B FEELS faster because it doesn't randomly stall.
192
197
  export const getStabilityScore = (r) => {
193
- const successfulPings = (r.pings || []).filter(p => p.code === '200')
194
- if (successfulPings.length === 0) return -1
198
+ const measurablePings = (r.pings || []).filter(p => measurablePingCodes.has(p.code))
199
+ if (measurablePings.length === 0) return -1
195
200
 
196
201
  const p95 = getP95(r)
197
202
  const jitter = getJitter(r)
198
203
  const uptime = getUptime(r)
199
- const spikeCount = successfulPings.filter(p => p.ms > 3000).length
200
- const spikeRate = spikeCount / successfulPings.length
204
+ const spikeCount = measurablePings.filter(p => p.ms > 3000).length
205
+ const spikeRate = spikeCount / measurablePings.length
201
206
 
202
207
  // πŸ“– Normalize each component to 0–100 (higher = better)
203
208
  const p95Score = Math.max(0, Math.min(100, 100 * (1 - p95 / 5000)))