free-coding-models 0.3.12 β†’ 0.3.14

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.
@@ -2,943 +2,62 @@
2
2
  /**
3
3
  * @file free-coding-models.js
4
4
  * @description Live terminal availability checker for coding LLM models with OpenCode & OpenClaw integration.
5
- *
6
- * @details
7
- * This CLI tool discovers and benchmarks language models optimized for coding.
8
- * It runs in an alternate screen buffer, pings all models in parallel, re-pings successful ones
9
- * multiple times for reliable latency measurements, and prints a clean final table.
10
- * During benchmarking, users can navigate with arrow keys and press Enter to act on the selected model.
11
- *
12
- * 🎯 Key features:
13
- * - Parallel pings across all models with animated real-time updates (multi-provider)
14
- * - Continuous monitoring with 60-second ping intervals (never stops)
15
- * - Rolling averages calculated from ALL successful pings since start
16
- * - Best-per-tier highlighting with medals (πŸ₯‡πŸ₯ˆπŸ₯‰)
17
- * - Interactive navigation with arrow keys directly in the table
18
- * - Instant OpenCode / OpenClaw / external-tool action on Enter key press
19
- * - Direct mode flags plus an in-app Z-cycle for the public launcher set
20
- * - Automatic config detection and model setup for both tools
21
- * - JSON config stored in ~/.free-coding-models.json (auto-migrates from old plain-text)
22
- * - Multi-provider support via sources.js (NIM/Groq/Cerebras/OpenRouter/Hugging Face/Replicate/DeepInfra/... β€” extensible)
23
- * - Settings screen (P key) to manage API keys, provider toggles, manual updates, and provider-key diagnostics
24
- * - Install Endpoints flow (Y key) to push provider catalogs into OpenCode, OpenClaw, Crush, and Goose
25
- * - Favorites system: toggle with F, pin rows to top, persist between sessions
26
- * - Uptime percentage tracking (successful pings / total pings)
27
- * - Sortable columns (R/O/M/L/A/S/C/H/V/B/U/G keys)
28
- * - Tier filtering via T key (cycles S+→S→A+→A→A-→B+→B→C→All)
29
- *
30
- * β†’ Functions:
31
- * - `loadConfig` / `saveConfig` / `getApiKey`: Multi-provider JSON config via lib/config.js
32
- * - `getTelemetryDistinctId`: Generate/reuse a stable anonymous ID for telemetry
33
- * - `getTelemetryTerminal`: Infer terminal family (Terminal.app, iTerm2, kitty, etc.)
34
- * - `isTelemetryDebugEnabled` / `telemetryDebug`: Optional runtime telemetry diagnostics via env
35
- * - `sendUsageTelemetry`: Fire-and-forget anonymous app-start event
36
- * - `ensureFavoritesConfig` / `toggleFavoriteModel`: Persist and toggle pinned favorites
37
- * - `promptApiKey`: Interactive wizard for first-time multi-provider API key setup
38
- * - `buildPingRequest` / `ping`: Build provider-specific probe requests and measure latency
39
- * - `renderTable`: Generate ASCII table with colored latency indicators and status emojis
40
- * - `getAvg`: Calculate average latency from all successful pings
41
- * - `getVerdict`: Determine verdict string based on average latency (Overloaded for 429)
42
- * - `getUptime`: Calculate uptime percentage from ping history
43
- * - `sortResults`: Sort models by various columns
44
- * - `checkNvidiaNimConfig`: Check if NVIDIA NIM provider is configured in OpenCode
45
- * - `isTcpPortAvailable` / `resolveOpenCodeTmuxPort`: Pick a safe OpenCode port when running in tmux
46
- * - `startOpenCode`: Launch OpenCode CLI with selected model (configures if needed)
47
- * - `startOpenCodeDesktop`: Set model in shared config & open OpenCode Desktop app
48
- * - `loadOpenClawConfig` / `saveOpenClawConfig`: Manage ~/.openclaw/openclaw.json
49
- * - `startOpenClaw`: Set selected model as default in OpenClaw config (remote, no launch)
50
- * - `filterByTier`: Filter models by tier letter prefix (S, A, B, C)
51
- * - `main`: Orchestrates CLI flow, wizard, ping loops, animation, and output
52
- *
53
- * πŸ“¦ Dependencies:
54
- * - Node.js 18+ (native fetch)
55
- * - chalk: Terminal styling and colors
56
- * - readline: Interactive input handling
57
- * - sources.js: Model definitions from all providers
58
- *
59
- * βš™οΈ Configuration:
60
- * - API keys stored per-provider in ~/.free-coding-models.json (0600 perms)
61
- * - Old ~/.free-coding-models plain-text auto-migrated as nvidia key on first run
62
- * - Env vars override config: NVIDIA_API_KEY, GROQ_API_KEY, CEREBRAS_API_KEY, OPENROUTER_API_KEY, HUGGINGFACE_API_KEY/HF_TOKEN, REPLICATE_API_TOKEN, DEEPINFRA_API_KEY/DEEPINFRA_TOKEN, FIREWORKS_API_KEY, SILICONFLOW_API_KEY, TOGETHER_API_KEY, PERPLEXITY_API_KEY, ZAI_API_KEY, etc.
63
- * - ZAI (z.ai) uses a non-standard base path; cloudflare needs CLOUDFLARE_ACCOUNT_ID in env.
64
- * - Cloudflare Workers AI requires both CLOUDFLARE_API_TOKEN (or CLOUDFLARE_API_KEY) and CLOUDFLARE_ACCOUNT_ID
65
- * - Models loaded from sources.js β€” all provider/model definitions are centralized there
66
- * - OpenCode config: ~/.config/opencode/opencode.json
67
- * - OpenClaw config: ~/.openclaw/openclaw.json
68
- * - Ping timeout: 15s per attempt
69
- * - Ping cadence: 2s startup burst for 60s, 10s steady state, 30s after 5m idle, forced 4s via `W`
70
- * - Animation: 12 FPS with braille spinners
71
- *
72
- * πŸš€ CLI flags:
73
- * - (no flag): Start in OpenCode CLI mode
74
- * - --opencode: OpenCode CLI mode (launch CLI with selected model)
75
- * - --opencode-desktop: OpenCode Desktop mode (set model & open Desktop app)
76
- * - --openclaw: OpenClaw mode (set selected model as default in OpenClaw)
77
- * - --crush / --goose / --pi: launch the currently selected model in the supported external CLI
78
- * - --best: Show only top-tier models (A+, S, S+)
79
- * - --fiable: Analyze 10s and output the most reliable model
80
- * - --json: Output results as JSON (for scripting/automation)
81
- * - --recommend: Open Smart Recommend immediately on startup
82
- * - --profile <name>: Load a saved config profile before entering the TUI
83
- * - --no-telemetry: Disable anonymous usage analytics for this run
84
- * - --help / -h: Print the full CLI help and exit
85
- * - --tier S/A/B/C: Filter models by tier letter (S=S+/S, A=A+/A/A-, B=B+/B, C=C)
86
- *
87
- * @see {@link https://build.nvidia.com} NVIDIA API key generation
88
- * @see {@link https://github.com/opencode-ai/opencode} OpenCode repository
89
- * @see {@link https://openclaw.ai} OpenClaw documentation
90
5
  */
91
6
 
92
- import chalk from 'chalk'
93
- import { createRequire } from 'module'
94
- import { fileURLToPath } from 'url'
95
- import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'fs'
96
- import { randomUUID } from 'crypto'
97
- import { homedir } from 'os'
98
- import { join, dirname } from 'path'
99
- import { MODELS, sources } from '../sources.js'
100
- import { getAvg, getVerdict, getUptime, getP95, getJitter, getStabilityScore, sortResults, filterByTier, findBestModel, parseArgs, TIER_ORDER, VERDICT_ORDER, TIER_LETTER_MAP, scoreModelForTask, getTopRecommendations, TASK_TYPES, PRIORITY_TYPES, CONTEXT_BUDGETS, formatCtxWindow, labelFromId, formatResultsAsJSON } from '../src/utils.js'
101
- import { loadConfig, saveConfig, getApiKey, resolveApiKeys, addApiKey, removeApiKey, isProviderEnabled, persistApiKeysForProvider } from '../src/config.js'
102
- import { buildMergedModels } from '../src/model-merger.js'
103
- import { loadOpenCodeConfig, saveOpenCodeConfig } from '../src/opencode-config.js'
104
- import { usageForRow as _usageForRow } from '../src/usage-reader.js'
105
- import { buildProviderModelTokenKey, loadTokenUsageByProviderModel } from '../src/token-usage-reader.js'
106
- import { parseOpenRouterResponse, fetchProviderQuota as _fetchProviderQuotaFromModule } from '../src/provider-quota-fetchers.js'
107
- import { isKnownQuotaTelemetry } from '../src/quota-capabilities.js'
108
- import { ALT_ENTER, ALT_LEAVE, ALT_HOME, PING_TIMEOUT, PING_INTERVAL, FPS, COL_MODEL, COL_MS, CELL_W, FRAMES, TIER_CYCLE, SETTINGS_OVERLAY_BG, HELP_OVERLAY_BG, RECOMMEND_OVERLAY_BG, OVERLAY_PANEL_WIDTH, TABLE_HEADER_LINES, TABLE_FOOTER_LINES, TABLE_FIXED_LINES, msCell, spinCell } from '../src/constants.js'
109
- import { TIER_COLOR } from '../src/tier-colors.js'
110
- import { resolveCloudflareUrl, buildPingRequest, ping, extractQuotaPercent, getProviderQuotaPercentCached, usagePlaceholderForProvider } from '../src/ping.js'
111
- import { runFiableMode, filterByTierOrExit, fetchOpenRouterFreeModels } from '../src/analysis.js'
112
- import { PROVIDER_METADATA, ENV_VAR_NAMES, isWindows, isMac } from '../src/provider-metadata.js'
113
- import { parseTelemetryEnv, isTelemetryDebugEnabled, telemetryDebug, ensureTelemetryConfig, getTelemetryDistinctId, getTelemetrySystem, getTelemetryTerminal, isTelemetryEnabled, sendUsageTelemetry, sendBugReport } from '../src/telemetry.js'
114
- import { ensureFavoritesConfig, toFavoriteKey, syncFavoriteFlags, toggleFavoriteModel } from '../src/favorites.js'
115
- import { checkForUpdateDetailed, checkForUpdate, runUpdate, promptUpdateNotification } from '../src/updater.js'
116
- import { promptApiKey } from '../src/setup.js'
117
- import { stripAnsi, maskApiKey, displayWidth, padEndDisplay, tintOverlayLines, keepOverlayTargetVisible, sliceOverlayLines, calculateViewport, sortResultsWithPinnedFavorites, adjustScrollOffset } from '../src/render-helpers.js'
118
- import { renderTable, PROVIDER_COLOR } from '../src/render-table.js'
119
- import { setOpenCodeModelData, startOpenCode, startOpenCodeDesktop } from '../src/opencode.js'
120
- import { startOpenClaw } from '../src/openclaw.js'
121
- import { createOverlayRenderers } from '../src/overlays.js'
122
- import { createKeyHandler } from '../src/key-handler.js'
123
- import { getToolModeOrder, getToolMeta } from '../src/tool-metadata.js'
124
- import { startExternalTool } from '../src/tool-launchers.js'
125
- import { getConfiguredInstallableProviders, installProviderEndpoints, refreshInstalledEndpoints, getInstallTargetModes, getProviderCatalogModels } from '../src/endpoint-installer.js'
126
- import { loadCache, saveCache, clearCache, getCacheAge } from '../src/cache.js'
127
- import { checkConfigSecurity } from '../src/security.js'
128
- import { buildCliHelpText } from '../src/cli-help.js'
129
-
130
- // πŸ“– mergedModels: cross-provider grouped model list (one entry per label, N providers each)
131
- // πŸ“– mergedModelByLabel: fast lookup map from display label β†’ merged model entry
132
- const mergedModels = buildMergedModels(MODELS)
133
- const mergedModelByLabel = new Map(mergedModels.map(m => [m.label, m]))
134
- setOpenCodeModelData(mergedModels, mergedModelByLabel)
135
-
136
- // πŸ“– Provider quota cache is managed by lib/provider-quota-fetchers.js (TTL + backoff).
137
- // πŸ“– Usage placeholder logic uses isKnownQuotaTelemetry() from lib/quota-capabilities.js.
138
-
139
- const require = createRequire(import.meta.url)
140
- const readline = require('readline')
141
-
142
- // ─── Version check ────────────────────────────────────────────────────────────
143
- const pkg = require('../package.json')
144
- const LOCAL_VERSION = pkg.version
145
-
146
- // πŸ“– sendBugReport β†’ imported from ../src/telemetry.js
147
-
148
- // πŸ“– parseTelemetryEnv, isTelemetryDebugEnabled, telemetryDebug, ensureTelemetryConfig β†’ imported from ../src/telemetry.js
149
-
150
- // πŸ“– ensureFavoritesConfig, toFavoriteKey, syncFavoriteFlags, toggleFavoriteModel β†’ imported from ../src/favorites.js
151
-
152
- // ─── Alternate screen control ─────────────────────────────────────────────────
153
- // πŸ“– \x1b[?1049h = enter alt screen \x1b[?1049l = leave alt screen
154
- // πŸ“– \x1b[?25l = hide cursor \x1b[?25h = show cursor
155
- // πŸ“– \x1b[H = cursor to top
156
- // πŸ“– NOTE: We avoid \x1b[2J (clear screen) because Ghostty scrolls cleared
157
- // πŸ“– content into the scrollback on the alt screen, pushing the header off-screen.
158
- // πŸ“– Instead we overwrite in place: cursor home, then \x1b[K (erase to EOL) per line.
159
- // πŸ“– \x1b[?7l disables auto-wrap so wide rows clip at the right edge instead of
160
- // πŸ“– wrapping to the next line (which would double the row height and overflow).
161
- // NOTE: All constants (ALT_ENTER, PING_TIMEOUT, etc.) are imported from ../src/constants.js
162
-
163
- // ─── Styling ──────────────────────────────────────────────────────────────────
164
- // πŸ“– Tier colors (TIER_COLOR) are imported from ../src/tier-colors.js
165
- // πŸ“– All TUI constants (ALT_ENTER, PING_TIMEOUT, etc.) are imported from ../src/constants.js
166
-
167
- // πŸ“– renderTable is now extracted to ../src/render-table.js
168
-
169
- // ─── OpenCode integration ──────────────────────────────────────────────────────
170
- // πŸ“– OpenCode helpers are imported from ../src/opencode.js
171
-
172
- // ─── OpenCode integration ──────────────────────────────────────────────────────
173
- // πŸ“– OpenCode helpers are imported from ../src/opencode.js
7
+ import chalk from 'chalk';
8
+ import { parseArgs, TIER_LETTER_MAP } from '../src/utils.js';
9
+ import { loadConfig } from '../src/config.js';
10
+ import { ensureTelemetryConfig } from '../src/telemetry.js';
11
+ import { ensureFavoritesConfig } from '../src/favorites.js';
12
+ import { buildCliHelpText } from '../src/cli-help.js';
13
+ import { ALT_LEAVE } from '../src/constants.js';
14
+ import { runApp } from '../src/app.js';
15
+
16
+ // Global error handlers to ensure terminal is restored if something crashes catastrophically
17
+ process.on('uncaughtException', (err) => {
18
+ process.stdout.write(ALT_LEAVE);
19
+ console.error(chalk.red('\n[Fatal Error] An unhandled exception occurred.'));
20
+ console.error(err);
21
+ console.error(chalk.yellow('\nPlease file an issue at https://github.com/vava-nessa/free-coding-models/issues or use the feedback form (I key) to report this to the author.'));
22
+ process.exit(1);
23
+ });
24
+
25
+ process.on('unhandledRejection', (reason, promise) => {
26
+ process.stdout.write(ALT_LEAVE);
27
+ console.error(chalk.red('\n[Fatal Error] An unhandled promise rejection occurred.'));
28
+ console.error(reason);
29
+ console.error(chalk.yellow('\nPlease file an issue at https://github.com/vava-nessa/free-coding-models/issues or use the feedback form (I key) to report this to the author.'));
30
+ process.exit(1);
31
+ });
174
32
 
175
33
  async function main() {
176
- const cliArgs = parseArgs(process.argv)
34
+ const cliArgs = parseArgs(process.argv);
177
35
 
178
36
  if (cliArgs.helpMode) {
179
- console.log()
180
- console.log(buildCliHelpText({ chalk, title: 'free-coding-models' }))
181
- console.log()
182
- process.exit(0)
37
+ console.log();
38
+ console.log(buildCliHelpText({ chalk, title: 'free-coding-models' }));
39
+ console.log();
40
+ process.exit(0);
183
41
  }
184
42
 
185
43
  // Validate --tier early, before entering alternate screen
186
44
  if (cliArgs.tierFilter && !TIER_LETTER_MAP[cliArgs.tierFilter]) {
187
- console.error(chalk.red(` Unknown tier "${cliArgs.tierFilter}". Valid tiers: S, A, B, C`))
188
- process.exit(1)
189
- }
190
-
191
- // πŸ“– Load JSON config (auto-migrates old plain-text ~/.free-coding-models if needed)
192
- const config = loadConfig()
193
- ensureTelemetryConfig(config)
194
- ensureFavoritesConfig(config)
195
-
196
- // πŸ“– Check config file security β€” warn and offer auto-fix if permissions are too open
197
- const securityCheck = checkConfigSecurity()
198
- if (!securityCheck.wasSecure && !securityCheck.wasFixed) {
199
- // πŸ“– User declined auto-fix or it failed β€” continue anyway, just warned
200
- }
201
-
202
- // πŸ“– Apply CLI overrides for settings
203
- if (cliArgs.sortColumn) config.settings.sortColumn = cliArgs.sortColumn
204
- if (cliArgs.sortDirection) config.settings.sortAsc = cliArgs.sortDirection === 'asc'
205
- if (cliArgs.originFilter) config.settings.originFilter = cliArgs.originFilter
206
- if (cliArgs.pingInterval) config.settings.pingInterval = cliArgs.pingInterval
207
- if (cliArgs.hideUnconfigured) config.settings.hideUnconfiguredModels = true
208
- if (cliArgs.showUnconfigured) config.settings.hideUnconfiguredModels = false
209
- if (cliArgs.disableWidthsWarning) config.settings.disableWidthsWarning = true
210
-
211
- // πŸ“– Apply premium mode: show only S‑tier models sorted by verdict
212
- if (cliArgs.premiumMode) {
213
- config.settings.tierFilter = 'S'
214
- config.settings.sortColumn = 'verdict'
215
- config.settings.sortAsc = true
216
- }
217
-
218
- // πŸ“– Profile system removed - API keys now persist permanently across all sessions
219
-
220
- // πŸ“– Check if any provider has a key β€” if not, run the first-time setup wizard
221
- const hasAnyKey = Object.keys(sources).some(pk => !!getApiKey(config, pk))
222
-
223
- if (!hasAnyKey) {
224
- const result = await promptApiKey(config)
225
- if (!result) {
226
- console.log()
227
- console.log(chalk.red(' βœ– No API key provided.'))
228
- console.log(chalk.dim(' Run `free-coding-models` again or set NVIDIA_API_KEY / GROQ_API_KEY / CEREBRAS_API_KEY.'))
229
- console.log()
230
- process.exit(1)
231
- }
232
- }
233
-
234
- // πŸ“– Default mode: use the last persisted launcher choice when valid,
235
- // πŸ“– otherwise fall back to OpenCode CLI.
236
- let mode = getToolModeOrder().includes(config.settings?.preferredToolMode)
237
- ? config.settings.preferredToolMode
238
- : 'opencode'
239
- const requestedMode = getToolModeOrder().find((toolMode) => {
240
- const flagByMode = {
241
- opencode: cliArgs.openCodeMode,
242
- 'opencode-desktop': cliArgs.openCodeDesktopMode,
243
- openclaw: cliArgs.openClawMode,
244
- aider: cliArgs.aiderMode,
245
- crush: cliArgs.crushMode,
246
- goose: cliArgs.gooseMode,
247
- qwen: cliArgs.qwenMode,
248
- openhands: cliArgs.openHandsMode,
249
- amp: cliArgs.ampMode,
250
- pi: cliArgs.piMode,
251
- }
252
- return flagByMode[toolMode] === true
253
- })
254
- if (requestedMode) mode = requestedMode
255
-
256
- // πŸ“– Track app opening early so fast exits are still counted.
257
- // πŸ“– Must run before update checks because npm registry lookups can add startup delay.
258
- void sendUsageTelemetry(config, cliArgs, {
259
- event: 'app_start',
260
- version: LOCAL_VERSION,
261
- mode,
262
- ts: new Date().toISOString(),
263
- })
264
-
265
- // πŸ“– Auto-update detection: check npm registry for new versions at startup.
266
- // πŸ“– If a new version is available, show an interactive prompt (Update / Changelogs / Skip).
267
- // πŸ“– Dev mode (git checkout) skips auto-update to avoid infinite relaunch loops.
268
- let latestVersion = null
269
- const isDevMode = existsSync(join(dirname(fileURLToPath(import.meta.url)), '..', '.git'))
270
- try {
271
- latestVersion = await checkForUpdate()
272
- // πŸ“– Reset failure counter on successful check
273
- if (config.settings?.updateCheckFailures) {
274
- config.settings.updateCheckFailures = 0
275
- saveConfig(config)
276
- }
277
- } catch (err) {
278
- const failures = (config.settings?.updateCheckFailures || 0) + 1
279
- if (!config.settings) config.settings = {}
280
- config.settings.updateCheckFailures = Math.min(failures, 3)
281
- saveConfig(config)
282
- }
283
-
284
- // πŸ“– Show interactive update prompt if a new version is available (skip in dev mode)
285
- if (latestVersion && !isDevMode) {
286
- const choice = await promptUpdateNotification(latestVersion)
287
- if (choice === 'update') {
288
- runUpdate(latestVersion)
289
- return // πŸ“– runUpdate relaunches the process β€” this line is a safety guard
290
- } else if (choice === 'changelogs') {
291
- const { execSync: _exec } = await import('child_process')
292
- const url = 'https://github.com/vava-nessa/free-coding-models/releases'
293
- try {
294
- if (process.platform === 'darwin') _exec(`open ${url}`)
295
- else if (process.platform === 'linux') _exec(`xdg-open ${url}`)
296
- else console.log(chalk.dim(` πŸ“‹ ${url}`))
297
- } catch { console.log(chalk.dim(` πŸ“‹ ${url}`)) }
298
- // πŸ“– After opening changelogs, re-prompt so user can still update or continue
299
- const choice2 = await promptUpdateNotification(latestVersion)
300
- if (choice2 === 'update') {
301
- runUpdate(latestVersion)
302
- return
303
- }
304
- }
305
- }
306
-
307
- // πŸ“– Dynamic OpenRouter free model discovery β€” fetch live free models from API
308
- // πŸ“– Replaces static openrouter entries in MODELS with fresh data.
309
- // πŸ“– Fallback: if fetch fails, the static list from sources.js stays intact + warning shown.
310
- const dynamicModels = await fetchOpenRouterFreeModels()
311
- if (dynamicModels) {
312
- // πŸ“– Remove all existing openrouter entries from MODELS
313
- for (let i = MODELS.length - 1; i >= 0; i--) {
314
- if (MODELS[i][5] === 'openrouter') MODELS.splice(i, 1)
315
- }
316
- // πŸ“– Push fresh entries with 'openrouter' providerKey
317
- for (const [modelId, label, tier, swe, ctx] of dynamicModels) {
318
- MODELS.push([modelId, label, tier, swe, ctx, 'openrouter'])
319
- }
320
- } else {
321
- console.log(chalk.yellow(' OpenRouter: using cached model list (live fetch failed)'))
322
- }
323
-
324
- // πŸ“– Re-sync tracked external-tool catalogs after the live provider catalog has settled.
325
- // πŸ“– This keeps prior `Y` installs aligned with the current FCM model list.
326
- refreshInstalledEndpoints(config)
327
-
328
- // πŸ“– Build results from MODELS β€” only include enabled providers
329
- // πŸ“– Each result gets providerKey so ping() knows which URL + API key to use
330
-
331
- let results = MODELS
332
- .filter(([,,,,,providerKey]) => isProviderEnabled(config, providerKey))
333
- .map(([modelId, label, tier, sweScore, ctx, providerKey], i) => ({
334
- idx: i + 1, modelId, label, tier, sweScore, ctx, providerKey,
335
- status: 'pending',
336
- pings: [], // πŸ“– All ping results (ms or 'TIMEOUT')
337
- httpCode: null,
338
- isPinging: false, // πŸ“– Per-row live flag so Latest Ping can keep last value and show a spinner during refresh.
339
- hidden: false, // πŸ“– Simple flag to hide/show models
340
- }))
341
- syncFavoriteFlags(results, config)
342
-
343
- // πŸ“– Load usage data from token-stats.json and attach usagePercent to each result row.
344
- // πŸ“– usagePercent is the quota percent remaining (0–100). undefined = no data available.
345
- // πŸ“– Freshness-aware: snapshots older than 30 minutes are excluded (shown as N/A in UI).
346
- const tokenTotalsByProviderModel = loadTokenUsageByProviderModel()
347
- for (const r of results) {
348
- const pct = _usageForRow(r.providerKey, r.modelId)
349
- r.usagePercent = typeof pct === 'number' ? pct : undefined
350
- r.totalTokens = tokenTotalsByProviderModel[buildProviderModelTokenKey(r.providerKey, r.modelId)] || 0
351
- }
352
-
353
- // πŸ“– Add interactive selection state - cursor index and user's choice
354
- // πŸ“– sortColumn: 'rank'|'tier'|'origin'|'model'|'ping'|'avg'|'status'|'verdict'|'uptime'
355
- // πŸ“– sortDirection: 'asc' (default) or 'desc'
356
- // πŸ“– ping cadence is now mode-driven:
357
- // πŸ“– speed = 2s for 1 minute bursts
358
- // πŸ“– normal = 10s steady state
359
- // πŸ“– slow = 30s after 5 minutes of inactivity
360
- // πŸ“– forced = 4s and ignores inactivity / auto slowdowns
361
- const PING_MODE_INTERVALS = {
362
- speed: 2_000,
363
- normal: 10_000,
364
- slow: 30_000,
365
- forced: 4_000,
366
- }
367
- const PING_MODE_CYCLE = ['speed', 'normal', 'slow', 'forced']
368
- const SPEED_MODE_DURATION_MS = 60_000
369
- const IDLE_SLOW_AFTER_MS = 5 * 60_000
370
- const now = Date.now()
371
-
372
- const intervalToPingMode = (intervalMs) => {
373
- if (intervalMs <= 3000) return 'speed'
374
- if (intervalMs <= 5000) return 'forced'
375
- if (intervalMs >= 30000) return 'slow'
376
- return 'normal'
377
- }
378
-
379
- // πŸ“– tierFilter: current tier filter letter (null = all, 'S' = S+/S, 'A' = A+/A/A-, etc.)
380
- const state = {
381
- results,
382
- pendingPings: 0,
383
- frame: 0,
384
- cursor: 0,
385
- selectedModel: null,
386
- sortColumn: config.settings?.sortColumn ?? 'avg',
387
- sortDirection: (config.settings?.sortAsc ?? true) ? 'asc' : 'desc',
388
- pingInterval: PING_MODE_INTERVALS.speed, // πŸ“– Effective live interval derived from the active ping mode.
389
- pingMode: 'speed', // πŸ“– Current ping mode: speed | normal | slow | forced.
390
- pingModeSource: 'startup', // πŸ“– Why this mode is active: startup | manual | auto | idle | activity.
391
- speedModeUntil: now + SPEED_MODE_DURATION_MS, // πŸ“– Speed bursts auto-fall back to normal after 60 seconds.
392
- lastPingTime: now, // πŸ“– Track when last ping cycle started
393
- lastUserActivityAt: now, // πŸ“– Any keypress refreshes this timer; inactivity can force slow mode.
394
- resumeSpeedOnActivity: false, // πŸ“– Set after idle slowdown so the next activity restarts a 60s speed burst.
395
- startupLatestVersion: latestVersion, // πŸ“– Startup auto-check result reused by the footer banner after "skip update".
396
- versionAlertsEnabled: !isDevMode, // πŸ“– Dev checkouts should not tell contributors to upgrade the global npm package.
397
- mode, // πŸ“– 'opencode' or 'openclaw' β€” controls Enter action
398
- tierFilterMode: 0, // πŸ“– Index into TIER_CYCLE (0=All, 1=S+, 2=S, ...)
399
- originFilterMode: 0, // πŸ“– Index into ORIGIN_CYCLE (0=All, then providers)
400
- premiumMode: cliArgs.premiumMode, // πŸ“– Special elite-only mode: S/S+ only, Health UP only, Perfect/Normal/Slow verdict only.
401
- hideUnconfiguredModels: config.settings?.hideUnconfiguredModels === true, // πŸ“– Hide providers with no configured API key when true.
402
- disableWidthsWarning: config.settings?.disableWidthsWarning ?? false, // πŸ“– Disable widths warning toggle (default off)
403
- scrollOffset: 0, // πŸ“– First visible model index in viewport
404
- terminalRows: process.stdout.rows || 24, // πŸ“– Current terminal height
405
- terminalCols: process.stdout.columns || 80, // πŸ“– Current terminal width
406
- widthWarningStartedAt: (process.stdout.columns || 80) < 166 ? now : null, // πŸ“– Start the narrow-terminal countdown immediately when booting in a small viewport.
407
- widthWarningDismissed: false, // πŸ“– Esc hides the narrow-terminal warning early for the current narrow-width session.
408
- widthWarningShowCount: 0, // πŸ“– Counter for how many times the narrow-terminal warning has been shown (max 2 per session).
409
- // πŸ“– Settings screen state (P key opens it)
410
- settingsOpen: false, // πŸ“– Whether settings overlay is active
411
- settingsCursor: 0, // πŸ“– Which provider row is selected in settings
412
- settingsEditMode: false, // πŸ“– Whether we're in inline key editing mode (edit primary key)
413
- settingsAddKeyMode: false, // πŸ“– Whether we're in add-key mode (append a new key to provider)
414
- settingsEditBuffer: '', // πŸ“– Typed characters for the API key being edited
415
- settingsErrorMsg: null, // πŸ“– Temporary error message to display in settings
416
- settingsTestResults: {}, // πŸ“– { providerKey: 'pending'|'ok'|'auth_error'|'rate_limited'|'no_callable_model'|'fail'|'missing_key'|null }
417
- settingsTestDetails: {}, // πŸ“– Long-form diagnostics shown under Setup Instructions after a Settings key test.
418
- settingsUpdateState: 'idle', // πŸ“– 'idle'|'checking'|'available'|'up-to-date'|'error'|'installing'
419
- settingsUpdateLatestVersion: null, // πŸ“– Latest npm version discovered from manual check
420
- settingsUpdateError: null, // πŸ“– Last update-check error message for maintenance row
421
- config, // πŸ“– Live reference to the config object (updated on save)
422
- visibleSorted: [], // πŸ“– Cached visible+sorted models β€” shared between render loop and key handlers
423
- helpVisible: false, // πŸ“– Whether the help overlay (K key) is active
424
- settingsScrollOffset: 0, // πŸ“– Vertical scroll offset for Settings overlay viewport
425
- helpScrollOffset: 0, // πŸ“– Vertical scroll offset for Help overlay viewport
426
- // πŸ“– Install Endpoints overlay state (Y key opens it)
427
- installEndpointsOpen: false, // πŸ“– Whether the install-endpoints overlay is active
428
- installEndpointsPhase: 'providers', // πŸ“– providers | tools | scope | models | result
429
- installEndpointsCursor: 0, // πŸ“– Selected row within the current install phase
430
- installEndpointsScrollOffset: 0, // πŸ“– Vertical scroll offset for the install overlay viewport
431
- installEndpointsProviderKey: null, // πŸ“– Selected provider for endpoint installation
432
- installEndpointsToolMode: null, // πŸ“– Selected target tool mode
433
- installEndpointsConnectionMode: null, // πŸ“– Direct provider path retained for future install flow state.
434
- installEndpointsScope: null, // πŸ“– all | selected
435
- installEndpointsSelectedModelIds: new Set(), // πŸ“– Multi-select buffer for the selected-models phase
436
- installEndpointsErrorMsg: null, // πŸ“– Temporary validation/error message inside the install flow
437
- installEndpointsResult: null, // πŸ“– Final install result shown in the result phase
438
- // πŸ“– Smart Recommend overlay state (Q key opens it)
439
- recommendOpen: false, // πŸ“– Whether the recommend overlay is active
440
- recommendPhase: 'questionnaire', // πŸ“– 'questionnaire'|'analyzing'|'results' β€” current phase
441
- recommendCursor: 0, // πŸ“– Selected question option (0-based index within current question)
442
- recommendQuestion: 0, // πŸ“– Which question we're on (0=task, 1=priority, 2=context)
443
- recommendAnswers: { taskType: null, priority: null, contextBudget: null }, // πŸ“– User's answers
444
- recommendProgress: 0, // πŸ“– Analysis progress percentage (0–100)
445
- recommendResults: [], // πŸ“– Top N recommendations from getTopRecommendations()
446
- recommendScrollOffset: 0, // πŸ“– Vertical scroll offset for Recommend overlay viewport
447
- recommendAnalysisTimer: null, // πŸ“– setInterval handle for the 10s analysis phase
448
- recommendPingTimer: null, // πŸ“– setInterval handle for 2 pings/sec during analysis
449
- recommendedKeys: new Set(), // πŸ“– Set of "providerKey/modelId" for recommended models (shown in main table)
450
- // πŸ“– Feedback state (J/I keys open it)
451
- feedbackOpen: false, // πŸ“– Whether the feedback overlay is active
452
- bugReportBuffer: '', // πŸ“– Typed characters for the feedback message
453
- bugReportStatus: 'idle', // πŸ“– 'idle'|'sending'|'success'|'error' β€” webhook send status
454
- bugReportError: null, // πŸ“– Last webhook error message
455
- // πŸ“– OpenCode sync status (S key in settings)
456
- settingsSyncStatus: null, // πŸ“– { type: 'success'|'error', msg: string } β€” shown in settings footer
457
- // πŸ“– Changelog overlay state (N key opens it)
458
- changelogOpen: false, // πŸ“– Whether the changelog overlay is active
459
- changelogScrollOffset: 0, // πŸ“– Vertical scroll offset for changelog overlay viewport
460
- changelogPhase: 'index', // πŸ“– 'index' (all versions) | 'details' (specific version)
461
- changelogCursor: 0, // πŸ“– Selected row in index phase
462
- changelogSelectedVersion: null, // πŸ“– Which version to show details for
463
- }
464
-
465
- // πŸ“– Re-clamp viewport on terminal resize
466
- process.stdout.on('resize', () => {
467
- const prevCols = state.terminalCols
468
- state.terminalRows = process.stdout.rows || 24
469
- state.terminalCols = process.stdout.columns || 80
470
- if (state.terminalCols < 166 && !state.disableWidthsWarning) {
471
- if (prevCols >= 166 || state.widthWarningDismissed) {
472
- state.widthWarningStartedAt = Date.now()
473
- state.widthWarningDismissed = false
474
- state.widthWarningShowCount++ // πŸ“– Increment counter when showing the warning again
475
- } else if (!state.widthWarningStartedAt) {
476
- state.widthWarningStartedAt = Date.now()
477
- }
478
- } else {
479
- state.widthWarningStartedAt = null
480
- state.widthWarningDismissed = false
481
- }
482
- adjustScrollOffset(state)
483
- })
484
-
485
- let ticker = null
486
- let onKeyPress = null
487
- let pingModel = null
488
-
489
- const scheduleNextPing = () => {
490
- clearTimeout(state.pingIntervalObj)
491
- const elapsed = Date.now() - state.lastPingTime
492
- const delay = Math.max(0, state.pingInterval - elapsed)
493
- state.pingIntervalObj = setTimeout(runPingCycle, delay)
494
- }
495
-
496
- const setPingMode = (nextMode, source = 'manual') => {
497
- const modeInterval = PING_MODE_INTERVALS[nextMode] ?? PING_MODE_INTERVALS.normal
498
- state.pingMode = nextMode
499
- state.pingModeSource = source
500
- state.pingInterval = modeInterval
501
- state.speedModeUntil = nextMode === 'speed' ? Date.now() + SPEED_MODE_DURATION_MS : null
502
- state.resumeSpeedOnActivity = source === 'idle'
503
- if (state.pingIntervalObj) scheduleNextPing()
504
- }
505
-
506
- const noteUserActivity = () => {
507
- state.lastUserActivityAt = Date.now()
508
- if (state.pingMode === 'forced') return
509
- if (state.resumeSpeedOnActivity) {
510
- setPingMode('speed', 'activity')
511
- }
512
- }
513
-
514
- const refreshAutoPingMode = () => {
515
- const currentTime = Date.now()
516
- if (state.pingMode === 'forced') return
517
-
518
- if (state.speedModeUntil && currentTime >= state.speedModeUntil) {
519
- setPingMode('normal', 'auto')
520
- return
521
- }
522
-
523
- if (currentTime - state.lastUserActivityAt >= IDLE_SLOW_AFTER_MS) {
524
- if (state.pingMode !== 'slow' || state.pingModeSource !== 'idle') {
525
- setPingMode('slow', 'idle')
526
- } else {
527
- state.resumeSpeedOnActivity = true
528
- }
529
- }
530
- }
531
-
532
- // πŸ“– Load cache if available (for faster startup with cached ping results)
533
- const cached = loadCache()
534
- if (cached && cached.models) {
535
- // πŸ“– Apply cached values to results
536
- for (const r of state.results) {
537
- const cachedModel = cached.models[r.modelId]
538
- if (cachedModel) {
539
- r.avg = cachedModel.avg
540
- r.p95 = cachedModel.p95
541
- r.jitter = cachedModel.jitter
542
- r.stability = cachedModel.stability
543
- r.uptime = cachedModel.uptime
544
- r.verdict = cachedModel.verdict
545
- r.status = cachedModel.status
546
- r.httpCode = cachedModel.httpCode
547
- r.pings = cachedModel.pings || []
548
- }
549
- }
45
+ console.error(chalk.red(` Unknown tier "${cliArgs.tierFilter}". Valid tiers: S, A, B, C`));
46
+ process.exit(1);
550
47
  }
551
48
 
552
- // πŸ“– Define pingModel before JSON mode so `--json` can reuse the same provider-aware
553
- // πŸ“– ping path as the interactive TUI without waiting for the PTY/render loop setup.
554
- pingModel = async (r) => {
555
- state.pendingPings += 1
556
- r.isPinging = true
557
-
558
- try {
559
- const providerApiKey = getApiKey(state.config, r.providerKey) ?? null
560
- const providerUrl = sources[r.providerKey]?.url ?? sources.nvidia.url
561
- let { code, ms, quotaPercent } = await ping(providerApiKey, r.modelId, r.providerKey, providerUrl)
562
-
563
- if ((quotaPercent === null || quotaPercent === undefined) && providerApiKey) {
564
- const providerQuota = await getProviderQuotaPercentCached(r.providerKey, providerApiKey)
565
- if (typeof providerQuota === 'number' && Number.isFinite(providerQuota)) {
566
- quotaPercent = providerQuota
567
- }
568
- }
569
-
570
- r.pings.push({ ms, code })
571
-
572
- if (code === '200') {
573
- r.status = 'up'
574
- } else if (code === '000') {
575
- r.status = 'timeout'
576
- } else if (code === '401' || code === '403') {
577
- r.status = providerApiKey ? 'auth_error' : 'noauth'
578
- r.httpCode = code
579
- } else {
580
- r.status = 'down'
581
- r.httpCode = code
582
- }
583
-
584
- if (typeof quotaPercent === 'number' && Number.isFinite(quotaPercent)) {
585
- r.usagePercent = quotaPercent
586
- for (const sibling of state.results) {
587
- if (sibling.providerKey === r.providerKey && (sibling.usagePercent === undefined || sibling.usagePercent === null)) {
588
- sibling.usagePercent = quotaPercent
589
- }
590
- }
591
- }
592
- } finally {
593
- r.isPinging = false
594
- state.pendingPings = Math.max(0, state.pendingPings - 1)
595
- }
596
- }
597
-
598
- // πŸ“– JSON output mode: skip TUI, output results as JSON after initial pings
599
- if (cliArgs.jsonMode) {
600
- console.log(chalk.cyan(' ⚑ Pinging models for JSON output...'))
601
- console.log()
602
-
603
- // πŸ“– Run initial pings
604
- const initialPing = Promise.all(state.results.map(r => pingModel(r)))
605
- await initialPing
606
-
607
- // πŸ“– Calculate final stats
608
- state.results.forEach(r => {
609
- r.avg = getAvg(r)
610
- r.p95 = getP95(r)
611
- r.jitter = getJitter(r)
612
- r.stability = getStabilityScore(r)
613
- r.uptime = getUptime(r)
614
- r.verdict = getVerdict(r)
615
- })
616
-
617
- // πŸ“– Apply tier filter if specified
618
- let outputResults = state.results
619
- if (cliArgs.tierFilter) {
620
- const filteredTier = TIER_LETTER_MAP[cliArgs.tierFilter]
621
- if (filteredTier) {
622
- outputResults = state.results.filter(r => filteredTier.includes(r.tier))
623
- }
624
- }
625
-
626
- // πŸ“– Apply best mode filter if specified
627
- if (cliArgs.bestMode) {
628
- outputResults = outputResults.filter(r => ['S+', 'S', 'A+'].includes(r.tier))
629
- }
630
-
631
- // πŸ“– Apply premium mode filter if specified: elite-only (S/S+, UP, Good Verdict)
632
- if (cliArgs.premiumMode) {
633
- outputResults = outputResults.filter(r => {
634
- const isEliteTier = r.tier === 'S' || r.tier === 'S+'
635
- const isHealthUp = r.status === 'up'
636
- const verdict = getVerdict(r)
637
- const isGoodVerdict = ['Perfect', 'Normal', 'Slow'].includes(verdict)
638
- return isEliteTier && isHealthUp && isGoodVerdict
639
- })
640
- }
641
-
642
- // πŸ“– Sort by avg ping (ascending)
643
- outputResults = sortResults(outputResults, 'avg', 'asc')
644
-
645
- // πŸ“– Output JSON
646
- console.log(formatResultsAsJSON(outputResults))
647
-
648
- // πŸ“– Save cache before exiting
649
- saveCache(state.results, state.pingMode)
650
-
651
- process.exit(0)
652
- }
653
-
654
- // πŸ“– Enter alternate screen β€” animation runs here, zero scrollback pollution
655
- process.stdout.write(ALT_ENTER)
656
- if (process.stdout.isTTY) {
657
- process.stdout.flush && process.stdout.flush()
658
- }
659
-
660
- // πŸ“– Ensure we always leave alt screen cleanly (Ctrl+C, crash, normal exit)
661
- const exit = (code = 0) => {
662
- // πŸ“– Save cache before exiting so next run starts faster
663
- saveCache(state.results, state.pingMode)
664
- clearInterval(ticker)
665
- clearTimeout(state.pingIntervalObj)
666
- process.stdout.write(ALT_LEAVE)
667
- if (process.stdout.isTTY) {
668
- process.stdout.flush && process.stdout.flush()
669
- }
670
- process.exit(code)
671
- }
672
- process.on('SIGINT', () => exit(0))
673
- process.on('SIGTERM', () => exit(0))
674
-
675
- // πŸ“– originFilterMode: index into ORIGIN_CYCLE, 0=All, then each provider key in order
676
- const ORIGIN_CYCLE = [null, ...Object.keys(sources)]
677
- const resolvedTierFilter = config.settings?.tierFilter
678
- state.tierFilterMode = resolvedTierFilter ? Math.max(0, TIER_CYCLE.indexOf(resolvedTierFilter)) : 0
679
- const resolvedOriginFilter = config.settings?.originFilter
680
- state.originFilterMode = resolvedOriginFilter ? Math.max(0, ORIGIN_CYCLE.indexOf(resolvedOriginFilter)) : 0
681
-
682
- function applyTierFilter() {
683
- const activeTier = TIER_CYCLE[state.tierFilterMode]
684
- const activeOrigin = ORIGIN_CYCLE[state.originFilterMode]
685
- state.results.forEach(r => {
686
- // πŸ“– Favorites stay visible and pinned regardless of configured-only, tier, or provider filters.
687
- if (r.isFavorite) {
688
- r.hidden = false
689
- return
690
- }
691
- const unconfiguredHide = state.hideUnconfiguredModels && !getApiKey(state.config, r.providerKey)
692
- if (unconfiguredHide) {
693
- r.hidden = true
694
- return
695
- }
696
- // πŸ“– Apply both tier and origin filters β€” model is hidden if it fails either
697
- // πŸ“– TIER_LETTER_MAP is used so --tier S also includes S+ models (tier family behavior).
698
- const allowedTiers = (activeTier && TIER_LETTER_MAP[activeTier]) ? TIER_LETTER_MAP[activeTier] : [activeTier]
699
- const tierHide = activeTier !== null && !allowedTiers.includes(r.tier)
700
- const originHide = activeOrigin !== null && r.providerKey !== activeOrigin
701
- r.hidden = tierHide || originHide
702
-
703
- // πŸ“– Premium Mode: elite-only constraints (Health UP, Good Verdict, S/S+ only)
704
- if (state.premiumMode && !r.hidden) {
705
- const isEliteTier = r.tier === 'S' || r.tier === 'S+'
706
- const isHealthUp = r.status === 'up'
707
- const verdict = getVerdict(r)
708
- const isGoodVerdict = ['Perfect', 'Normal', 'Slow'].includes(verdict)
709
-
710
- if (!isEliteTier || !isHealthUp || !isGoodVerdict) {
711
- r.hidden = true
712
- }
713
- }
714
- })
715
- return state.results
716
- }
717
-
718
- // πŸ“– Apply initial filters so configured-only mode works on first render
719
- applyTierFilter()
720
-
721
- // ─── Overlay renderers + key handler ─────────────────────────────────────
722
- const stopUi = ({ resetRawMode = false } = {}) => {
723
- if (ticker) clearInterval(ticker)
724
- clearTimeout(state.pingIntervalObj)
725
- if (onKeyPress) process.stdin.removeListener('keypress', onKeyPress)
726
- if (process.stdin.isTTY && resetRawMode) process.stdin.setRawMode(false)
727
- process.stdin.pause()
728
- process.stdout.write(ALT_LEAVE)
729
- if (process.stdout.isTTY) {
730
- process.stdout.flush && process.stdout.flush()
731
- }
732
- }
733
-
734
- const overlays = createOverlayRenderers(state, {
735
- chalk,
736
- sources,
737
- PROVIDER_METADATA,
738
- PROVIDER_COLOR,
739
- LOCAL_VERSION,
740
- getApiKey,
741
- resolveApiKeys,
742
- isProviderEnabled,
743
- TIER_CYCLE,
744
- SETTINGS_OVERLAY_BG,
745
- HELP_OVERLAY_BG,
746
- RECOMMEND_OVERLAY_BG,
747
- OVERLAY_PANEL_WIDTH,
748
- keepOverlayTargetVisible,
749
- sliceOverlayLines,
750
- tintOverlayLines,
751
- TASK_TYPES,
752
- PRIORITY_TYPES,
753
- CONTEXT_BUDGETS,
754
- FRAMES,
755
- TIER_COLOR,
756
- getAvg,
757
- getStabilityScore,
758
- toFavoriteKey,
759
- getTopRecommendations,
760
- adjustScrollOffset,
761
- getPingModel: () => pingModel,
762
- getConfiguredInstallableProviders,
763
- getInstallTargetModes,
764
- getProviderCatalogModels,
765
- getToolMeta,
766
- })
767
-
768
- onKeyPress = createKeyHandler({
769
- state,
770
- exit,
771
- cliArgs,
772
- MODELS,
773
- sources,
774
- getApiKey,
775
- resolveApiKeys,
776
- addApiKey,
777
- removeApiKey,
778
- persistApiKeysForProvider,
779
- isProviderEnabled,
780
- saveConfig,
781
- getConfiguredInstallableProviders,
782
- getInstallTargetModes,
783
- getProviderCatalogModels,
784
- installProviderEndpoints,
785
- syncFavoriteFlags,
786
- toggleFavoriteModel,
787
- sortResultsWithPinnedFavorites,
788
- adjustScrollOffset,
789
- applyTierFilter,
790
- PING_INTERVAL,
791
- TIER_CYCLE,
792
- ORIGIN_CYCLE,
793
- ENV_VAR_NAMES,
794
- checkForUpdateDetailed,
795
- runUpdate,
796
- startOpenClaw,
797
- startOpenCodeDesktop,
798
- startOpenCode,
799
- startExternalTool,
800
- getToolModeOrder,
801
- startRecommendAnalysis: overlays.startRecommendAnalysis,
802
- stopRecommendAnalysis: overlays.stopRecommendAnalysis,
803
- sendBugReport,
804
- stopUi,
805
- ping,
806
- TASK_TYPES,
807
- PRIORITY_TYPES,
808
- CONTEXT_BUDGETS,
809
- toFavoriteKey,
810
- mergedModels,
811
- chalk,
812
- setPingMode,
813
- noteUserActivity,
814
- intervalToPingMode,
815
- PING_MODE_CYCLE,
816
- setResults: (next) => { results = next },
817
- readline,
818
- })
819
-
820
- // Apply CLI --tier filter if provided
821
- if (cliArgs.tierFilter) {
822
- const allowed = TIER_LETTER_MAP[cliArgs.tierFilter]
823
- state.results.forEach(r => {
824
- r.hidden = r.isFavorite ? false : !allowed.includes(r.tier)
825
- })
826
- }
827
-
828
- // πŸ“– Setup keyboard input for interactive selection during pings
829
- // πŸ“– Use readline with keypress event for arrow key handling
830
- process.stdin.setEncoding('utf8')
831
- process.stdin.resume()
832
-
833
- let userSelected = null
834
-
835
- // πŸ“– Enable keypress events on stdin
836
- readline.emitKeypressEvents(process.stdin)
837
- if (process.stdin.isTTY) {
838
- process.stdin.setRawMode(true)
839
- }
840
-
841
- process.stdin.on('keypress', onKeyPress)
842
- process.on('SIGCONT', noteUserActivity)
843
-
844
- // πŸ“– Animation loop: render settings overlay, recommend overlay, help overlay, feature request overlay, bug report overlay, changelog overlay, OR main table
845
- ticker = setInterval(() => {
846
- refreshAutoPingMode()
847
- state.frame++
848
- // πŸ“– Cache visible+sorted models each frame so Enter handler always matches the display
849
- if (!state.settingsOpen && !state.installEndpointsOpen && !state.recommendOpen && !state.feedbackOpen && !state.changelogOpen) {
850
- const visible = state.results.filter(r => !r.hidden)
851
- state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
852
- }
853
- const content = state.settingsOpen
854
- ? overlays.renderSettings()
855
- : state.installEndpointsOpen
856
- ? overlays.renderInstallEndpoints()
857
- : state.recommendOpen
858
- ? overlays.renderRecommend()
859
- : state.feedbackOpen
860
- ? overlays.renderFeedback()
861
- : state.helpVisible
862
- ? overlays.renderHelp()
863
- : state.changelogOpen
864
- ? overlays.renderChangelog()
865
- : 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)
866
- process.stdout.write(ALT_HOME + content)
867
- if (process.stdout.isTTY) {
868
- process.stdout.flush && process.stdout.flush()
869
- }
870
- }, Math.round(1000 / FPS))
871
-
872
- // πŸ“– Populate visibleSorted before the first frame so Enter works immediately
873
- const initialVisible = state.results.filter(r => !r.hidden)
874
- state.visibleSorted = sortResultsWithPinnedFavorites(initialVisible, state.sortColumn, state.sortDirection)
875
-
876
- 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))
877
- if (process.stdout.isTTY) {
878
- process.stdout.flush && process.stdout.flush()
879
- }
880
-
881
- // πŸ“– If --recommend was passed, auto-open the Smart Recommend overlay on start
882
- if (cliArgs.recommendMode) {
883
- state.recommendOpen = true
884
- state.recommendPhase = 'questionnaire'
885
- state.recommendCursor = 0
886
- state.recommendQuestion = 0
887
- state.recommendAnswers = { taskType: null, priority: null, contextBudget: null }
888
- state.recommendProgress = 0
889
- state.recommendResults = []
890
- state.recommendScrollOffset = 0
891
- }
892
-
893
- // ── Continuous ping loop β€” ping all models every N seconds forever ──────────
894
-
895
- // πŸ“– Initial ping of all models
896
- const initialPing = Promise.all(state.results.map(r => pingModel(r)))
897
-
898
- // πŸ“– Continuous ping loop with mode-driven cadence.
899
- const runPingCycle = async () => {
900
- refreshAutoPingMode()
901
- state.lastPingTime = Date.now()
902
-
903
- // πŸ“– Refresh persisted usage snapshots each cycle so background usage data appears live in table.
904
- // πŸ“– Freshness-aware: stale snapshots (>30m) are excluded and row reverts to undefined.
905
- for (const r of state.results) {
906
- const pct = _usageForRow(r.providerKey, r.modelId)
907
- if (typeof pct === 'number' && Number.isFinite(pct)) {
908
- r.usagePercent = pct
909
- } else {
910
- // If snapshot is now stale or gone, clear the cached value so UI shows N/A.
911
- r.usagePercent = undefined
912
- }
913
- }
914
-
915
- state.results.forEach(r => {
916
- pingModel(r).catch(() => {
917
- // Individual ping failures don't crash the loop
918
- })
919
- })
920
-
921
- refreshAutoPingMode()
922
- scheduleNextPing()
923
- }
924
-
925
- // πŸ“– Start the ping loop
926
- state.pingIntervalObj = null
927
- scheduleNextPing()
928
-
929
- await initialPing
930
-
931
- // πŸ“– Save cache after initial pings complete for faster next startup
932
- saveCache(state.results, state.pingMode)
49
+ // πŸ“– Load JSON config
50
+ const config = loadConfig();
51
+ ensureTelemetryConfig(config);
52
+ ensureFavoritesConfig(config);
933
53
 
934
- // πŸ“– Keep interface running forever - user can select anytime or Ctrl+C to exit
935
- // πŸ“– The pings continue running in background with dynamic interval
936
- // πŸ“– User can press W to decrease interval (faster pings) or = to increase (slower)
937
- // πŸ“– Current interval shown in header: "next ping Xs"
54
+ await runApp(cliArgs, config);
938
55
  }
939
56
 
940
57
  main().catch((err) => {
941
- process.stdout.write(ALT_LEAVE)
942
- console.error(err)
943
- process.exit(1)
944
- })
58
+ process.stdout.write(ALT_LEAVE);
59
+ console.error(chalk.red('\n[Fatal Error]'));
60
+ console.error(err);
61
+ console.error(chalk.yellow('\nPlease file an issue at https://github.com/vava-nessa/free-coding-models/issues or use the feedback form (I key) to report this to the author.'));
62
+ process.exit(1);
63
+ });