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.
package/src/app.js ADDED
@@ -0,0 +1,949 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file free-coding-models.js
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
+ */
91
+
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
174
+
175
+ export async function runApp(cliArgs, config) {
176
+
177
+
178
+
179
+ // πŸ“– Check config file security β€” warn and offer auto-fix if permissions are too open
180
+ const securityCheck = checkConfigSecurity()
181
+ if (!securityCheck.wasSecure && !securityCheck.wasFixed) {
182
+ // πŸ“– User declined auto-fix or it failed β€” continue anyway, just warned
183
+ }
184
+
185
+ // πŸ“– Apply CLI overrides for settings
186
+ if (cliArgs.sortColumn) config.settings.sortColumn = cliArgs.sortColumn
187
+ if (cliArgs.sortDirection) config.settings.sortAsc = cliArgs.sortDirection === 'asc'
188
+ if (cliArgs.originFilter) config.settings.originFilter = cliArgs.originFilter
189
+ if (cliArgs.pingInterval) config.settings.pingInterval = cliArgs.pingInterval
190
+ if (cliArgs.hideUnconfigured) config.settings.hideUnconfiguredModels = true
191
+ if (cliArgs.showUnconfigured) config.settings.hideUnconfiguredModels = false
192
+ if (cliArgs.disableWidthsWarning) config.settings.disableWidthsWarning = true
193
+
194
+ // πŸ“– Apply premium mode: show only S‑tier models sorted by verdict
195
+ if (cliArgs.premiumMode) {
196
+ config.settings.tierFilter = 'S'
197
+ config.settings.sortColumn = 'verdict'
198
+ config.settings.sortAsc = true
199
+ }
200
+
201
+ // πŸ“– Profile system removed - API keys now persist permanently across all sessions
202
+
203
+ // πŸ“– Check if any provider has a key β€” if not, run the first-time setup wizard
204
+ const hasAnyKey = Object.keys(sources).some(pk => !!getApiKey(config, pk))
205
+
206
+ if (!hasAnyKey) {
207
+ const result = await promptApiKey(config)
208
+ if (!result) {
209
+ console.log()
210
+ console.log(chalk.red(' βœ– No API key provided.'))
211
+ console.log(chalk.dim(' Run `free-coding-models` again or set NVIDIA_API_KEY / GROQ_API_KEY / CEREBRAS_API_KEY.'))
212
+ console.log()
213
+ process.exit(1)
214
+ }
215
+ }
216
+
217
+ // πŸ“– Default mode: use the last persisted launcher choice when valid,
218
+ // πŸ“– otherwise fall back to OpenCode CLI.
219
+ let mode = getToolModeOrder().includes(config.settings?.preferredToolMode)
220
+ ? config.settings.preferredToolMode
221
+ : 'opencode'
222
+ const requestedMode = getToolModeOrder().find((toolMode) => {
223
+ const flagByMode = {
224
+ opencode: cliArgs.openCodeMode,
225
+ 'opencode-desktop': cliArgs.openCodeDesktopMode,
226
+ openclaw: cliArgs.openClawMode,
227
+ aider: cliArgs.aiderMode,
228
+ crush: cliArgs.crushMode,
229
+ goose: cliArgs.gooseMode,
230
+ qwen: cliArgs.qwenMode,
231
+ openhands: cliArgs.openHandsMode,
232
+ amp: cliArgs.ampMode,
233
+ pi: cliArgs.piMode,
234
+ }
235
+ return flagByMode[toolMode] === true
236
+ })
237
+ if (requestedMode) mode = requestedMode
238
+
239
+ // πŸ“– Track app opening early so fast exits are still counted.
240
+ // πŸ“– Must run before update checks because npm registry lookups can add startup delay.
241
+ void sendUsageTelemetry(config, cliArgs, {
242
+ event: 'app_start',
243
+ version: LOCAL_VERSION,
244
+ mode,
245
+ ts: new Date().toISOString(),
246
+ })
247
+
248
+ // πŸ“– Auto-update detection: check npm registry for new versions at startup.
249
+ // πŸ“– If a new version is available, show an interactive prompt (Update / Changelogs / Skip).
250
+ // πŸ“– Dev mode (git checkout) skips auto-update to avoid infinite relaunch loops.
251
+ let latestVersion = null
252
+ const isDevMode = existsSync(join(dirname(fileURLToPath(import.meta.url)), '..', '.git'))
253
+ try {
254
+ latestVersion = await checkForUpdate()
255
+ // πŸ“– Reset failure counter on successful check
256
+ if (config.settings?.updateCheckFailures) {
257
+ config.settings.updateCheckFailures = 0
258
+ saveConfig(config)
259
+ }
260
+ } catch (err) {
261
+ const failures = (config.settings?.updateCheckFailures || 0) + 1
262
+ if (!config.settings) config.settings = {}
263
+ config.settings.updateCheckFailures = Math.min(failures, 3)
264
+ saveConfig(config)
265
+ }
266
+
267
+ // πŸ“– Show interactive update prompt if a new version is available (skip in dev mode)
268
+ if (latestVersion && !isDevMode) {
269
+ const choice = await promptUpdateNotification(latestVersion)
270
+ if (choice === 'update') {
271
+ runUpdate(latestVersion)
272
+ return // πŸ“– runUpdate relaunches the process β€” this line is a safety guard
273
+ } else if (choice === 'changelogs') {
274
+ const { execSync: _exec } = await import('child_process')
275
+ const url = 'https://github.com/vava-nessa/free-coding-models/releases'
276
+ try {
277
+ if (process.platform === 'darwin') _exec(`open ${url}`)
278
+ else if (process.platform === 'linux') _exec(`xdg-open ${url}`)
279
+ else console.log(chalk.dim(` πŸ“‹ ${url}`))
280
+ } catch { console.log(chalk.dim(` πŸ“‹ ${url}`)) }
281
+ // πŸ“– After opening changelogs, re-prompt so user can still update or continue
282
+ const choice2 = await promptUpdateNotification(latestVersion)
283
+ if (choice2 === 'update') {
284
+ runUpdate(latestVersion)
285
+ return
286
+ }
287
+ }
288
+ }
289
+
290
+ // πŸ“– Dynamic OpenRouter free model discovery β€” fetch live free models from API
291
+ // πŸ“– Replaces static openrouter entries in MODELS with fresh data.
292
+ // πŸ“– Fallback: if fetch fails, the static list from sources.js stays intact + warning shown.
293
+ const dynamicModels = await fetchOpenRouterFreeModels()
294
+ if (dynamicModels) {
295
+ // πŸ“– Remove all existing openrouter entries from MODELS
296
+ for (let i = MODELS.length - 1; i >= 0; i--) {
297
+ if (MODELS[i][5] === 'openrouter') MODELS.splice(i, 1)
298
+ }
299
+ // πŸ“– Push fresh entries with 'openrouter' providerKey
300
+ for (const [modelId, label, tier, swe, ctx] of dynamicModels) {
301
+ MODELS.push([modelId, label, tier, swe, ctx, 'openrouter'])
302
+ }
303
+ } else {
304
+ console.log(chalk.yellow(' OpenRouter: using cached model list (live fetch failed)'))
305
+ }
306
+
307
+ // πŸ“– Re-sync tracked external-tool catalogs after the live provider catalog has settled.
308
+ // πŸ“– This keeps prior `Y` installs aligned with the current FCM model list.
309
+ refreshInstalledEndpoints(config)
310
+
311
+ // πŸ“– Build results from MODELS β€” only include enabled providers
312
+ // πŸ“– Each result gets providerKey so ping() knows which URL + API key to use
313
+
314
+ let results = MODELS
315
+ .filter(([,,,,,providerKey]) => isProviderEnabled(config, providerKey))
316
+ .map(([modelId, label, tier, sweScore, ctx, providerKey], i) => ({
317
+ idx: i + 1, modelId, label, tier, sweScore, ctx, providerKey,
318
+ status: 'pending',
319
+ pings: [], // πŸ“– All ping results (ms or 'TIMEOUT')
320
+ httpCode: null,
321
+ isPinging: false, // πŸ“– Per-row live flag so Latest Ping can keep last value and show a spinner during refresh.
322
+ hidden: false, // πŸ“– Simple flag to hide/show models
323
+ }))
324
+ syncFavoriteFlags(results, config)
325
+
326
+ // πŸ“– Load usage data from token-stats.json and attach usagePercent to each result row.
327
+ // πŸ“– usagePercent is the quota percent remaining (0–100). undefined = no data available.
328
+ // πŸ“– Freshness-aware: snapshots older than 30 minutes are excluded (shown as N/A in UI).
329
+ const tokenTotalsByProviderModel = loadTokenUsageByProviderModel()
330
+ for (const r of results) {
331
+ const pct = _usageForRow(r.providerKey, r.modelId)
332
+ r.usagePercent = typeof pct === 'number' ? pct : undefined
333
+ r.totalTokens = tokenTotalsByProviderModel[buildProviderModelTokenKey(r.providerKey, r.modelId)] || 0
334
+ }
335
+
336
+ // πŸ“– Add interactive selection state - cursor index and user's choice
337
+ // πŸ“– sortColumn: 'rank'|'tier'|'origin'|'model'|'ping'|'avg'|'status'|'verdict'|'uptime'
338
+ // πŸ“– sortDirection: 'asc' (default) or 'desc'
339
+ // πŸ“– ping cadence is now mode-driven:
340
+ // πŸ“– speed = 2s for 1 minute bursts
341
+ // πŸ“– normal = 10s steady state
342
+ // πŸ“– slow = 30s after 5 minutes of inactivity
343
+ // πŸ“– forced = 4s and ignores inactivity / auto slowdowns
344
+ const PING_MODE_INTERVALS = {
345
+ speed: 2_000,
346
+ normal: 10_000,
347
+ slow: 30_000,
348
+ forced: 4_000,
349
+ }
350
+ const PING_MODE_CYCLE = ['speed', 'normal', 'slow', 'forced']
351
+ const SPEED_MODE_DURATION_MS = 60_000
352
+ const IDLE_SLOW_AFTER_MS = 5 * 60_000
353
+ const now = Date.now()
354
+
355
+ const intervalToPingMode = (intervalMs) => {
356
+ if (intervalMs <= 3000) return 'speed'
357
+ if (intervalMs <= 5000) return 'forced'
358
+ if (intervalMs >= 30000) return 'slow'
359
+ return 'normal'
360
+ }
361
+
362
+ // πŸ“– tierFilter: current tier filter letter (null = all, 'S' = S+/S, 'A' = A+/A/A-, etc.)
363
+ const state = {
364
+ results,
365
+ pendingPings: 0,
366
+ frame: 0,
367
+ cursor: 0,
368
+ selectedModel: null,
369
+ sortColumn: config.settings?.sortColumn ?? 'avg',
370
+ sortDirection: (config.settings?.sortAsc ?? true) ? 'asc' : 'desc',
371
+ pingInterval: PING_MODE_INTERVALS.speed, // πŸ“– Effective live interval derived from the active ping mode.
372
+ pingMode: 'speed', // πŸ“– Current ping mode: speed | normal | slow | forced.
373
+ pingModeSource: 'startup', // πŸ“– Why this mode is active: startup | manual | auto | idle | activity.
374
+ speedModeUntil: now + SPEED_MODE_DURATION_MS, // πŸ“– Speed bursts auto-fall back to normal after 60 seconds.
375
+ lastPingTime: now, // πŸ“– Track when last ping cycle started
376
+ lastUserActivityAt: now, // πŸ“– Any keypress refreshes this timer; inactivity can force slow mode.
377
+ resumeSpeedOnActivity: false, // πŸ“– Set after idle slowdown so the next activity restarts a 60s speed burst.
378
+ startupLatestVersion: latestVersion, // πŸ“– Startup auto-check result reused by the footer banner after "skip update".
379
+ versionAlertsEnabled: !isDevMode, // πŸ“– Dev checkouts should not tell contributors to upgrade the global npm package.
380
+ mode, // πŸ“– 'opencode' or 'openclaw' β€” controls Enter action
381
+ tierFilterMode: 0, // πŸ“– Index into TIER_CYCLE (0=All, 1=S+, 2=S, ...)
382
+ originFilterMode: 0, // πŸ“– Index into ORIGIN_CYCLE (0=All, then providers)
383
+ premiumMode: cliArgs.premiumMode, // πŸ“– Special elite-only mode: S/S+ only, Health UP only, Perfect/Normal/Slow verdict only.
384
+ hideUnconfiguredModels: config.settings?.hideUnconfiguredModels === true, // πŸ“– Hide providers with no configured API key when true.
385
+ disableWidthsWarning: config.settings?.disableWidthsWarning ?? false, // πŸ“– Cached for runtime checks; keep it in sync with config.settings.
386
+ scrollOffset: 0, // πŸ“– First visible model index in viewport
387
+ terminalRows: process.stdout.rows || 24, // πŸ“– Current terminal height
388
+ terminalCols: process.stdout.columns || 80, // πŸ“– Current terminal width
389
+ widthWarningStartedAt: (process.stdout.columns || 80) < 166 && !(config.settings?.disableWidthsWarning ?? false) ? now : null, // πŸ“– Start immediately only when warnings are enabled in a narrow viewport.
390
+ widthWarningDismissed: false, // πŸ“– Esc hides the narrow-terminal warning early for the current narrow-width session.
391
+ widthWarningShowCount: 0, // πŸ“– Counter for how many times the narrow-terminal warning has been shown (max 2 per session).
392
+ // πŸ“– Settings screen state (P key opens it)
393
+ settingsOpen: false, // πŸ“– Whether settings overlay is active
394
+ settingsCursor: 0, // πŸ“– Which provider row is selected in settings
395
+ settingsEditMode: false, // πŸ“– Whether we're in inline key editing mode (edit primary key)
396
+ settingsAddKeyMode: false, // πŸ“– Whether we're in add-key mode (append a new key to provider)
397
+ settingsEditBuffer: '', // πŸ“– Typed characters for the API key being edited
398
+ settingsErrorMsg: null, // πŸ“– Temporary error message to display in settings
399
+ settingsTestResults: {}, // πŸ“– { providerKey: 'pending'|'ok'|'auth_error'|'rate_limited'|'no_callable_model'|'fail'|'missing_key'|null }
400
+ settingsTestDetails: {}, // πŸ“– Long-form diagnostics shown under Setup Instructions after a Settings key test.
401
+ settingsUpdateState: 'idle', // πŸ“– 'idle'|'checking'|'available'|'up-to-date'|'error'|'installing'
402
+ settingsUpdateLatestVersion: null, // πŸ“– Latest npm version discovered from manual check
403
+ settingsUpdateError: null, // πŸ“– Last update-check error message for maintenance row
404
+ config, // πŸ“– Live reference to the config object (updated on save)
405
+ visibleSorted: [], // πŸ“– Cached visible+sorted models β€” shared between render loop and key handlers
406
+ helpVisible: false, // πŸ“– Whether the help overlay (K key) is active
407
+ settingsScrollOffset: 0, // πŸ“– Vertical scroll offset for Settings overlay viewport
408
+ helpScrollOffset: 0, // πŸ“– Vertical scroll offset for Help overlay viewport
409
+ // πŸ“– Install Endpoints overlay state (Y key opens it)
410
+ installEndpointsOpen: false, // πŸ“– Whether the install-endpoints overlay is active
411
+ installEndpointsPhase: 'providers', // πŸ“– providers | tools | scope | models | result
412
+ installEndpointsCursor: 0, // πŸ“– Selected row within the current install phase
413
+ installEndpointsScrollOffset: 0, // πŸ“– Vertical scroll offset for the install overlay viewport
414
+ installEndpointsProviderKey: null, // πŸ“– Selected provider for endpoint installation
415
+ installEndpointsToolMode: null, // πŸ“– Selected target tool mode
416
+ installEndpointsConnectionMode: null, // πŸ“– Direct provider path retained for future install flow state.
417
+ installEndpointsScope: null, // πŸ“– all | selected
418
+ installEndpointsSelectedModelIds: new Set(), // πŸ“– Multi-select buffer for the selected-models phase
419
+ installEndpointsErrorMsg: null, // πŸ“– Temporary validation/error message inside the install flow
420
+ installEndpointsResult: null, // πŸ“– Final install result shown in the result phase
421
+ // πŸ“– Smart Recommend overlay state (Q key opens it)
422
+ recommendOpen: false, // πŸ“– Whether the recommend overlay is active
423
+ recommendPhase: 'questionnaire', // πŸ“– 'questionnaire'|'analyzing'|'results' β€” current phase
424
+ recommendCursor: 0, // πŸ“– Selected question option (0-based index within current question)
425
+ recommendQuestion: 0, // πŸ“– Which question we're on (0=task, 1=priority, 2=context)
426
+ recommendAnswers: { taskType: null, priority: null, contextBudget: null }, // πŸ“– User's answers
427
+ recommendProgress: 0, // πŸ“– Analysis progress percentage (0–100)
428
+ recommendResults: [], // πŸ“– Top N recommendations from getTopRecommendations()
429
+ recommendScrollOffset: 0, // πŸ“– Vertical scroll offset for Recommend overlay viewport
430
+ recommendAnalysisTimer: null, // πŸ“– setInterval handle for the 10s analysis phase
431
+ recommendPingTimer: null, // πŸ“– setInterval handle for 2 pings/sec during analysis
432
+ recommendedKeys: new Set(), // πŸ“– Set of "providerKey/modelId" for recommended models (shown in main table)
433
+ // πŸ“– Feedback state (J/I keys open it)
434
+ feedbackOpen: false, // πŸ“– Whether the feedback overlay is active
435
+ bugReportBuffer: '', // πŸ“– Typed characters for the feedback message
436
+ bugReportStatus: 'idle', // πŸ“– 'idle'|'sending'|'success'|'error' β€” webhook send status
437
+ bugReportError: null, // πŸ“– Last webhook error message
438
+ // πŸ“– OpenCode sync status (S key in settings)
439
+ settingsSyncStatus: null, // πŸ“– { type: 'success'|'error', msg: string } β€” shown in settings footer
440
+ // πŸ“– Changelog overlay state (N key opens it)
441
+ changelogOpen: false, // πŸ“– Whether the changelog overlay is active
442
+ changelogScrollOffset: 0, // πŸ“– Vertical scroll offset for changelog overlay viewport
443
+ changelogPhase: 'index', // πŸ“– 'index' (all versions) | 'details' (specific version)
444
+ changelogCursor: 0, // πŸ“– Selected row in index phase
445
+ changelogSelectedVersion: null, // πŸ“– Which version to show details for
446
+ }
447
+
448
+ // πŸ“– Re-clamp viewport on terminal resize
449
+ process.stdout.on('resize', () => {
450
+ const prevCols = state.terminalCols
451
+ const widthsWarningDisabled = state.config.settings?.disableWidthsWarning === true
452
+ state.terminalRows = process.stdout.rows || 24
453
+ state.terminalCols = process.stdout.columns || 80
454
+ state.disableWidthsWarning = widthsWarningDisabled
455
+ if (state.terminalCols < 166 && !widthsWarningDisabled) {
456
+ if (prevCols >= 166 || state.widthWarningDismissed) {
457
+ state.widthWarningStartedAt = Date.now()
458
+ state.widthWarningDismissed = false
459
+ state.widthWarningShowCount++ // πŸ“– Increment counter when showing the warning again
460
+ } else if (!state.widthWarningStartedAt) {
461
+ state.widthWarningStartedAt = Date.now()
462
+ }
463
+ } else {
464
+ state.widthWarningStartedAt = null
465
+ state.widthWarningDismissed = false
466
+ }
467
+ adjustScrollOffset(state)
468
+ })
469
+
470
+ let ticker = null
471
+ let onKeyPress = null
472
+ let pingModel = null
473
+
474
+ const scheduleNextPing = () => {
475
+ clearTimeout(state.pingIntervalObj)
476
+ const elapsed = Date.now() - state.lastPingTime
477
+ const delay = Math.max(0, state.pingInterval - elapsed)
478
+ state.pingIntervalObj = setTimeout(runPingCycle, delay)
479
+ }
480
+
481
+ const setPingMode = (nextMode, source = 'manual') => {
482
+ const modeInterval = PING_MODE_INTERVALS[nextMode] ?? PING_MODE_INTERVALS.normal
483
+ state.pingMode = nextMode
484
+ state.pingModeSource = source
485
+ state.pingInterval = modeInterval
486
+ state.speedModeUntil = nextMode === 'speed' ? Date.now() + SPEED_MODE_DURATION_MS : null
487
+ state.resumeSpeedOnActivity = source === 'idle'
488
+ if (state.pingIntervalObj) scheduleNextPing()
489
+ }
490
+
491
+ const noteUserActivity = () => {
492
+ state.lastUserActivityAt = Date.now()
493
+ if (state.pingMode === 'forced') return
494
+ if (state.resumeSpeedOnActivity) {
495
+ setPingMode('speed', 'activity')
496
+ }
497
+ }
498
+
499
+ const refreshAutoPingMode = () => {
500
+ const currentTime = Date.now()
501
+ if (state.pingMode === 'forced') return
502
+
503
+ if (state.speedModeUntil && currentTime >= state.speedModeUntil) {
504
+ setPingMode('normal', 'auto')
505
+ return
506
+ }
507
+
508
+ if (currentTime - state.lastUserActivityAt >= IDLE_SLOW_AFTER_MS) {
509
+ if (state.pingMode !== 'slow' || state.pingModeSource !== 'idle') {
510
+ setPingMode('slow', 'idle')
511
+ } else {
512
+ state.resumeSpeedOnActivity = true
513
+ }
514
+ }
515
+ }
516
+
517
+ // πŸ“– Load cache if available (for faster startup with cached ping results)
518
+ const cached = loadCache()
519
+ if (cached && cached.models) {
520
+ // πŸ“– Apply cached values to results
521
+ for (const r of state.results) {
522
+ const cachedModel = cached.models[r.modelId]
523
+ if (cachedModel) {
524
+ r.avg = cachedModel.avg
525
+ r.p95 = cachedModel.p95
526
+ r.jitter = cachedModel.jitter
527
+ r.stability = cachedModel.stability
528
+ r.uptime = cachedModel.uptime
529
+ r.verdict = cachedModel.verdict
530
+ r.status = cachedModel.status
531
+ r.httpCode = cachedModel.httpCode
532
+ r.pings = cachedModel.pings || []
533
+ }
534
+ }
535
+ }
536
+
537
+ // πŸ“– Define pingModel before JSON mode so `--json` can reuse the same provider-aware
538
+ // πŸ“– ping path as the interactive TUI without waiting for the PTY/render loop setup.
539
+ pingModel = async (r) => {
540
+ state.pendingPings += 1
541
+ r.isPinging = true
542
+
543
+ try {
544
+ const providerApiKey = getApiKey(state.config, r.providerKey) ?? null
545
+ const providerUrl = sources[r.providerKey]?.url ?? sources.nvidia.url
546
+ let { code, ms, quotaPercent } = await ping(providerApiKey, r.modelId, r.providerKey, providerUrl)
547
+
548
+ if ((quotaPercent === null || quotaPercent === undefined) && providerApiKey) {
549
+ const providerQuota = await getProviderQuotaPercentCached(r.providerKey, providerApiKey)
550
+ if (typeof providerQuota === 'number' && Number.isFinite(providerQuota)) {
551
+ quotaPercent = providerQuota
552
+ }
553
+ }
554
+
555
+ r.pings.push({ ms, code })
556
+
557
+ if (code === '200') {
558
+ r.status = 'up'
559
+ } else if (code === '000') {
560
+ r.status = 'timeout'
561
+ } else if (code === '401' || code === '403') {
562
+ r.status = providerApiKey ? 'auth_error' : 'noauth'
563
+ r.httpCode = code
564
+ } else {
565
+ r.status = 'down'
566
+ r.httpCode = code
567
+ }
568
+
569
+ if (typeof quotaPercent === 'number' && Number.isFinite(quotaPercent)) {
570
+ r.usagePercent = quotaPercent
571
+ for (const sibling of state.results) {
572
+ if (sibling.providerKey === r.providerKey && (sibling.usagePercent === undefined || sibling.usagePercent === null)) {
573
+ sibling.usagePercent = quotaPercent
574
+ }
575
+ }
576
+ }
577
+ } finally {
578
+ r.isPinging = false
579
+ state.pendingPings = Math.max(0, state.pendingPings - 1)
580
+ }
581
+ }
582
+
583
+ // πŸ“– JSON output mode: skip TUI, output results as JSON after initial pings
584
+ if (cliArgs.jsonMode) {
585
+ console.log(chalk.cyan(' ⚑ Pinging models for JSON output...'))
586
+ console.log()
587
+
588
+ // πŸ“– Run initial pings
589
+ const initialPing = Promise.all(state.results.map(r => pingModel(r)))
590
+ await initialPing
591
+
592
+ // πŸ“– Calculate final stats
593
+ state.results.forEach(r => {
594
+ r.avg = getAvg(r)
595
+ r.p95 = getP95(r)
596
+ r.jitter = getJitter(r)
597
+ r.stability = getStabilityScore(r)
598
+ r.uptime = getUptime(r)
599
+ r.verdict = getVerdict(r)
600
+ })
601
+
602
+ // πŸ“– Apply tier filter if specified
603
+ let outputResults = state.results
604
+ if (cliArgs.tierFilter) {
605
+ const filteredTier = TIER_LETTER_MAP[cliArgs.tierFilter]
606
+ if (filteredTier) {
607
+ outputResults = state.results.filter(r => filteredTier.includes(r.tier))
608
+ }
609
+ }
610
+
611
+ // πŸ“– Apply best mode filter if specified
612
+ if (cliArgs.bestMode) {
613
+ outputResults = outputResults.filter(r => ['S+', 'S', 'A+'].includes(r.tier))
614
+ }
615
+
616
+ // πŸ“– Apply premium mode filter if specified: elite-only (S/S+, UP, Good Verdict)
617
+ if (cliArgs.premiumMode) {
618
+ outputResults = outputResults.filter(r => {
619
+ const isEliteTier = r.tier === 'S' || r.tier === 'S+'
620
+ const isHealthUp = r.status === 'up'
621
+ const verdict = getVerdict(r)
622
+ const isGoodVerdict = ['Perfect', 'Normal', 'Slow'].includes(verdict)
623
+ return isEliteTier && isHealthUp && isGoodVerdict
624
+ })
625
+ }
626
+
627
+ // πŸ“– Sort by avg ping (ascending)
628
+ outputResults = sortResults(outputResults, 'avg', 'asc')
629
+
630
+ // πŸ“– Output JSON
631
+ console.log(formatResultsAsJSON(outputResults))
632
+
633
+ // πŸ“– Save cache before exiting
634
+ saveCache(state.results, state.pingMode)
635
+
636
+ process.exit(0)
637
+ }
638
+
639
+ // πŸ“– Enter alternate screen β€” animation runs here, zero scrollback pollution
640
+ process.stdout.write(ALT_ENTER)
641
+ if (process.stdout.isTTY) {
642
+ process.stdout.flush && process.stdout.flush()
643
+ }
644
+
645
+ // πŸ“– Ensure we always leave alt screen cleanly (Ctrl+C, crash, normal exit)
646
+ const exit = (code = 0) => {
647
+ // πŸ“– Save cache before exiting so next run starts faster
648
+ saveCache(state.results, state.pingMode)
649
+ clearInterval(ticker)
650
+ clearTimeout(state.pingIntervalObj)
651
+ process.stdout.write(ALT_LEAVE)
652
+ if (process.stdout.isTTY) {
653
+ process.stdout.flush && process.stdout.flush()
654
+ }
655
+ process.exit(code)
656
+ }
657
+ process.on('SIGINT', () => exit(0))
658
+ process.on('SIGTERM', () => exit(0))
659
+
660
+ // πŸ“– originFilterMode: index into ORIGIN_CYCLE, 0=All, then each provider key in order
661
+ const ORIGIN_CYCLE = [null, ...Object.keys(sources)]
662
+ const resolvedTierFilter = config.settings?.tierFilter
663
+ state.tierFilterMode = resolvedTierFilter ? Math.max(0, TIER_CYCLE.indexOf(resolvedTierFilter)) : 0
664
+ const resolvedOriginFilter = config.settings?.originFilter
665
+ state.originFilterMode = resolvedOriginFilter ? Math.max(0, ORIGIN_CYCLE.indexOf(resolvedOriginFilter)) : 0
666
+
667
+ function applyTierFilter() {
668
+ const activeTier = TIER_CYCLE[state.tierFilterMode]
669
+ const activeOrigin = ORIGIN_CYCLE[state.originFilterMode]
670
+ state.results.forEach(r => {
671
+ // πŸ“– Favorites stay visible and pinned regardless of configured-only, tier, or provider filters.
672
+ if (r.isFavorite) {
673
+ r.hidden = false
674
+ return
675
+ }
676
+ const unconfiguredHide = state.hideUnconfiguredModels && !getApiKey(state.config, r.providerKey)
677
+ if (unconfiguredHide) {
678
+ r.hidden = true
679
+ return
680
+ }
681
+ // πŸ“– Apply both tier and origin filters β€” model is hidden if it fails either
682
+ // πŸ“– TIER_LETTER_MAP is used so --tier S also includes S+ models (tier family behavior).
683
+ const allowedTiers = (activeTier && TIER_LETTER_MAP[activeTier]) ? TIER_LETTER_MAP[activeTier] : [activeTier]
684
+ const tierHide = activeTier !== null && !allowedTiers.includes(r.tier)
685
+ const originHide = activeOrigin !== null && r.providerKey !== activeOrigin
686
+ r.hidden = tierHide || originHide
687
+
688
+ // πŸ“– Premium Mode: elite-only constraints (Health UP, Good Verdict, S/S+ only)
689
+ if (state.premiumMode && !r.hidden) {
690
+ const isEliteTier = r.tier === 'S' || r.tier === 'S+'
691
+ const isHealthUp = r.status === 'up'
692
+ const verdict = getVerdict(r)
693
+ const isGoodVerdict = ['Perfect', 'Normal', 'Slow'].includes(verdict)
694
+
695
+ if (!isEliteTier || !isHealthUp || !isGoodVerdict) {
696
+ r.hidden = true
697
+ }
698
+ }
699
+ })
700
+ return state.results
701
+ }
702
+
703
+ // πŸ“– Apply initial filters so configured-only mode works on first render
704
+ applyTierFilter()
705
+
706
+ // ─── Overlay renderers + key handler ─────────────────────────────────────
707
+ const stopUi = ({ resetRawMode = false } = {}) => {
708
+ if (ticker) clearInterval(ticker)
709
+ clearTimeout(state.pingIntervalObj)
710
+ if (onKeyPress) process.stdin.removeListener('keypress', onKeyPress)
711
+ if (process.stdin.isTTY && resetRawMode) process.stdin.setRawMode(false)
712
+ process.stdin.pause()
713
+ process.stdout.write(ALT_LEAVE)
714
+ if (process.stdout.isTTY) {
715
+ process.stdout.flush && process.stdout.flush()
716
+ }
717
+ }
718
+
719
+ const overlays = createOverlayRenderers(state, {
720
+ chalk,
721
+ sources,
722
+ PROVIDER_METADATA,
723
+ PROVIDER_COLOR,
724
+ LOCAL_VERSION,
725
+ getApiKey,
726
+ resolveApiKeys,
727
+ isProviderEnabled,
728
+ TIER_CYCLE,
729
+ SETTINGS_OVERLAY_BG,
730
+ HELP_OVERLAY_BG,
731
+ RECOMMEND_OVERLAY_BG,
732
+ OVERLAY_PANEL_WIDTH,
733
+ keepOverlayTargetVisible,
734
+ sliceOverlayLines,
735
+ tintOverlayLines,
736
+ TASK_TYPES,
737
+ PRIORITY_TYPES,
738
+ CONTEXT_BUDGETS,
739
+ FRAMES,
740
+ TIER_COLOR,
741
+ getAvg,
742
+ getStabilityScore,
743
+ toFavoriteKey,
744
+ getTopRecommendations,
745
+ adjustScrollOffset,
746
+ getPingModel: () => pingModel,
747
+ getConfiguredInstallableProviders,
748
+ getInstallTargetModes,
749
+ getProviderCatalogModels,
750
+ getToolMeta,
751
+ })
752
+
753
+ onKeyPress = createKeyHandler({
754
+ state,
755
+ exit,
756
+ cliArgs,
757
+ MODELS,
758
+ sources,
759
+ getApiKey,
760
+ resolveApiKeys,
761
+ addApiKey,
762
+ removeApiKey,
763
+ persistApiKeysForProvider,
764
+ isProviderEnabled,
765
+ saveConfig,
766
+ getConfiguredInstallableProviders,
767
+ getInstallTargetModes,
768
+ getProviderCatalogModels,
769
+ installProviderEndpoints,
770
+ syncFavoriteFlags,
771
+ toggleFavoriteModel,
772
+ sortResultsWithPinnedFavorites,
773
+ adjustScrollOffset,
774
+ applyTierFilter,
775
+ PING_INTERVAL,
776
+ TIER_CYCLE,
777
+ ORIGIN_CYCLE,
778
+ ENV_VAR_NAMES,
779
+ checkForUpdateDetailed,
780
+ runUpdate,
781
+ startOpenClaw,
782
+ startOpenCodeDesktop,
783
+ startOpenCode,
784
+ startExternalTool,
785
+ getToolModeOrder,
786
+ startRecommendAnalysis: overlays.startRecommendAnalysis,
787
+ stopRecommendAnalysis: overlays.stopRecommendAnalysis,
788
+ sendBugReport,
789
+ stopUi,
790
+ ping,
791
+ TASK_TYPES,
792
+ PRIORITY_TYPES,
793
+ CONTEXT_BUDGETS,
794
+ toFavoriteKey,
795
+ mergedModels,
796
+ chalk,
797
+ setPingMode,
798
+ noteUserActivity,
799
+ intervalToPingMode,
800
+ PING_MODE_CYCLE,
801
+ setResults: (next) => { results = next },
802
+ readline,
803
+ })
804
+
805
+ // Apply CLI --tier filter if provided
806
+ if (cliArgs.tierFilter) {
807
+ const allowed = TIER_LETTER_MAP[cliArgs.tierFilter]
808
+ state.results.forEach(r => {
809
+ r.hidden = r.isFavorite ? false : !allowed.includes(r.tier)
810
+ })
811
+ }
812
+
813
+ // πŸ“– Setup keyboard input for interactive selection during pings
814
+ // πŸ“– Use readline with keypress event for arrow key handling
815
+ process.stdin.setEncoding('utf8')
816
+ process.stdin.resume()
817
+
818
+ let userSelected = null
819
+
820
+ // πŸ“– Enable keypress events on stdin
821
+ readline.emitKeypressEvents(process.stdin)
822
+ if (process.stdin.isTTY) {
823
+ process.stdin.setRawMode(true)
824
+ }
825
+
826
+ process.stdin.on('keypress', async (str, key) => {
827
+ try {
828
+ await onKeyPress(str, key);
829
+ } catch (err) {
830
+ process.stdout.write(ALT_LEAVE);
831
+ console.error(chalk.red('\n[TUI Error] An error occurred while handling a keypress.'));
832
+ console.error(err);
833
+ 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.'));
834
+ process.exit(1);
835
+ }
836
+ })
837
+ process.on('SIGCONT', noteUserActivity)
838
+
839
+ // πŸ“– Animation loop: render settings overlay, recommend overlay, help overlay, feature request overlay, bug report overlay, changelog overlay, OR main table
840
+ ticker = setInterval(() => {
841
+ try {
842
+ refreshAutoPingMode()
843
+ state.frame++
844
+ // πŸ“– Cache visible+sorted models each frame so Enter handler always matches the display
845
+ if (!state.settingsOpen && !state.installEndpointsOpen && !state.recommendOpen && !state.feedbackOpen && !state.changelogOpen) {
846
+ const visible = state.results.filter(r => !r.hidden)
847
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
848
+ }
849
+ const content = state.settingsOpen
850
+ ? overlays.renderSettings()
851
+ : state.installEndpointsOpen
852
+ ? overlays.renderInstallEndpoints()
853
+ : state.recommendOpen
854
+ ? overlays.renderRecommend()
855
+ : state.feedbackOpen
856
+ ? overlays.renderFeedback()
857
+ : state.helpVisible
858
+ ? overlays.renderHelp()
859
+ : state.changelogOpen
860
+ ? overlays.renderChangelog()
861
+ : 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)
862
+ process.stdout.write(ALT_HOME + content)
863
+ if (process.stdout.isTTY) {
864
+ process.stdout.flush && process.stdout.flush()
865
+ }
866
+ } catch (err) {
867
+ process.stdout.write(ALT_LEAVE);
868
+ console.error(chalk.red('\n[TUI Render Error] An error occurred during UI rendering.'));
869
+ console.error(err);
870
+ 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.'));
871
+ process.exit(1);
872
+ }
873
+ }, Math.round(1000 / FPS))
874
+
875
+ // πŸ“– Populate visibleSorted before the first frame so Enter works immediately
876
+ const initialVisible = state.results.filter(r => !r.hidden)
877
+ state.visibleSorted = sortResultsWithPinnedFavorites(initialVisible, state.sortColumn, state.sortDirection)
878
+
879
+ 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))
880
+ if (process.stdout.isTTY) {
881
+ process.stdout.flush && process.stdout.flush()
882
+ }
883
+
884
+ // πŸ“– If --recommend was passed, auto-open the Smart Recommend overlay on start
885
+ if (cliArgs.recommendMode) {
886
+ state.recommendOpen = true
887
+ state.recommendPhase = 'questionnaire'
888
+ state.recommendCursor = 0
889
+ state.recommendQuestion = 0
890
+ state.recommendAnswers = { taskType: null, priority: null, contextBudget: null }
891
+ state.recommendProgress = 0
892
+ state.recommendResults = []
893
+ state.recommendScrollOffset = 0
894
+ }
895
+
896
+ // ── Continuous ping loop β€” ping all models every N seconds forever ──────────
897
+
898
+ // πŸ“– Initial ping of all models
899
+ const initialPing = Promise.all(state.results.map(r => pingModel(r)))
900
+
901
+ // πŸ“– Continuous ping loop with mode-driven cadence.
902
+ const runPingCycle = async () => {
903
+ try {
904
+ refreshAutoPingMode()
905
+ state.lastPingTime = Date.now()
906
+
907
+ // πŸ“– Refresh persisted usage snapshots each cycle so background usage data appears live in table.
908
+ // πŸ“– Freshness-aware: stale snapshots (>30m) are excluded and row reverts to undefined.
909
+ for (const r of state.results) {
910
+ const pct = _usageForRow(r.providerKey, r.modelId)
911
+ if (typeof pct === 'number' && Number.isFinite(pct)) {
912
+ r.usagePercent = pct
913
+ } else {
914
+ // If snapshot is now stale or gone, clear the cached value so UI shows N/A.
915
+ r.usagePercent = undefined
916
+ }
917
+ }
918
+
919
+ state.results.forEach(r => {
920
+ pingModel(r).catch(() => {
921
+ // Individual ping failures don't crash the loop
922
+ })
923
+ })
924
+
925
+ refreshAutoPingMode()
926
+ scheduleNextPing()
927
+ } catch (err) {
928
+ process.stdout.write(ALT_LEAVE);
929
+ console.error(chalk.red('\n[TUI Error] An error occurred in the ping loop.'));
930
+ console.error(err);
931
+ 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.'));
932
+ process.exit(1);
933
+ }
934
+ }
935
+
936
+ // πŸ“– Start the ping loop
937
+ state.pingIntervalObj = null
938
+ scheduleNextPing()
939
+
940
+ await initialPing
941
+
942
+ // πŸ“– Save cache after initial pings complete for faster next startup
943
+ saveCache(state.results, state.pingMode)
944
+
945
+ // πŸ“– Keep interface running forever - user can select anytime or Ctrl+C to exit
946
+ // πŸ“– The pings continue running in background with dynamic interval
947
+ // πŸ“– User can press W to decrease interval (faster pings) or = to increase (slower)
948
+ // πŸ“– Current interval shown in header: "next ping Xs"
949
+ }