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.
- package/changelog/v0.3.70.md +8 -0
- package/changelog/v0.3.71.md +11 -0
- package/package.json +1 -1
- package/src/app.js +89 -358
- package/src/ping-loop.js +105 -0
- package/src/render-table.js +79 -3
- package/src/router-daemon.js +33 -0
- package/src/tui-filters.js +128 -0
- package/src/tui-state.js +265 -0
- package/src/updater.js +1 -105
- package/web/dist/assets/{index-DwztVNMT.css → index-CGN-0_A0.css} +1 -1
- package/web/dist/assets/{index-CugpJNf7.js → index-GNK87eO5.js} +4 -4
- package/web/dist/index.html +2 -2
- package/web/src/App.jsx +7 -0
- package/web/src/components/layout/Sidebar.jsx +1 -0
- package/web/src/components/map/MapView.jsx +17 -0
- package/web/src/components/map/MapView.module.css +25 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "free-coding-models",
|
|
3
|
-
"version": "0.3.
|
|
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,
|
|
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
|
-
// 📖
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
// 📖
|
|
392
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/ping-loop.js
ADDED
|
@@ -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
|
+
}
|