free-coding-models 0.3.69 → 0.3.71

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.
@@ -0,0 +1,8 @@
1
+ # Changelog v0.3.70 - 2026-05-27
2
+
3
+ ### Changed
4
+ - Bumped version to 0.3.70 as part of release process.
5
+ - Updated documentation and internal references for the new version.
6
+
7
+ ### Fixed
8
+ - (No functional changes in this bump; included for completeness.)
@@ -0,0 +1,11 @@
1
+ # Changelog v0.3.71 - 2026-05-27
2
+
3
+ ### Added
4
+ - Cleaned up unused temporary and documentation files.
5
+ - Retained only Docker configurations, image assets, and kanban data.
6
+
7
+ ### Fixed
8
+ - None (cleanup only).
9
+
10
+ ### Changed
11
+ - None.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.3.69",
3
+ "version": "0.3.71",
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/app.js CHANGED
@@ -112,7 +112,10 @@ import { runFiableMode, filterByTierOrExit, fetchOpenRouterFreeModels } from '..
112
112
  import { PROVIDER_METADATA, ENV_VAR_NAMES, isWindows, isMac } from '../src/provider-metadata.js'
113
113
  import { parseTelemetryEnv, isTelemetryDebugEnabled, telemetryDebug, ensureTelemetryConfig, getTelemetryDistinctId, getTelemetrySystem, getTelemetryTerminal, isTelemetryEnabled, sendUsageTelemetry } from '../src/telemetry.js'
114
114
  import { ensureFavoritesConfig, toFavoriteKey, syncFavoriteFlags, toggleFavoriteModel, reorderFavorite, pruneOrphanedFavorites } from '../src/favorites.js'
115
- import { checkForUpdateDetailed, checkForUpdate, runUpdate, promptUpdateNotification, fetchLastReleaseDate } from './updater.js'
115
+ import { checkForUpdateDetailed, checkForUpdate, runUpdate, fetchLastReleaseDate } from './updater.js'
116
+ import { createTuiState, PING_MODE_INTERVALS, PING_MODE_CYCLE, SPEED_MODE_DURATION_MS, IDLE_SLOW_AFTER_MS, intervalToPingMode } from './tui-state.js'
117
+ import { createPingLoop } from './ping-loop.js'
118
+ import { createTuiFilters } from './tui-filters.js'
116
119
  import { promptApiKey } from '../src/setup.js'
117
120
  import { syncShellEnv, ensureShellRcSource, promptShellEnvMigration, removeShellEnv } from '../src/shell-env.js'
118
121
  import { stripAnsi, maskApiKey, displayWidth, padEndDisplay, tintOverlayLines, keepOverlayTargetVisible, sliceOverlayLines, calculateViewport, sortResultsWithPinnedFavorites, adjustScrollOffset } from '../src/render-helpers.js'
@@ -315,27 +318,12 @@ export async function runApp(cliArgs, config) {
315
318
  saveConfig(config)
316
319
  }
317
320
 
318
- // 📖 Show interactive update prompt if a new version is available (skip in dev mode)
321
+ // 📖 Auto-update: if a new version is available, install it immediately (skip in dev mode)
322
+ // 📖 runUpdate() will relaunch the process with the new version after install completes
319
323
  if (latestVersion && !isDevMode) {
320
- const choice = await promptUpdateNotification(latestVersion)
321
- if (choice === 'update') {
322
- runUpdate(latestVersion)
323
- return // 📖 runUpdate relaunches the process — this line is a safety guard
324
- } else if (choice === 'changelogs') {
325
- const { execSync: _exec } = await import('child_process')
326
- const url = 'https://github.com/vava-nessa/free-coding-models/releases'
327
- try {
328
- if (process.platform === 'darwin') _exec(`open ${url}`)
329
- else if (process.platform === 'linux') _exec(`xdg-open ${url}`)
330
- else console.log(chalk.dim(` 📋 ${url}`))
331
- } catch { console.log(chalk.dim(` 📋 ${url}`)) }
332
- // 📖 After opening changelogs, re-prompt so user can still update or continue
333
- const choice2 = await promptUpdateNotification(latestVersion)
334
- if (choice2 === 'update') {
335
- runUpdate(latestVersion)
336
- return
337
- }
338
- }
324
+ console.log(chalk.dim(` ⬆ New version v${latestVersion} detected, updating...`))
325
+ runUpdate(latestVersion)
326
+ return // 📖 runUpdate relaunches the process — this line is a safety guard
339
327
  }
340
328
 
341
329
  // 📖 Dynamic OpenRouter free model discovery — fetch live free models from API
@@ -388,176 +376,24 @@ export async function runApp(cliArgs, config) {
388
376
  r.totalTokens = tokenTotalsByProviderModel[buildProviderModelTokenKey(r.providerKey, r.modelId)] || 0
389
377
  }
390
378
 
391
- // 📖 Add interactive selection state - cursor index and user's choice
392
- // 📖 sortColumn: 'rank'|'tier'|'origin'|'model'|'ping'|'avg'|'status'|'verdict'|'uptime'
393
- // 📖 sortDirection: 'asc' (default) or 'desc'
394
- // 📖 ping cadence is now mode-driven:
395
- // 📖 speed = 2s for 1 minute bursts
396
- // 📖 normal = 10s steady state
397
- // 📖 slow = 30s after 5 minutes of inactivity
398
- // 📖 forced = 4s and ignores inactivity / auto slowdowns
399
- const PING_MODE_INTERVALS = {
400
- speed: 2_000,
401
- normal: 10_000,
402
- slow: 30_000,
403
- forced: 4_000,
404
- }
405
- const PING_MODE_CYCLE = ['speed', 'normal', 'slow', 'forced']
406
- const SPEED_MODE_DURATION_MS = 60_000
407
- const IDLE_SLOW_AFTER_MS = 5 * 60_000
408
- const now = Date.now()
409
-
410
- const intervalToPingMode = (intervalMs) => {
411
- if (intervalMs <= 3000) return 'speed'
412
- if (intervalMs <= 5000) return 'forced'
413
- if (intervalMs >= 30000) return 'slow'
414
- return 'normal'
415
- }
416
-
417
- // 📖 tierFilter: current tier filter letter (null = all, 'S' = S+/S, 'A' = A+/A/A-, etc.)
418
- const state = {
379
+ // 📖 Build TUI state via factory keeps runApp() focused on orchestration, not initialization
380
+ const state = createTuiState({
419
381
  results,
420
- pendingPings: 0,
421
- frame: 0,
422
- cursor: 0,
423
- selectedModel: null,
424
- sortColumn: config.settings?.sortColumn ?? 'avg',
425
- sortDirection: (config.settings?.sortAsc ?? true) ? 'asc' : 'desc',
426
- pingInterval: PING_MODE_INTERVALS.speed, // 📖 Effective live interval derived from the active ping mode.
427
- pingMode: 'speed', // 📖 Current ping mode: speed | normal | slow | forced.
428
- pingModeSource: 'startup', // 📖 Why this mode is active: startup | manual | auto | idle | activity.
429
- speedModeUntil: now + SPEED_MODE_DURATION_MS, // 📖 Speed bursts auto-fall back to normal after 60 seconds.
430
- lastPingTime: now, // 📖 Track when last ping cycle started
431
- lastUserActivityAt: now, // 📖 Any keypress refreshes this timer; inactivity can force slow mode.
432
- resumeSpeedOnActivity: false, // 📖 Set after idle slowdown so the next activity restarts a 60s speed burst.
433
- startupLatestVersion: latestVersion, // 📖 Startup auto-check result reused by the footer banner after "skip update".
434
- lastReleaseDate: null, // 📖 Human-readable last npm publish date (fetched asynchronously).
435
- versionAlertsEnabled: !isDevMode, // 📖 Dev checkouts should not tell contributors to upgrade the global npm package.
436
- mode, // 📖 'opencode' or 'openclaw' — controls Enter action
437
- tierFilterMode: 0, // 📖 Index into TIER_CYCLE (0=All, 1=S+, 2=S, ...)
438
- originFilterMode: 0, // 📖 Index into ORIGIN_CYCLE (0=All, then providers)
439
- verdictFilterMode: 0, // 📖 Index into VERDICT_CYCLE (0=All, then verdicts)
440
- healthFilterMode: 0, // 📖 Index into HEALTH_CYCLE (0=All, then health states)
441
- hideUnconfiguredModels: config.settings?.hideUnconfiguredModels === true, // 📖 Hide providers with no configured API key when true.
442
- bestModeOnly: false, // 📖 E cycles Normal → Configured only → Usable only (Health UP + Verdict ≤ Slow)
443
- favoritesPinnedAndSticky: config.settings?.favoritesPinnedAndSticky === true, // 📖 false by default: favorites follow normal sort/filter rules until Y enables pinned+sticky mode.
444
- scrollOffset: 0, // 📖 First visible model index in viewport
445
- terminalRows: process.stdout.rows || 24, // 📖 Current terminal height
446
- terminalCols: process.stdout.columns || 80, // 📖 Current terminal width
447
- widthWarningStartedAt: (process.stdout.columns || 80) < WIDTH_WARNING_MIN_COLS ? now : null, // 📖 Start immediately in very narrow viewports.
448
- widthWarningDismissed: false, // 📖 Esc hides the narrow-terminal warning early for the current narrow-width session.
449
- widthWarningShowCount: 0, // 📖 No longer used — kept for backward compatibility. Warning now shows every time terminal is too small.
450
- // 📖 Settings screen state (P key opens it)
451
- settingsOpen: false, // 📖 Whether settings overlay is active
452
- settingsCursor: 0, // 📖 Which provider row is selected in settings
453
- settingsEditMode: false, // 📖 Whether we're in inline key editing mode (edit primary key)
454
- settingsAddKeyMode: false, // 📖 Whether we're in add-key mode (append a new key to provider)
455
- settingsEditBuffer: '', // 📖 Typed characters for the API key being edited
456
- settingsErrorMsg: null, // 📖 Temporary error message to display in settings
457
- settingsTestResults: {}, // 📖 { providerKey: 'pending'|'ok'|'auth_error'|'rate_limited'|'no_callable_model'|'fail'|'missing_key'|null }
458
- settingsTestDetails: {}, // 📖 Long-form diagnostics shown under Setup Instructions after a Settings key test.
459
- settingsUpdateState: 'idle', // 📖 'idle'|'checking'|'available'|'up-to-date'|'error'|'installing'
460
- settingsUpdateLatestVersion: null, // 📖 Latest npm version discovered from manual check
461
- settingsUpdateError: null, // 📖 Last update-check error message for maintenance row
462
- config, // 📖 Live reference to the config object (updated on save)
463
- sessionId, // 📖 Per-process analytics link between app_start and later app_use/app_action events.
464
- visibleSorted: [], // 📖 Cached visible+sorted models — shared between render loop and key handlers
465
- commandPaletteOpen: false, // 📖 Whether the Ctrl+P command palette overlay is active.
466
- commandPaletteQuery: '', // 📖 Current command palette search query.
467
- commandPaletteCursor: 0, // 📖 Selected command index in the filtered command list.
468
- commandPaletteScrollOffset: 0, // 📖 Vertical scroll offset for the command palette result viewport.
469
- commandPaletteResults: [], // 📖 Cached fuzzy-filtered command entries for the command palette.
470
- commandPaletteFrozenTable: null, // 📖 Frozen table snapshot rendered behind the command palette overlay.
471
- commandPaletteExpandedIds: new Set(['filters', 'actions']), // 📖 Expanded category IDs (filters + actions open by default for quick access).
472
- helpVisible: false, // 📖 Whether the help overlay (K key) is active
473
- settingsScrollOffset: 0, // 📖 Vertical scroll offset for Settings overlay viewport
474
- helpScrollOffset: 0, // 📖 Vertical scroll offset for Help overlay viewport
475
- // 📖 Install Endpoints overlay state (opened from Settings or Command Palette)
476
- installEndpointsOpen: false, // 📖 Whether the install-endpoints overlay is active
477
- installEndpointsPhase: 'providers', // 📖 providers | tools | scope | models | result
478
- installEndpointsCursor: 0, // 📖 Selected row within the current install phase
479
- installEndpointsScrollOffset: 0, // 📖 Vertical scroll offset for the install overlay viewport
480
- installEndpointsProviderKey: null, // 📖 Selected provider for endpoint installation
481
- installEndpointsToolMode: null, // 📖 Selected target tool mode
482
- installEndpointsConnectionMode: null, // 📖 Direct provider path retained for future install flow state.
483
- installEndpointsScope: null, // 📖 all | selected
484
- installEndpointsSelectedModelIds: new Set(), // 📖 Multi-select buffer for the selected-models phase
485
- installEndpointsErrorMsg: null, // 📖 Temporary validation/error message inside the install flow
486
- installEndpointsResult: null, // 📖 Final install result shown in the result phase
487
- // 📖 Missing-tool bootstrap overlay — confirms a one-click install before retrying the launch.
488
- toolInstallPromptOpen: false,
489
- toolInstallPromptCursor: 0,
490
- toolInstallPromptScrollOffset: 0,
491
- toolInstallPromptMode: null,
492
- toolInstallPromptModel: null,
493
- toolInstallPromptPlan: null,
494
- toolInstallPromptErrorMsg: null,
495
- // 📖 Incompatible model fallback overlay — shown when user presses Enter on a red-highlighted model.
496
- // 📖 Offers two options: switch to a compatible tool, or pick a similar SWE-scored model.
497
- incompatibleFallbackOpen: false,
498
- incompatibleFallbackCursor: 0,
499
- incompatibleFallbackScrollOffset: 0,
500
- incompatibleFallbackModel: null, // 📖 The incompatible model the user tried to launch
501
- incompatibleFallbackTools: [], // 📖 Compatible tools for the selected model
502
- incompatibleFallbackSimilarModels: [], // 📖 Similar SWE models compatible with current tool
503
- incompatibleFallbackSection: 'tools', // 📖 'tools' or 'models' — which section cursor is in
504
- // 📖 Smart Recommend overlay state (Q key opens it)
505
- recommendOpen: false, // 📖 Whether the recommend overlay is active
506
- recommendPhase: 'questionnaire', // 📖 'questionnaire'|'analyzing'|'results' — current phase
507
- recommendCursor: 0, // 📖 Selected question option (0-based index within current question)
508
- recommendQuestion: 0, // 📖 Which question we're on (0=task, 1=priority, 2=context)
509
- recommendAnswers: { taskType: null, priority: null, contextBudget: null }, // 📖 User's answers
510
- recommendProgress: 0, // 📖 Analysis progress percentage (0–100)
511
- recommendResults: [], // 📖 Top N recommendations from getTopRecommendations()
512
- recommendScrollOffset: 0, // 📖 Vertical scroll offset for Recommend overlay viewport
513
- recommendAnalysisTimer: null, // 📖 setInterval handle for the 10s analysis phase
514
- recommendPingTimer: null, // 📖 setInterval handle for 2 pings/sec during analysis
515
- recommendedKeys: new Set(), // 📖 Set of "providerKey/modelId" for recommended models (shown in main table)
516
-
517
- // 📖 OpenCode sync status (S key in settings)
518
- settingsSyncStatus: null, // 📖 { type: 'success'|'error', msg: string } — shown in settings footer
519
- // 📖 Changelog overlay state (N key opens it)
520
- changelogOpen: false, // 📖 Whether the changelog overlay is active
521
- changelogScrollOffset: 0, // 📖 Vertical scroll offset for changelog overlay viewport
522
- changelogPhase: 'index', // 📖 'index' (all versions) | 'details' (specific version)
523
- changelogCursor: 0, // 📖 Selected row in index phase
524
- changelogSelectedVersion: null, // 📖 Which version to show details for
525
- // 📖 Installed Models overlay state (Command Palette → Installed models)
526
- installedModelsOpen: false, // 📖 Whether the installed models overlay is active
527
- installedModelsCursor: 0, // 📖 Selected row (tool or model)
528
- installedModelsScrollOffset: 0, // 📖 Vertical scroll offset for overlay viewport
529
- installedModelsData: [], // 📖 Cached scan results
530
- installedModelsErrorMsg: null, // 📖 Error or status message
531
- // 📖 Router Dashboard overlay state (Shift+R opens it).
532
- routerDashboardOpen: false,
533
- routerDashboardStatus: 'idle', // 📖 idle | loading | ready | partial | stopped | stale | unreachable | malformed
534
- routerDashboardBaseUrl: null,
535
- routerDashboardPort: null,
536
- routerDashboardHealth: null,
537
- routerDashboardStats: null,
538
- routerDashboardError: null,
539
- routerDashboardScrollOffset: 0,
540
- routerDashboardEvents: [],
541
- routerDashboardLiveRequests: [],
542
- routerDashboardClearedAt: 0,
543
- routerDashboardLastUpdatedAt: null,
544
- routerDashboardLastRefreshStartedAt: null,
545
- routerDashboardPollTimer: null,
546
- routerDashboardEventAbort: null,
547
- routerDashboardEventStatus: 'idle',
548
- routerDashboardEventError: null,
549
- routerDashboardNotice: null,
550
- routerDashboardNoticeTimer: null,
551
- routerOnboardingScrollOffset: 0,
552
- routerDashboardEverOpened: false, // 📖 Set to true the first time dashboard opens (used for upgrade-path telemetry)
553
- routerDashboardCursorIndex: 0, // 📖 Cursor index for navigating the favorites list in router dashboard
554
- // 📖 Custom text filter (Ctrl+P palette → type text → Enter). Ephemeral — not saved to config.
555
- customTextFilter: null, // 📖 Active free-text filter string (null = off). Matches model name, ctx, provider key/name.
556
- }
382
+ config,
383
+ mode,
384
+ sessionId,
385
+ latestVersion,
386
+ isDevMode,
387
+ })
557
388
 
558
389
  // 📖 Apply the pre-fetched last release date now that state is initialized
559
390
  state.lastReleaseDate = lastReleaseDate
560
391
 
392
+ // 📖 Create ping loop controller and filter engine
393
+ const { setPingMode, refreshAutoPingMode, noteUserActivity } = createPingLoop(state)
394
+ const { applyTierFilter, buildOriginCycle } = createTuiFilters(state, { sources, getApiKey, PROVIDER_METADATA })
395
+ const ORIGIN_CYCLE = buildOriginCycle()
396
+
561
397
  // 📖 Re-clamp viewport on terminal resize
562
398
  process.stdout.on('resize', () => {
563
399
  const prevCols = state.terminalCols
@@ -582,6 +418,8 @@ export async function runApp(cliArgs, config) {
582
418
  let onMouseData = null // 📖 Mouse data listener — set after createMouseEventHandler
583
419
  let pingModel = null
584
420
 
421
+ // 📖 scheduleNextPing: wrapper that defers to the factory version, passing the current runPingCycle.
422
+ // 📖 Defined here because runPingCycle is created later in runApp() and can't be moved earlier.
585
423
  const scheduleNextPing = () => {
586
424
  clearTimeout(state.pingIntervalObj)
587
425
  const elapsed = Date.now() - state.lastPingTime
@@ -590,42 +428,6 @@ export async function runApp(cliArgs, config) {
590
428
  state.pingIntervalObj = setTimeout(runPingCycle, delay)
591
429
  }
592
430
 
593
- const setPingMode = (nextMode, source = 'manual') => {
594
- const modeInterval = PING_MODE_INTERVALS[nextMode] ?? PING_MODE_INTERVALS.normal
595
- state.pingMode = nextMode
596
- state.pingModeSource = source
597
- state.pingInterval = modeInterval
598
- state.speedModeUntil = nextMode === 'speed' ? Date.now() + SPEED_MODE_DURATION_MS : null
599
- state.resumeSpeedOnActivity = source === 'idle'
600
- if (state.pingIntervalObj) scheduleNextPing()
601
- }
602
-
603
- const noteUserActivity = () => {
604
- state.lastUserActivityAt = Date.now()
605
- if (state.pingMode === 'forced') return
606
- if (state.resumeSpeedOnActivity) {
607
- setPingMode('speed', 'activity')
608
- }
609
- }
610
-
611
- const refreshAutoPingMode = () => {
612
- const currentTime = Date.now()
613
- if (state.pingMode === 'forced') return
614
-
615
- if (state.speedModeUntil && currentTime >= state.speedModeUntil) {
616
- setPingMode('normal', 'auto')
617
- return
618
- }
619
-
620
- if (currentTime - state.lastUserActivityAt >= IDLE_SLOW_AFTER_MS) {
621
- if (state.pingMode !== 'slow' || state.pingModeSource !== 'idle') {
622
- setPingMode('slow', 'idle')
623
- } else {
624
- state.resumeSpeedOnActivity = true
625
- }
626
- }
627
- }
628
-
629
431
  // 📖 Load cache if available (for faster startup with cached ping results)
630
432
  const cached = loadCache()
631
433
  if (cached && cached.models) {
@@ -766,76 +568,11 @@ export async function runApp(cliArgs, config) {
766
568
  process.on('SIGTERM', () => exit(0))
767
569
 
768
570
  // 📖 originFilterMode: index into ORIGIN_CYCLE, 0=All, then each provider key in order
769
- const ORIGIN_CYCLE = [null, ...Object.keys(sources)]
770
571
  const resolvedTierFilter = config.settings?.tierFilter
771
572
  state.tierFilterMode = resolvedTierFilter ? Math.max(0, TIER_CYCLE.indexOf(resolvedTierFilter)) : 0
772
573
  const resolvedOriginFilter = config.settings?.originFilter
773
574
  state.originFilterMode = resolvedOriginFilter ? Math.max(0, ORIGIN_CYCLE.indexOf(resolvedOriginFilter)) : 0
774
575
 
775
- function applyTierFilter() {
776
- const activeTier = TIER_CYCLE[state.tierFilterMode]
777
- const activeOrigin = ORIGIN_CYCLE[state.originFilterMode]
778
- const activeVerdict = VERDICT_CYCLE[state.verdictFilterMode]
779
- const activeHealth = HEALTH_CYCLE[state.healthFilterMode]
780
- state.results.forEach(r => {
781
- const stickyFavorite = state.favoritesPinnedAndSticky && r.isFavorite
782
- // 📖 CLI-only tools (rovo, gemini) and Zen models don't need traditional API keys —
783
- // 📖 they authenticate via their own CLI login flow, so "configured only" should never hide them.
784
- const providerMeta = PROVIDER_METADATA[r.providerKey]
785
- const noKeyNeeded = providerMeta?.cliOnly || providerMeta?.zenOnly
786
- // 📖 E toggles "Show only configured & working models":
787
- // 📖 hide models where provider has no key, or where the health status is noauth/auth_error (but keep timeout and 429)
788
- const badHealth = r.status === 'noauth' || r.status === 'auth_error'
789
- const unconfiguredHide = state.hideUnconfiguredModels && !noKeyNeeded && (!getApiKey(state.config, r.providerKey) || badHealth)
790
- if (unconfiguredHide) {
791
- r.hidden = true
792
- return
793
- }
794
- // 📖 Usable only: only show models with Health UP and Verdict Perfect/Normal/Slow
795
- if (state.bestModeOnly) {
796
- const bmVerdict = getVerdict(r)
797
- const bmVerdictOk = ['Perfect', 'Normal', 'Slow'].includes(bmVerdict)
798
- const bmHealthOk = r.status === 'up'
799
- if (!bmHealthOk || !bmVerdictOk) {
800
- r.hidden = true
801
- return
802
- }
803
- }
804
- // 📖 Sticky-favorites mode keeps usable favorites visible regardless of
805
- // 📖 tier/provider/text filters, but "Usable only" health still wins above.
806
- if (stickyFavorite) {
807
- r.hidden = false
808
- return
809
- }
810
- // 📖 Apply tier, origin, verdict, and health filters — model is hidden if it fails any
811
- const allowedTiers = (activeTier && TIER_LETTER_MAP[activeTier]) ? TIER_LETTER_MAP[activeTier] : [activeTier]
812
- const tierHide = activeTier !== null && !allowedTiers.includes(r.tier)
813
- const originHide = activeOrigin !== null && r.providerKey !== activeOrigin
814
- // 📖 Verdict filter: match against getVerdict(r) when active
815
- const rVerdict = getVerdict(r)
816
- const verdictHide = activeVerdict !== null && rVerdict !== activeVerdict
817
- // 📖 Health filter: match against r.status when active
818
- const healthHide = activeHealth !== null && r.status !== activeHealth
819
- if (tierHide || originHide || verdictHide || healthHide) {
820
- r.hidden = true
821
- return
822
- }
823
- // 📖 Custom text filter — case-insensitive includes match against model name, ctx, provider key, and provider display name.
824
- if (state.customTextFilter) {
825
- const q = state.customTextFilter.toLowerCase()
826
- const providerName = (sources[r.providerKey]?.name || '').toLowerCase()
827
- const match = (r.label || '').toLowerCase().includes(q)
828
- || (r.ctx || '').toLowerCase().includes(q)
829
- || (r.providerKey || '').toLowerCase().includes(q)
830
- || providerName.includes(q)
831
- r.hidden = !match
832
- return
833
- }
834
- r.hidden = false
835
- })
836
- return state.results
837
- }
838
-
839
576
  // 📖 Apply initial filters so configured-only mode works on first render
840
577
  applyTierFilter()
841
578
 
@@ -1076,84 +813,48 @@ export async function runApp(cliArgs, config) {
1076
813
  const tableTerminalRows = state.terminalRows
1077
814
 
1078
815
  let tableContent = null
816
+ // 📖 Build renderTable options once per frame — keeps all call sites in sync
817
+ const tableOpts = {
818
+ results: state.results,
819
+ pendingPings: state.pendingPings,
820
+ frame: state.frame,
821
+ cursor: state.cursor,
822
+ sortColumn: state.sortColumn,
823
+ sortDirection: state.sortDirection,
824
+ pingInterval: state.pingInterval,
825
+ lastPingTime: state.lastPingTime,
826
+ mode: state.mode,
827
+ tierFilterMode: state.tierFilterMode,
828
+ scrollOffset: state.scrollOffset,
829
+ terminalRows: tableTerminalRows,
830
+ terminalCols: state.terminalCols,
831
+ originFilterMode: state.originFilterMode,
832
+ pingMode: state.pingMode,
833
+ pingModeSource: state.pingModeSource,
834
+ hideUnconfiguredModels: state.hideUnconfiguredModels,
835
+ widthWarningStartedAt: state.widthWarningStartedAt,
836
+ widthWarningDismissed: state.widthWarningDismissed,
837
+ settingsUpdateState: state.settingsUpdateState,
838
+ settingsUpdateLatestVersion: state.settingsUpdateLatestVersion,
839
+ startupLatestVersion: state.startupLatestVersion,
840
+ versionAlertsEnabled: state.versionAlertsEnabled,
841
+ favoritesPinnedAndSticky: state.favoritesPinnedAndSticky,
842
+ customTextFilter: state.customTextFilter,
843
+ lastReleaseDate: state.lastReleaseDate,
844
+ verdictFilterMode: state.verdictFilterMode,
845
+ healthFilterMode: state.healthFilterMode,
846
+ bestModeOnly: state.bestModeOnly,
847
+ }
1079
848
  if (state.commandPaletteOpen) {
1080
849
  if (!state.commandPaletteFrozenTable) {
1081
850
  // 📖 Freeze the full table (including countdown and spinner glyphs) while
1082
851
  // 📖 the command palette is open so the background remains perfectly static.
1083
- state.commandPaletteFrozenTable = renderTable(
1084
- state.results,
1085
- state.pendingPings,
1086
- state.frame,
1087
- state.cursor,
1088
- state.sortColumn,
1089
- state.sortDirection,
1090
- state.pingInterval,
1091
- state.lastPingTime,
1092
- state.mode,
1093
- state.tierFilterMode,
1094
- state.scrollOffset,
1095
- tableTerminalRows,
1096
- state.terminalCols,
1097
- state.originFilterMode,
1098
- null,
1099
- state.pingMode,
1100
- state.pingModeSource,
1101
- state.hideUnconfiguredModels,
1102
- state.widthWarningStartedAt,
1103
- state.widthWarningDismissed,
1104
- state.widthWarningShowCount,
1105
- state.settingsUpdateState,
1106
- state.settingsUpdateLatestVersion,
1107
- false,
1108
- state.startupLatestVersion,
1109
- state.versionAlertsEnabled,
1110
- state.favoritesPinnedAndSticky,
1111
- state.customTextFilter,
1112
- state.lastReleaseDate,
1113
- false,
1114
- state.verdictFilterMode,
1115
- state.healthFilterMode,
1116
- state.bestModeOnly
1117
- )
852
+ state.commandPaletteFrozenTable = renderTable(tableOpts)
1118
853
  }
1119
854
  tableContent = state.commandPaletteFrozenTable
1120
855
  } else {
1121
856
  state.commandPaletteFrozenTable = null
1122
- tableContent = renderTable(
1123
- state.results,
1124
- state.pendingPings,
1125
- state.frame,
1126
- state.cursor,
1127
- state.sortColumn,
1128
- state.sortDirection,
1129
- state.pingInterval,
1130
- state.lastPingTime,
1131
- state.mode,
1132
- state.tierFilterMode,
1133
- state.scrollOffset,
1134
- tableTerminalRows,
1135
- state.terminalCols,
1136
- state.originFilterMode,
1137
- null,
1138
- state.pingMode,
1139
- state.pingModeSource,
1140
- state.hideUnconfiguredModels,
1141
- state.widthWarningStartedAt,
1142
- state.widthWarningDismissed,
1143
- state.widthWarningShowCount,
1144
- state.settingsUpdateState,
1145
- state.settingsUpdateLatestVersion,
1146
- false,
1147
- state.startupLatestVersion,
1148
- state.versionAlertsEnabled,
1149
- state.favoritesPinnedAndSticky,
1150
- state.customTextFilter,
1151
- state.lastReleaseDate,
1152
- false,
1153
- state.verdictFilterMode,
1154
- state.healthFilterMode,
1155
- state.bestModeOnly
1156
- )
857
+ tableContent = renderTable(tableOpts)
1157
858
  }
1158
859
 
1159
860
  const content = state.settingsOpen
@@ -1200,7 +901,37 @@ export async function runApp(cliArgs, config) {
1200
901
  pinFavorites: state.favoritesPinnedAndSticky,
1201
902
  })
1202
903
 
1203
- 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.terminalCols, state.originFilterMode, null, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels, state.widthWarningStartedAt, state.widthWarningDismissed, state.widthWarningShowCount, state.settingsUpdateState, state.settingsUpdateLatestVersion, false, state.startupLatestVersion, state.versionAlertsEnabled, state.favoritesPinnedAndSticky, state.customTextFilter, state.lastReleaseDate, false, state.verdictFilterMode, state.healthFilterMode, state.bestModeOnly))
904
+ process.stdout.write(ALT_HOME + renderTable({
905
+ results: state.results,
906
+ pendingPings: state.pendingPings,
907
+ frame: state.frame,
908
+ cursor: state.cursor,
909
+ sortColumn: state.sortColumn,
910
+ sortDirection: state.sortDirection,
911
+ pingInterval: state.pingInterval,
912
+ lastPingTime: state.lastPingTime,
913
+ mode: state.mode,
914
+ tierFilterMode: state.tierFilterMode,
915
+ scrollOffset: state.scrollOffset,
916
+ terminalRows: state.terminalRows,
917
+ terminalCols: state.terminalCols,
918
+ originFilterMode: state.originFilterMode,
919
+ pingMode: state.pingMode,
920
+ pingModeSource: state.pingModeSource,
921
+ hideUnconfiguredModels: state.hideUnconfiguredModels,
922
+ widthWarningStartedAt: state.widthWarningStartedAt,
923
+ widthWarningDismissed: state.widthWarningDismissed,
924
+ settingsUpdateState: state.settingsUpdateState,
925
+ settingsUpdateLatestVersion: state.settingsUpdateLatestVersion,
926
+ startupLatestVersion: state.startupLatestVersion,
927
+ versionAlertsEnabled: state.versionAlertsEnabled,
928
+ favoritesPinnedAndSticky: state.favoritesPinnedAndSticky,
929
+ customTextFilter: state.customTextFilter,
930
+ lastReleaseDate: state.lastReleaseDate,
931
+ verdictFilterMode: state.verdictFilterMode,
932
+ healthFilterMode: state.healthFilterMode,
933
+ bestModeOnly: state.bestModeOnly,
934
+ }))
1204
935
  if (process.stdout.isTTY) {
1205
936
  process.stdout.flush && process.stdout.flush()
1206
937
  }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @file ping-loop.js
3
+ * @description Ping cadence management — mode transitions, scheduling, and auto-throttle logic.
4
+ *
5
+ * @details
6
+ * The ping loop runs forever in the background, probing model endpoints at a cadence
7
+ * determined by the current "ping mode" (speed / normal / slow / forced).
8
+ *
9
+ * 🎯 Mode transitions:
10
+ * - **speed**: Active on startup (2s intervals). Auto-falls back to normal after 60s.
11
+ * - **normal**: Steady state (10s intervals). The default after speed burst expires.
12
+ * - **slow**: Idle throttle (30s intervals). Activates after 5 minutes of no user interaction.
13
+ * - **forced**: User-triggered fast burst (4s, W key). Ignores idle / auto slowdowns.
14
+ *
15
+ * 📖 This module exports factory functions that close over the state object,
16
+ * so each app instance gets its own ping loop with its own timers.
17
+ *
18
+ * @functions
19
+ * → createPingLoop(state) — Returns { setPingMode, refreshAutoPingMode, noteUserActivity }
20
+ *
21
+ * @exports createPingLoop
22
+ *
23
+ * @see src/tui-state.js — PING_MODE_INTERVALS, SPEED_MODE_DURATION_MS, IDLE_SLOW_AFTER_MS
24
+ * @see src/app.js — calls createPingLoop() and wires into the TUI event loop
25
+ */
26
+
27
+ import {
28
+ PING_MODE_INTERVALS,
29
+ SPEED_MODE_DURATION_MS,
30
+ IDLE_SLOW_AFTER_MS,
31
+ } from './tui-state.js'
32
+
33
+ /**
34
+ * 📖 createPingLoop: Build the ping loop control functions for a given TUI state.
35
+ *
36
+ * 📖 Returns an object with all the ping cadence control functions that were previously
37
+ * 📖 inline closures in runApp(). Each function closes over the provided `state` object.
38
+ *
39
+ * @param {object} state — The TUI state object (from createTuiState)
40
+ * @returns {{
41
+ * setPingMode: (nextMode: string, source?: string) => void,
42
+ * refreshAutoPingMode: () => void,
43
+ * noteUserActivity: () => void,
44
+ * }}
45
+ */
46
+ export function createPingLoop(state) {
47
+ /**
48
+ * 📖 setPingMode: Switch the active ping mode and update the interval.
49
+ * @param {string} nextMode — 'speed' | 'normal' | 'slow' | 'forced'
50
+ * @param {string} [source='manual'] — Why the mode changed (startup | manual | auto | idle | activity)
51
+ */
52
+ function setPingMode(nextMode, source = 'manual') {
53
+ const modeInterval = PING_MODE_INTERVALS[nextMode] ?? PING_MODE_INTERVALS.normal
54
+ state.pingMode = nextMode
55
+ state.pingModeSource = source
56
+ state.pingInterval = modeInterval
57
+ state.speedModeUntil = nextMode === 'speed' ? Date.now() + SPEED_MODE_DURATION_MS : null
58
+ state.resumeSpeedOnActivity = source === 'idle'
59
+ // 📖 Clear existing timer so the next scheduleNextPing() call picks up the new interval.
60
+ // 📖 The caller (app.js) owns scheduleNextPing and re-schedules after calling setPingMode.
61
+ clearTimeout(state.pingIntervalObj)
62
+ }
63
+
64
+ /**
65
+ * 📖 refreshAutoPingMode: Check timers and auto-transition between ping modes.
66
+ * 📖 Called at the start of each ping cycle and each render frame.
67
+ */
68
+ function refreshAutoPingMode() {
69
+ const currentTime = Date.now()
70
+ if (state.pingMode === 'forced') return
71
+
72
+ // 📖 Speed burst expired → fall back to normal
73
+ if (state.speedModeUntil && currentTime >= state.speedModeUntil) {
74
+ setPingMode('normal', 'auto')
75
+ return
76
+ }
77
+
78
+ // 📖 User idle for too long → slow down
79
+ if (currentTime - state.lastUserActivityAt >= IDLE_SLOW_AFTER_MS) {
80
+ if (state.pingMode !== 'slow' || state.pingModeSource !== 'idle') {
81
+ setPingMode('slow', 'idle')
82
+ } else {
83
+ state.resumeSpeedOnActivity = true
84
+ }
85
+ }
86
+ }
87
+
88
+ /**
89
+ * 📖 noteUserActivity: Mark that the user is active (key was pressed).
90
+ * 📖 Restarts a speed burst if the loop was previously slowed by idle.
91
+ */
92
+ function noteUserActivity() {
93
+ state.lastUserActivityAt = Date.now()
94
+ if (state.pingMode === 'forced') return
95
+ if (state.resumeSpeedOnActivity) {
96
+ setPingMode('speed', 'activity')
97
+ }
98
+ }
99
+
100
+ return {
101
+ setPingMode,
102
+ refreshAutoPingMode,
103
+ noteUserActivity,
104
+ }
105
+ }