free-coding-models 0.3.13 β†’ 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,945 +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, // πŸ“– Cached for runtime checks; keep it in sync with config.settings.
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 && !(config.settings?.disableWidthsWarning ?? false) ? now : null, // πŸ“– Start immediately only when warnings are enabled in a narrow 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
- const widthsWarningDisabled = state.config.settings?.disableWidthsWarning === true
469
- state.terminalRows = process.stdout.rows || 24
470
- state.terminalCols = process.stdout.columns || 80
471
- state.disableWidthsWarning = widthsWarningDisabled
472
- if (state.terminalCols < 166 && !widthsWarningDisabled) {
473
- if (prevCols >= 166 || state.widthWarningDismissed) {
474
- state.widthWarningStartedAt = Date.now()
475
- state.widthWarningDismissed = false
476
- state.widthWarningShowCount++ // πŸ“– Increment counter when showing the warning again
477
- } else if (!state.widthWarningStartedAt) {
478
- state.widthWarningStartedAt = Date.now()
479
- }
480
- } else {
481
- state.widthWarningStartedAt = null
482
- state.widthWarningDismissed = false
483
- }
484
- adjustScrollOffset(state)
485
- })
486
-
487
- let ticker = null
488
- let onKeyPress = null
489
- let pingModel = null
490
-
491
- const scheduleNextPing = () => {
492
- clearTimeout(state.pingIntervalObj)
493
- const elapsed = Date.now() - state.lastPingTime
494
- const delay = Math.max(0, state.pingInterval - elapsed)
495
- state.pingIntervalObj = setTimeout(runPingCycle, delay)
496
- }
497
-
498
- const setPingMode = (nextMode, source = 'manual') => {
499
- const modeInterval = PING_MODE_INTERVALS[nextMode] ?? PING_MODE_INTERVALS.normal
500
- state.pingMode = nextMode
501
- state.pingModeSource = source
502
- state.pingInterval = modeInterval
503
- state.speedModeUntil = nextMode === 'speed' ? Date.now() + SPEED_MODE_DURATION_MS : null
504
- state.resumeSpeedOnActivity = source === 'idle'
505
- if (state.pingIntervalObj) scheduleNextPing()
506
- }
507
-
508
- const noteUserActivity = () => {
509
- state.lastUserActivityAt = Date.now()
510
- if (state.pingMode === 'forced') return
511
- if (state.resumeSpeedOnActivity) {
512
- setPingMode('speed', 'activity')
513
- }
514
- }
515
-
516
- const refreshAutoPingMode = () => {
517
- const currentTime = Date.now()
518
- if (state.pingMode === 'forced') return
519
-
520
- if (state.speedModeUntil && currentTime >= state.speedModeUntil) {
521
- setPingMode('normal', 'auto')
522
- return
523
- }
524
-
525
- if (currentTime - state.lastUserActivityAt >= IDLE_SLOW_AFTER_MS) {
526
- if (state.pingMode !== 'slow' || state.pingModeSource !== 'idle') {
527
- setPingMode('slow', 'idle')
528
- } else {
529
- state.resumeSpeedOnActivity = true
530
- }
531
- }
532
- }
533
-
534
- // πŸ“– Load cache if available (for faster startup with cached ping results)
535
- const cached = loadCache()
536
- if (cached && cached.models) {
537
- // πŸ“– Apply cached values to results
538
- for (const r of state.results) {
539
- const cachedModel = cached.models[r.modelId]
540
- if (cachedModel) {
541
- r.avg = cachedModel.avg
542
- r.p95 = cachedModel.p95
543
- r.jitter = cachedModel.jitter
544
- r.stability = cachedModel.stability
545
- r.uptime = cachedModel.uptime
546
- r.verdict = cachedModel.verdict
547
- r.status = cachedModel.status
548
- r.httpCode = cachedModel.httpCode
549
- r.pings = cachedModel.pings || []
550
- }
551
- }
45
+ console.error(chalk.red(` Unknown tier "${cliArgs.tierFilter}". Valid tiers: S, A, B, C`));
46
+ process.exit(1);
552
47
  }
553
48
 
554
- // πŸ“– Define pingModel before JSON mode so `--json` can reuse the same provider-aware
555
- // πŸ“– ping path as the interactive TUI without waiting for the PTY/render loop setup.
556
- pingModel = async (r) => {
557
- state.pendingPings += 1
558
- r.isPinging = true
559
-
560
- try {
561
- const providerApiKey = getApiKey(state.config, r.providerKey) ?? null
562
- const providerUrl = sources[r.providerKey]?.url ?? sources.nvidia.url
563
- let { code, ms, quotaPercent } = await ping(providerApiKey, r.modelId, r.providerKey, providerUrl)
564
-
565
- if ((quotaPercent === null || quotaPercent === undefined) && providerApiKey) {
566
- const providerQuota = await getProviderQuotaPercentCached(r.providerKey, providerApiKey)
567
- if (typeof providerQuota === 'number' && Number.isFinite(providerQuota)) {
568
- quotaPercent = providerQuota
569
- }
570
- }
571
-
572
- r.pings.push({ ms, code })
573
-
574
- if (code === '200') {
575
- r.status = 'up'
576
- } else if (code === '000') {
577
- r.status = 'timeout'
578
- } else if (code === '401' || code === '403') {
579
- r.status = providerApiKey ? 'auth_error' : 'noauth'
580
- r.httpCode = code
581
- } else {
582
- r.status = 'down'
583
- r.httpCode = code
584
- }
585
-
586
- if (typeof quotaPercent === 'number' && Number.isFinite(quotaPercent)) {
587
- r.usagePercent = quotaPercent
588
- for (const sibling of state.results) {
589
- if (sibling.providerKey === r.providerKey && (sibling.usagePercent === undefined || sibling.usagePercent === null)) {
590
- sibling.usagePercent = quotaPercent
591
- }
592
- }
593
- }
594
- } finally {
595
- r.isPinging = false
596
- state.pendingPings = Math.max(0, state.pendingPings - 1)
597
- }
598
- }
599
-
600
- // πŸ“– JSON output mode: skip TUI, output results as JSON after initial pings
601
- if (cliArgs.jsonMode) {
602
- console.log(chalk.cyan(' ⚑ Pinging models for JSON output...'))
603
- console.log()
604
-
605
- // πŸ“– Run initial pings
606
- const initialPing = Promise.all(state.results.map(r => pingModel(r)))
607
- await initialPing
608
-
609
- // πŸ“– Calculate final stats
610
- state.results.forEach(r => {
611
- r.avg = getAvg(r)
612
- r.p95 = getP95(r)
613
- r.jitter = getJitter(r)
614
- r.stability = getStabilityScore(r)
615
- r.uptime = getUptime(r)
616
- r.verdict = getVerdict(r)
617
- })
618
-
619
- // πŸ“– Apply tier filter if specified
620
- let outputResults = state.results
621
- if (cliArgs.tierFilter) {
622
- const filteredTier = TIER_LETTER_MAP[cliArgs.tierFilter]
623
- if (filteredTier) {
624
- outputResults = state.results.filter(r => filteredTier.includes(r.tier))
625
- }
626
- }
627
-
628
- // πŸ“– Apply best mode filter if specified
629
- if (cliArgs.bestMode) {
630
- outputResults = outputResults.filter(r => ['S+', 'S', 'A+'].includes(r.tier))
631
- }
632
-
633
- // πŸ“– Apply premium mode filter if specified: elite-only (S/S+, UP, Good Verdict)
634
- if (cliArgs.premiumMode) {
635
- outputResults = outputResults.filter(r => {
636
- const isEliteTier = r.tier === 'S' || r.tier === 'S+'
637
- const isHealthUp = r.status === 'up'
638
- const verdict = getVerdict(r)
639
- const isGoodVerdict = ['Perfect', 'Normal', 'Slow'].includes(verdict)
640
- return isEliteTier && isHealthUp && isGoodVerdict
641
- })
642
- }
643
-
644
- // πŸ“– Sort by avg ping (ascending)
645
- outputResults = sortResults(outputResults, 'avg', 'asc')
646
-
647
- // πŸ“– Output JSON
648
- console.log(formatResultsAsJSON(outputResults))
649
-
650
- // πŸ“– Save cache before exiting
651
- saveCache(state.results, state.pingMode)
652
-
653
- process.exit(0)
654
- }
655
-
656
- // πŸ“– Enter alternate screen β€” animation runs here, zero scrollback pollution
657
- process.stdout.write(ALT_ENTER)
658
- if (process.stdout.isTTY) {
659
- process.stdout.flush && process.stdout.flush()
660
- }
661
-
662
- // πŸ“– Ensure we always leave alt screen cleanly (Ctrl+C, crash, normal exit)
663
- const exit = (code = 0) => {
664
- // πŸ“– Save cache before exiting so next run starts faster
665
- saveCache(state.results, state.pingMode)
666
- clearInterval(ticker)
667
- clearTimeout(state.pingIntervalObj)
668
- process.stdout.write(ALT_LEAVE)
669
- if (process.stdout.isTTY) {
670
- process.stdout.flush && process.stdout.flush()
671
- }
672
- process.exit(code)
673
- }
674
- process.on('SIGINT', () => exit(0))
675
- process.on('SIGTERM', () => exit(0))
676
-
677
- // πŸ“– originFilterMode: index into ORIGIN_CYCLE, 0=All, then each provider key in order
678
- const ORIGIN_CYCLE = [null, ...Object.keys(sources)]
679
- const resolvedTierFilter = config.settings?.tierFilter
680
- state.tierFilterMode = resolvedTierFilter ? Math.max(0, TIER_CYCLE.indexOf(resolvedTierFilter)) : 0
681
- const resolvedOriginFilter = config.settings?.originFilter
682
- state.originFilterMode = resolvedOriginFilter ? Math.max(0, ORIGIN_CYCLE.indexOf(resolvedOriginFilter)) : 0
683
-
684
- function applyTierFilter() {
685
- const activeTier = TIER_CYCLE[state.tierFilterMode]
686
- const activeOrigin = ORIGIN_CYCLE[state.originFilterMode]
687
- state.results.forEach(r => {
688
- // πŸ“– Favorites stay visible and pinned regardless of configured-only, tier, or provider filters.
689
- if (r.isFavorite) {
690
- r.hidden = false
691
- return
692
- }
693
- const unconfiguredHide = state.hideUnconfiguredModels && !getApiKey(state.config, r.providerKey)
694
- if (unconfiguredHide) {
695
- r.hidden = true
696
- return
697
- }
698
- // πŸ“– Apply both tier and origin filters β€” model is hidden if it fails either
699
- // πŸ“– TIER_LETTER_MAP is used so --tier S also includes S+ models (tier family behavior).
700
- const allowedTiers = (activeTier && TIER_LETTER_MAP[activeTier]) ? TIER_LETTER_MAP[activeTier] : [activeTier]
701
- const tierHide = activeTier !== null && !allowedTiers.includes(r.tier)
702
- const originHide = activeOrigin !== null && r.providerKey !== activeOrigin
703
- r.hidden = tierHide || originHide
704
-
705
- // πŸ“– Premium Mode: elite-only constraints (Health UP, Good Verdict, S/S+ only)
706
- if (state.premiumMode && !r.hidden) {
707
- const isEliteTier = r.tier === 'S' || r.tier === 'S+'
708
- const isHealthUp = r.status === 'up'
709
- const verdict = getVerdict(r)
710
- const isGoodVerdict = ['Perfect', 'Normal', 'Slow'].includes(verdict)
711
-
712
- if (!isEliteTier || !isHealthUp || !isGoodVerdict) {
713
- r.hidden = true
714
- }
715
- }
716
- })
717
- return state.results
718
- }
719
-
720
- // πŸ“– Apply initial filters so configured-only mode works on first render
721
- applyTierFilter()
722
-
723
- // ─── Overlay renderers + key handler ─────────────────────────────────────
724
- const stopUi = ({ resetRawMode = false } = {}) => {
725
- if (ticker) clearInterval(ticker)
726
- clearTimeout(state.pingIntervalObj)
727
- if (onKeyPress) process.stdin.removeListener('keypress', onKeyPress)
728
- if (process.stdin.isTTY && resetRawMode) process.stdin.setRawMode(false)
729
- process.stdin.pause()
730
- process.stdout.write(ALT_LEAVE)
731
- if (process.stdout.isTTY) {
732
- process.stdout.flush && process.stdout.flush()
733
- }
734
- }
735
-
736
- const overlays = createOverlayRenderers(state, {
737
- chalk,
738
- sources,
739
- PROVIDER_METADATA,
740
- PROVIDER_COLOR,
741
- LOCAL_VERSION,
742
- getApiKey,
743
- resolveApiKeys,
744
- isProviderEnabled,
745
- TIER_CYCLE,
746
- SETTINGS_OVERLAY_BG,
747
- HELP_OVERLAY_BG,
748
- RECOMMEND_OVERLAY_BG,
749
- OVERLAY_PANEL_WIDTH,
750
- keepOverlayTargetVisible,
751
- sliceOverlayLines,
752
- tintOverlayLines,
753
- TASK_TYPES,
754
- PRIORITY_TYPES,
755
- CONTEXT_BUDGETS,
756
- FRAMES,
757
- TIER_COLOR,
758
- getAvg,
759
- getStabilityScore,
760
- toFavoriteKey,
761
- getTopRecommendations,
762
- adjustScrollOffset,
763
- getPingModel: () => pingModel,
764
- getConfiguredInstallableProviders,
765
- getInstallTargetModes,
766
- getProviderCatalogModels,
767
- getToolMeta,
768
- })
769
-
770
- onKeyPress = createKeyHandler({
771
- state,
772
- exit,
773
- cliArgs,
774
- MODELS,
775
- sources,
776
- getApiKey,
777
- resolveApiKeys,
778
- addApiKey,
779
- removeApiKey,
780
- persistApiKeysForProvider,
781
- isProviderEnabled,
782
- saveConfig,
783
- getConfiguredInstallableProviders,
784
- getInstallTargetModes,
785
- getProviderCatalogModels,
786
- installProviderEndpoints,
787
- syncFavoriteFlags,
788
- toggleFavoriteModel,
789
- sortResultsWithPinnedFavorites,
790
- adjustScrollOffset,
791
- applyTierFilter,
792
- PING_INTERVAL,
793
- TIER_CYCLE,
794
- ORIGIN_CYCLE,
795
- ENV_VAR_NAMES,
796
- checkForUpdateDetailed,
797
- runUpdate,
798
- startOpenClaw,
799
- startOpenCodeDesktop,
800
- startOpenCode,
801
- startExternalTool,
802
- getToolModeOrder,
803
- startRecommendAnalysis: overlays.startRecommendAnalysis,
804
- stopRecommendAnalysis: overlays.stopRecommendAnalysis,
805
- sendBugReport,
806
- stopUi,
807
- ping,
808
- TASK_TYPES,
809
- PRIORITY_TYPES,
810
- CONTEXT_BUDGETS,
811
- toFavoriteKey,
812
- mergedModels,
813
- chalk,
814
- setPingMode,
815
- noteUserActivity,
816
- intervalToPingMode,
817
- PING_MODE_CYCLE,
818
- setResults: (next) => { results = next },
819
- readline,
820
- })
821
-
822
- // Apply CLI --tier filter if provided
823
- if (cliArgs.tierFilter) {
824
- const allowed = TIER_LETTER_MAP[cliArgs.tierFilter]
825
- state.results.forEach(r => {
826
- r.hidden = r.isFavorite ? false : !allowed.includes(r.tier)
827
- })
828
- }
829
-
830
- // πŸ“– Setup keyboard input for interactive selection during pings
831
- // πŸ“– Use readline with keypress event for arrow key handling
832
- process.stdin.setEncoding('utf8')
833
- process.stdin.resume()
834
-
835
- let userSelected = null
836
-
837
- // πŸ“– Enable keypress events on stdin
838
- readline.emitKeypressEvents(process.stdin)
839
- if (process.stdin.isTTY) {
840
- process.stdin.setRawMode(true)
841
- }
842
-
843
- process.stdin.on('keypress', onKeyPress)
844
- process.on('SIGCONT', noteUserActivity)
845
-
846
- // πŸ“– Animation loop: render settings overlay, recommend overlay, help overlay, feature request overlay, bug report overlay, changelog overlay, OR main table
847
- ticker = setInterval(() => {
848
- refreshAutoPingMode()
849
- state.frame++
850
- // πŸ“– Cache visible+sorted models each frame so Enter handler always matches the display
851
- if (!state.settingsOpen && !state.installEndpointsOpen && !state.recommendOpen && !state.feedbackOpen && !state.changelogOpen) {
852
- const visible = state.results.filter(r => !r.hidden)
853
- state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
854
- }
855
- const content = state.settingsOpen
856
- ? overlays.renderSettings()
857
- : state.installEndpointsOpen
858
- ? overlays.renderInstallEndpoints()
859
- : state.recommendOpen
860
- ? overlays.renderRecommend()
861
- : state.feedbackOpen
862
- ? overlays.renderFeedback()
863
- : state.helpVisible
864
- ? overlays.renderHelp()
865
- : state.changelogOpen
866
- ? overlays.renderChangelog()
867
- : 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.config.settings?.disableWidthsWarning ?? false)
868
- process.stdout.write(ALT_HOME + content)
869
- if (process.stdout.isTTY) {
870
- process.stdout.flush && process.stdout.flush()
871
- }
872
- }, Math.round(1000 / FPS))
873
-
874
- // πŸ“– Populate visibleSorted before the first frame so Enter works immediately
875
- const initialVisible = state.results.filter(r => !r.hidden)
876
- state.visibleSorted = sortResultsWithPinnedFavorites(initialVisible, state.sortColumn, state.sortDirection)
877
-
878
- 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.config.settings?.disableWidthsWarning ?? false))
879
- if (process.stdout.isTTY) {
880
- process.stdout.flush && process.stdout.flush()
881
- }
882
-
883
- // πŸ“– If --recommend was passed, auto-open the Smart Recommend overlay on start
884
- if (cliArgs.recommendMode) {
885
- state.recommendOpen = true
886
- state.recommendPhase = 'questionnaire'
887
- state.recommendCursor = 0
888
- state.recommendQuestion = 0
889
- state.recommendAnswers = { taskType: null, priority: null, contextBudget: null }
890
- state.recommendProgress = 0
891
- state.recommendResults = []
892
- state.recommendScrollOffset = 0
893
- }
894
-
895
- // ── Continuous ping loop β€” ping all models every N seconds forever ──────────
896
-
897
- // πŸ“– Initial ping of all models
898
- const initialPing = Promise.all(state.results.map(r => pingModel(r)))
899
-
900
- // πŸ“– Continuous ping loop with mode-driven cadence.
901
- const runPingCycle = async () => {
902
- refreshAutoPingMode()
903
- state.lastPingTime = Date.now()
904
-
905
- // πŸ“– Refresh persisted usage snapshots each cycle so background usage data appears live in table.
906
- // πŸ“– Freshness-aware: stale snapshots (>30m) are excluded and row reverts to undefined.
907
- for (const r of state.results) {
908
- const pct = _usageForRow(r.providerKey, r.modelId)
909
- if (typeof pct === 'number' && Number.isFinite(pct)) {
910
- r.usagePercent = pct
911
- } else {
912
- // If snapshot is now stale or gone, clear the cached value so UI shows N/A.
913
- r.usagePercent = undefined
914
- }
915
- }
916
-
917
- state.results.forEach(r => {
918
- pingModel(r).catch(() => {
919
- // Individual ping failures don't crash the loop
920
- })
921
- })
922
-
923
- refreshAutoPingMode()
924
- scheduleNextPing()
925
- }
926
-
927
- // πŸ“– Start the ping loop
928
- state.pingIntervalObj = null
929
- scheduleNextPing()
930
-
931
- await initialPing
932
-
933
- // πŸ“– Save cache after initial pings complete for faster next startup
934
- saveCache(state.results, state.pingMode)
49
+ // πŸ“– Load JSON config
50
+ const config = loadConfig();
51
+ ensureTelemetryConfig(config);
52
+ ensureFavoritesConfig(config);
935
53
 
936
- // πŸ“– Keep interface running forever - user can select anytime or Ctrl+C to exit
937
- // πŸ“– The pings continue running in background with dynamic interval
938
- // πŸ“– User can press W to decrease interval (faster pings) or = to increase (slower)
939
- // πŸ“– Current interval shown in header: "next ping Xs"
54
+ await runApp(cliArgs, config);
940
55
  }
941
56
 
942
57
  main().catch((err) => {
943
- process.stdout.write(ALT_LEAVE)
944
- console.error(err)
945
- process.exit(1)
946
- })
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
+ });