free-coding-models 0.1.64 β†’ 0.1.65

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/README.md CHANGED
@@ -48,7 +48,7 @@
48
48
 
49
49
  - **🎯 Coding-focused** β€” Only LLM models optimized for code generation, not chat or vision
50
50
  - **🌐 Multi-provider** β€” 111 models from NVIDIA NIM, Groq, Cerebras, SambaNova, OpenRouter, Hugging Face Inference, Replicate, DeepInfra, Fireworks AI, Codestral, Hyperbolic, Scaleway, and Google AI β€” all free to use
51
- - **βš™οΈ Settings screen** β€” Press `P` to manage provider API keys, enable/disable providers, and test keys live
51
+ - **βš™οΈ Settings screen** β€” Press `P` to manage provider API keys, enable/disable providers, test keys live, and manually check/install updates
52
52
  - **πŸš€ Parallel pings** β€” All models tested simultaneously via native `fetch`
53
53
  - **πŸ“Š Real-time animation** β€” Watch latency appear live in alternate screen buffer
54
54
  - **πŸ† Smart ranking** β€” Top 3 fastest models highlighted with medals πŸ₯‡πŸ₯ˆπŸ₯‰
@@ -64,6 +64,7 @@
64
64
  - **πŸ“Ά Status indicators** β€” UP βœ… Β· No Key πŸ”‘ Β· Timeout ⏳ Β· Overloaded πŸ”₯ Β· Not Found 🚫
65
65
  - **πŸ” Keyless latency** β€” Models are pinged even without an API key β€” a `πŸ”‘ NO KEY` status confirms the server is reachable with real latency shown, so you can compare providers before committing to a key
66
66
  - **🏷 Tier filtering** β€” Filter models by tier letter (S, A, B, C) with `--tier` flag or dynamically with `T` key
67
+ - **⭐ Persistent favorites** β€” Press `F` on a selected row to pin/unpin it; favorites stay at top with a dark orange background and a star before the model name
67
68
  - **πŸ“Š Privacy-first analytics (optional)** β€” anonymous PostHog events with explicit consent + opt-out
68
69
 
69
70
  ---
@@ -227,18 +228,21 @@ Press **`P`** to open the Settings screen at any time:
227
228
  2) Profile β†’ API Keys β†’ Generate
228
229
  3) Press T to test your key
229
230
 
230
- ↑↓ Navigate β€’ Enter Edit key β€’ Space Toggle enabled β€’ T Test key β€’ Esc Close
231
+ ↑↓ Navigate β€’ Enter Edit key / Check-or-Install update β€’ Space Toggle enabled β€’ T Test key β€’ U Check updates β€’ Esc Close
231
232
  ```
232
233
 
233
234
  - **↑↓** β€” navigate providers
234
235
  - **Enter** β€” enter inline key edit mode (type your key, Enter to save, Esc to cancel)
235
236
  - **Space** β€” toggle provider enabled/disabled
236
237
  - **T** β€” fire a real test ping to verify the key works (shows βœ…/❌)
238
+ - **U** β€” manually check npm for a newer version
237
239
  - **Esc** β€” close settings and reload models list
238
240
 
239
241
  Keys are saved to `~/.free-coding-models.json` (permissions `0600`).
240
242
 
241
243
  Analytics toggle is in the same Settings screen (`P`) as a dedicated row (toggle with Enter or Space).
244
+ Manual update is in the same Settings screen (`P`) under **Maintenance** (Enter to check, Enter again to install when an update is available).
245
+ Favorites are also persisted in the same config file and survive restarts.
242
246
 
243
247
  ### Environment variable overrides
244
248
 
@@ -604,6 +608,9 @@ This script:
604
608
  "replicate": { "enabled": true },
605
609
  "deepinfra": { "enabled": true }
606
610
  },
611
+ "favorites": [
612
+ "nvidia/deepseek-ai/deepseek-v3.2"
613
+ ],
607
614
  "telemetry": {
608
615
  "enabled": true,
609
616
  "consentVersion": 1,
@@ -637,18 +644,23 @@ This script:
637
644
  - **↑↓** β€” Navigate models
638
645
  - **Enter** β€” Select model (launches OpenCode or sets OpenClaw default, depending on mode)
639
646
  - **R/Y/O/M/L/A/S/N/H/V/U** β€” Sort by Rank/Tier/Origin/Model/LatestPing/Avg/SWE/Ctx/Health/Verdict/Uptime
647
+ - **F** β€” Toggle favorite on selected model (⭐ in Model column, pinned at top)
640
648
  - **T** β€” Cycle tier filter (All β†’ S+ β†’ S β†’ A+ β†’ A β†’ A- β†’ B+ β†’ B β†’ C β†’ All)
641
649
  - **Z** β€” Cycle mode (OpenCode CLI β†’ OpenCode Desktop β†’ OpenClaw)
642
- - **P** β€” Open Settings (manage API keys, provider toggles, analytics toggle)
650
+ - **P** β€” Open Settings (manage API keys, provider toggles, analytics toggle, manual update)
643
651
  - **W** β€” Decrease ping interval (faster pings)
644
652
  - **X** β€” Increase ping interval (slower pings)
653
+ - **K** / **Esc** β€” Show/hide help overlay
645
654
  - **Ctrl+C** β€” Exit
646
655
 
656
+ Pressing **K** now shows a full in-app reference: main hotkeys, settings hotkeys, and CLI flags with usage examples.
657
+
647
658
  **Keyboard shortcuts (Settings screen β€” `P` key):**
648
- - **↑↓** β€” Navigate providers and analytics row
649
- - **Enter** β€” Edit API key inline, or toggle analytics on analytics row
659
+ - **↑↓** β€” Navigate providers, analytics row, and maintenance row
660
+ - **Enter** β€” Edit API key inline, toggle analytics on analytics row, or check/install update on maintenance row
650
661
  - **Space** β€” Toggle provider enabled/disabled, or toggle analytics on analytics row
651
662
  - **T** β€” Test current provider's API key (fires a live ping)
663
+ - **U** β€” Check for updates manually from settings
652
664
  - **Esc** β€” Close settings and return to main TUI
653
665
 
654
666
  ---
@@ -20,7 +20,8 @@
20
20
  * - Automatic config detection and model setup for both tools
21
21
  * - JSON config stored in ~/.free-coding-models.json (auto-migrates from old plain-text)
22
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 per provider, enable/disable, test keys
23
+ * - Settings screen (P key) to manage API keys, provider toggles, analytics, and manual updates
24
+ * - Favorites system: toggle with F, pin rows to top, persist between sessions
24
25
  * - Uptime percentage tracking (successful pings / total pings)
25
26
  * - Sortable columns (R/Y/O/M/L/A/S/N/H/V/U keys)
26
27
  * - Tier filtering via T key (cycles S+β†’Sβ†’A+β†’Aβ†’A-β†’B+β†’Bβ†’Cβ†’All)
@@ -32,6 +33,7 @@
32
33
  * - `getTelemetryTerminal`: Infer terminal family (Terminal.app, iTerm2, kitty, etc.)
33
34
  * - `isTelemetryDebugEnabled` / `telemetryDebug`: Optional runtime telemetry diagnostics via env
34
35
  * - `sendUsageTelemetry`: Fire-and-forget anonymous app-start event
36
+ * - `ensureFavoritesConfig` / `toggleFavoriteModel`: Persist and toggle pinned favorites
35
37
  * - `promptApiKey`: Interactive wizard for first-time multi-provider API key setup
36
38
  * - `promptModeSelection`: Startup menu to choose OpenCode vs OpenClaw
37
39
  * - `buildPingRequest` / `ping`: Build provider-specific probe requests and measure latency
@@ -159,6 +161,53 @@ function ensureTelemetryConfig(config) {
159
161
  }
160
162
  }
161
163
 
164
+ // πŸ“– Ensure favorites config shape exists and remains clean.
165
+ // πŸ“– Stored format: ["providerKey/modelId", ...] in insertion order.
166
+ function ensureFavoritesConfig(config) {
167
+ if (!Array.isArray(config.favorites)) config.favorites = []
168
+ const seen = new Set()
169
+ config.favorites = config.favorites.filter((entry) => {
170
+ if (typeof entry !== 'string' || entry.trim().length === 0) return false
171
+ if (seen.has(entry)) return false
172
+ seen.add(entry)
173
+ return true
174
+ })
175
+ }
176
+
177
+ // πŸ“– Build deterministic key used to persist one favorite model row.
178
+ function toFavoriteKey(providerKey, modelId) {
179
+ return `${providerKey}/${modelId}`
180
+ }
181
+
182
+ // πŸ“– Sync per-row favorite metadata from config (used by renderer and sorter).
183
+ function syncFavoriteFlags(results, config) {
184
+ ensureFavoritesConfig(config)
185
+ const favoriteRankMap = new Map(config.favorites.map((entry, index) => [entry, index]))
186
+ for (const row of results) {
187
+ const favoriteKey = toFavoriteKey(row.providerKey, row.modelId)
188
+ const rank = favoriteRankMap.get(favoriteKey)
189
+ row.favoriteKey = favoriteKey
190
+ row.isFavorite = rank !== undefined
191
+ row.favoriteRank = rank !== undefined ? rank : Number.MAX_SAFE_INTEGER
192
+ }
193
+ }
194
+
195
+ // πŸ“– Toggle favorite state and persist immediately.
196
+ // πŸ“– Returns true when row is now favorite, false when removed.
197
+ function toggleFavoriteModel(config, providerKey, modelId) {
198
+ ensureFavoritesConfig(config)
199
+ const favoriteKey = toFavoriteKey(providerKey, modelId)
200
+ const existingIndex = config.favorites.indexOf(favoriteKey)
201
+ if (existingIndex >= 0) {
202
+ config.favorites.splice(existingIndex, 1)
203
+ saveConfig(config)
204
+ return false
205
+ }
206
+ config.favorites.push(favoriteKey)
207
+ saveConfig(config)
208
+ return true
209
+ }
210
+
162
211
  // πŸ“– Create or reuse a persistent anonymous distinct_id for PostHog.
163
212
  // πŸ“– Stored locally in config so one user is stable over time without personal data.
164
213
  function getTelemetryDistinctId(config) {
@@ -416,14 +465,25 @@ async function sendUsageTelemetry(config, cliArgs, payload) {
416
465
  }
417
466
  }
418
467
 
419
- async function checkForUpdate() {
468
+ // πŸ“– checkForUpdateDetailed: Fetch npm latest version with explicit error details.
469
+ // πŸ“– Used by settings manual-check flow to display meaningful status in the UI.
470
+ async function checkForUpdateDetailed() {
420
471
  try {
421
472
  const res = await fetch('https://registry.npmjs.org/free-coding-models/latest', { signal: AbortSignal.timeout(5000) })
422
- if (!res.ok) return null
473
+ if (!res.ok) return { latestVersion: null, error: `HTTP ${res.status}` }
423
474
  const data = await res.json()
424
- if (data.version && data.version !== LOCAL_VERSION) return data.version
425
- } catch {}
426
- return null
475
+ if (data.version && data.version !== LOCAL_VERSION) return { latestVersion: data.version, error: null }
476
+ return { latestVersion: null, error: null }
477
+ } catch (error) {
478
+ const message = error instanceof Error ? error.message : 'Unknown error'
479
+ return { latestVersion: null, error: message }
480
+ }
481
+ }
482
+
483
+ // πŸ“– checkForUpdate: Backward-compatible wrapper for startup update prompt.
484
+ async function checkForUpdate() {
485
+ const { latestVersion } = await checkForUpdateDetailed()
486
+ return latestVersion
427
487
  }
428
488
 
429
489
  function runUpdate(latestVersion) {
@@ -723,6 +783,16 @@ function calculateViewport(terminalRows, scrollOffset, totalModels) {
723
783
  return { startIdx: scrollOffset, endIdx, hasAbove, hasBelow }
724
784
  }
725
785
 
786
+ // πŸ“– Favorites are always pinned at the top and keep insertion order.
787
+ // πŸ“– Non-favorites still use the active sort column/direction.
788
+ function sortResultsWithPinnedFavorites(results, sortColumn, sortDirection) {
789
+ const favoriteRows = results
790
+ .filter((r) => r.isFavorite)
791
+ .sort((a, b) => a.favoriteRank - b.favoriteRank)
792
+ const nonFavoriteRows = sortResults(results.filter((r) => !r.isFavorite), sortColumn, sortDirection)
793
+ return [...favoriteRows, ...nonFavoriteRows]
794
+ }
795
+
726
796
  // πŸ“– renderTable: mode param controls footer hint text (opencode vs openclaw)
727
797
  function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, originFilterMode = 0) {
728
798
  // πŸ“– Filter out hidden models for display
@@ -790,7 +860,7 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
790
860
  const W_UPTIME = 6
791
861
 
792
862
  // πŸ“– Sort models using the shared helper
793
- const sorted = sortResults(visibleResults, sortColumn, sortDirection)
863
+ const sorted = sortResultsWithPinnedFavorites(visibleResults, sortColumn, sortDirection)
794
864
 
795
865
  const lines = [
796
866
  ` ${chalk.bold('⚑ Free Coding Models')} ${chalk.dim('v' + LOCAL_VERSION)}${modeBadge}${modeHint}${tierBadge}${originBadge} ` +
@@ -882,7 +952,10 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
882
952
  // πŸ“– Show provider name from sources map (NIM / Groq / Cerebras)
883
953
  const providerName = sources[r.providerKey]?.name ?? r.providerKey ?? 'NIM'
884
954
  const source = chalk.green(providerName.padEnd(W_SOURCE))
885
- const name = r.label.slice(0, W_MODEL).padEnd(W_MODEL)
955
+ // πŸ“– Favorites get a leading star in Model column.
956
+ const favoritePrefix = r.isFavorite ? '⭐ ' : ''
957
+ const nameWidth = Math.max(0, W_MODEL - favoritePrefix.length)
958
+ const name = favoritePrefix + r.label.slice(0, nameWidth).padEnd(nameWidth)
886
959
  const sweScore = r.sweScore ?? 'β€”'
887
960
  const sweCell = sweScore !== 'β€”' && parseFloat(sweScore) >= 50
888
961
  ? chalk.greenBright(sweScore.padEnd(W_SWE))
@@ -1012,8 +1085,12 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
1012
1085
  // πŸ“– Build row with double space between columns (order: Rank, Tier, SWE%, CTX, Model, Origin, Latest Ping, Avg Ping, Health, Verdict, Up%)
1013
1086
  const row = ' ' + num + ' ' + tier + ' ' + sweCell + ' ' + ctxCell + ' ' + name + ' ' + source + ' ' + pingCell + ' ' + avgCell + ' ' + status + ' ' + speedCell + ' ' + uptimeCell
1014
1087
 
1015
- if (isCursor) {
1088
+ if (isCursor && r.isFavorite) {
1089
+ lines.push(chalk.bgRgb(120, 60, 0)(row))
1090
+ } else if (isCursor) {
1016
1091
  lines.push(chalk.bgRgb(139, 0, 139)(row))
1092
+ } else if (r.isFavorite) {
1093
+ lines.push(chalk.bgRgb(90, 45, 0)(row))
1017
1094
  } else {
1018
1095
  lines.push(row)
1019
1096
  }
@@ -1032,7 +1109,7 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
1032
1109
  : mode === 'opencode-desktop'
1033
1110
  ? chalk.rgb(0, 200, 255)('Enterβ†’OpenDesktop')
1034
1111
  : chalk.rgb(0, 200, 255)('Enterβ†’OpenCode')
1035
- lines.push(chalk.dim(` ↑↓ Navigate β€’ `) + actionHint + chalk.dim(` β€’ R/Y/O/M/L/A/S/C/H/V/U Sort β€’ T Tier β€’ N Origin β€’ W↓/X↑ (${intervalSec}s) β€’ Z Mode β€’ `) + chalk.yellow('P') + chalk.dim(` Settings β€’ `) + chalk.bgGreenBright.black.bold(' K Help ') + chalk.dim(` β€’ Ctrl+C Exit`))
1112
+ lines.push(chalk.dim(` ↑↓ Navigate β€’ `) + actionHint + chalk.dim(` β€’ F Favorite β€’ R/Y/O/M/L/A/S/C/H/V/U Sort β€’ T Tier β€’ N Origin β€’ W↓/X↑ (${intervalSec}s) β€’ Z Mode β€’ `) + chalk.yellow('P') + chalk.dim(` Settings β€’ `) + chalk.bgGreenBright.black.bold(' K Help ') + chalk.dim(` β€’ Ctrl+C Exit`))
1036
1113
  lines.push('')
1037
1114
  lines.push(
1038
1115
  chalk.rgb(255, 150, 200)(' Made with πŸ’– & β˜• by \x1b]8;;https://github.com/vava-nessa\x1b\\vava-nessa\x1b]8;;\x1b\\') +
@@ -2119,6 +2196,7 @@ async function main() {
2119
2196
  // πŸ“– Load JSON config (auto-migrates old plain-text ~/.free-coding-models if needed)
2120
2197
  const config = loadConfig()
2121
2198
  ensureTelemetryConfig(config)
2199
+ ensureFavoritesConfig(config)
2122
2200
 
2123
2201
  // πŸ“– Check if any provider has a key β€” if not, run the first-time setup wizard
2124
2202
  const hasAnyKey = Object.keys(sources).some(pk => !!getApiKey(config, pk))
@@ -2202,6 +2280,7 @@ async function main() {
2202
2280
  httpCode: null,
2203
2281
  hidden: false, // πŸ“– Simple flag to hide/show models
2204
2282
  }))
2283
+ syncFavoriteFlags(results, config)
2205
2284
 
2206
2285
  // πŸ“– Clamp scrollOffset so cursor is always within the visible viewport window.
2207
2286
  // πŸ“– Called after every cursor move, sort change, and terminal resize.
@@ -2252,6 +2331,9 @@ async function main() {
2252
2331
  settingsEditMode: false, // πŸ“– Whether we're in inline key editing mode
2253
2332
  settingsEditBuffer: '', // πŸ“– Typed characters for the API key being edited
2254
2333
  settingsTestResults: {}, // πŸ“– { providerKey: 'pending'|'ok'|'fail'|null }
2334
+ settingsUpdateState: 'idle', // πŸ“– 'idle'|'checking'|'available'|'up-to-date'|'error'|'installing'
2335
+ settingsUpdateLatestVersion: null, // πŸ“– Latest npm version discovered from manual check
2336
+ settingsUpdateError: null, // πŸ“– Last update-check error message for maintenance row
2255
2337
  config, // πŸ“– Live reference to the config object (updated on save)
2256
2338
  visibleSorted: [], // πŸ“– Cached visible+sorted models β€” shared between render loop and key handlers
2257
2339
  helpVisible: false, // πŸ“– Whether the help overlay (K key) is active
@@ -2289,6 +2371,11 @@ async function main() {
2289
2371
  const activeTier = TIER_CYCLE[tierFilterMode]
2290
2372
  const activeOrigin = ORIGIN_CYCLE[originFilterMode]
2291
2373
  state.results.forEach(r => {
2374
+ // πŸ“– Favorites stay visible regardless of tier/origin filters.
2375
+ if (r.isFavorite) {
2376
+ r.hidden = false
2377
+ return
2378
+ }
2292
2379
  // πŸ“– Apply both tier and origin filters β€” model is hidden if it fails either
2293
2380
  const tierHide = activeTier !== null && r.tier !== activeTier
2294
2381
  const originHide = activeOrigin !== null && r.providerKey !== activeOrigin
@@ -2305,6 +2392,7 @@ async function main() {
2305
2392
  function renderSettings() {
2306
2393
  const providerKeys = Object.keys(sources)
2307
2394
  const telemetryRowIdx = providerKeys.length
2395
+ const updateRowIdx = providerKeys.length + 1
2308
2396
  const EL = '\x1b[K'
2309
2397
  const lines = []
2310
2398
 
@@ -2381,11 +2469,35 @@ async function main() {
2381
2469
  const telemetryRow = `${telemetryRowBullet}${chalk.bold('Anonymous usage analytics').padEnd(44)} ${telemetryStatus} ${telemetrySource}`
2382
2470
  lines.push(telemetryCursor ? chalk.bgRgb(30, 30, 60)(telemetryRow) : telemetryRow)
2383
2471
 
2472
+ lines.push('')
2473
+ lines.push(` ${chalk.bold('πŸ›  Maintenance')}`)
2474
+ lines.push(` ${chalk.dim(' ' + '─'.repeat(112))}`)
2475
+ lines.push('')
2476
+
2477
+ const updateCursor = state.settingsCursor === updateRowIdx
2478
+ const updateBullet = updateCursor ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
2479
+ const updateState = state.settingsUpdateState
2480
+ const latestFound = state.settingsUpdateLatestVersion
2481
+ const updateActionLabel = updateState === 'available' && latestFound
2482
+ ? `Install update (v${latestFound})`
2483
+ : 'Check for updates manually'
2484
+ let updateStatus = chalk.dim('Press Enter or U to check npm registry')
2485
+ if (updateState === 'checking') updateStatus = chalk.yellow('Checking npm registry…')
2486
+ if (updateState === 'available' && latestFound) updateStatus = chalk.greenBright(`Update available: v${latestFound} (Enter to install)`)
2487
+ if (updateState === 'up-to-date') updateStatus = chalk.green('Already on latest version')
2488
+ if (updateState === 'error') updateStatus = chalk.red('Check failed (press U to retry)')
2489
+ if (updateState === 'installing') updateStatus = chalk.cyan('Installing update…')
2490
+ const updateRow = `${updateBullet}${chalk.bold(updateActionLabel).padEnd(44)} ${updateStatus}`
2491
+ lines.push(updateCursor ? chalk.bgRgb(30, 30, 60)(updateRow) : updateRow)
2492
+ if (updateState === 'error' && state.settingsUpdateError) {
2493
+ lines.push(chalk.red(` ${state.settingsUpdateError}`))
2494
+ }
2495
+
2384
2496
  lines.push('')
2385
2497
  if (state.settingsEditMode) {
2386
2498
  lines.push(chalk.dim(' Type API key β€’ Enter Save β€’ Esc Cancel'))
2387
2499
  } else {
2388
- lines.push(chalk.dim(' ↑↓ Navigate β€’ Enter Edit key / Toggle analytics β€’ Space Toggle enabled β€’ T Test key β€’ Esc Close'))
2500
+ lines.push(chalk.dim(' ↑↓ Navigate β€’ Enter Edit key / Toggle analytics / Check-or-Install update β€’ Space Toggle enabled β€’ T Test key β€’ U Check updates β€’ Esc Close'))
2389
2501
  }
2390
2502
  lines.push('')
2391
2503
 
@@ -2404,6 +2516,7 @@ async function main() {
2404
2516
  lines.push('')
2405
2517
  lines.push(` ${chalk.bold('❓ Keyboard Shortcuts')} ${chalk.dim('β€” press K or Esc to close')}`)
2406
2518
  lines.push('')
2519
+ lines.push(` ${chalk.bold('Main TUI')}`)
2407
2520
  lines.push(` ${chalk.bold('Navigation')}`)
2408
2521
  lines.push(` ${chalk.yellow('↑↓')} Navigate rows`)
2409
2522
  lines.push(` ${chalk.yellow('Enter')} Select model and launch`)
@@ -2421,10 +2534,30 @@ async function main() {
2421
2534
  lines.push(` ${chalk.yellow('W')} Decrease ping interval (faster)`)
2422
2535
  lines.push(` ${chalk.yellow('X')} Increase ping interval (slower)`)
2423
2536
  lines.push(` ${chalk.yellow('Z')} Cycle launch mode ${chalk.dim('(OpenCode CLI β†’ OpenCode Desktop β†’ OpenClaw)')}`)
2424
- lines.push(` ${chalk.yellow('P')} Open settings ${chalk.dim('(manage API keys, provider toggles, analytics toggle)')}`)
2537
+ lines.push(` ${chalk.yellow('F')} Toggle favorite on selected row ${chalk.dim('(⭐ pinned at top, persisted)')}`)
2538
+ lines.push(` ${chalk.yellow('P')} Open settings ${chalk.dim('(manage API keys, provider toggles, analytics, manual update)')}`)
2425
2539
  lines.push(` ${chalk.yellow('K')} / ${chalk.yellow('Esc')} Show/hide this help`)
2426
2540
  lines.push(` ${chalk.yellow('Ctrl+C')} Exit`)
2427
2541
  lines.push('')
2542
+ lines.push(` ${chalk.bold('Settings (P)')}`)
2543
+ lines.push(` ${chalk.yellow('↑↓')} Navigate rows`)
2544
+ lines.push(` ${chalk.yellow('Enter')} Edit key / toggle analytics / check-install update`)
2545
+ lines.push(` ${chalk.yellow('Space')} Toggle provider enable/disable`)
2546
+ lines.push(` ${chalk.yellow('T')} Test selected provider key`)
2547
+ lines.push(` ${chalk.yellow('U')} Check updates manually`)
2548
+ lines.push(` ${chalk.yellow('Esc')} Close settings`)
2549
+ lines.push('')
2550
+ lines.push(` ${chalk.bold('CLI Flags')}`)
2551
+ lines.push(` ${chalk.dim('Usage: free-coding-models [options]')}`)
2552
+ lines.push(` ${chalk.cyan('free-coding-models --opencode')} ${chalk.dim('OpenCode CLI mode')}`)
2553
+ lines.push(` ${chalk.cyan('free-coding-models --opencode-desktop')} ${chalk.dim('OpenCode Desktop mode')}`)
2554
+ lines.push(` ${chalk.cyan('free-coding-models --openclaw')} ${chalk.dim('OpenClaw mode')}`)
2555
+ lines.push(` ${chalk.cyan('free-coding-models --best')} ${chalk.dim('Only top tiers (A+, S, S+)')}`)
2556
+ lines.push(` ${chalk.cyan('free-coding-models --fiable')} ${chalk.dim('10s reliability analysis')}`)
2557
+ lines.push(` ${chalk.cyan('free-coding-models --tier S|A|B|C')} ${chalk.dim('Filter by tier letter')}`)
2558
+ lines.push(` ${chalk.cyan('free-coding-models --no-telemetry')} ${chalk.dim('Disable telemetry for this run')}`)
2559
+ lines.push(` ${chalk.dim('Flags can be combined: --openclaw --tier S')}`)
2560
+ lines.push('')
2428
2561
  const cleared = lines.map(l => l + EL)
2429
2562
  const remaining = state.terminalRows > 0 ? Math.max(0, state.terminalRows - cleared.length) : 0
2430
2563
  for (let i = 0; i < remaining; i++) cleared.push(EL)
@@ -2448,11 +2581,47 @@ async function main() {
2448
2581
  state.settingsTestResults[providerKey] = code === '200' ? 'ok' : 'fail'
2449
2582
  }
2450
2583
 
2584
+ // πŸ“– Manual update checker from settings; keeps status visible in maintenance row.
2585
+ async function checkUpdatesFromSettings() {
2586
+ if (state.settingsUpdateState === 'checking' || state.settingsUpdateState === 'installing') return
2587
+ state.settingsUpdateState = 'checking'
2588
+ state.settingsUpdateError = null
2589
+ const { latestVersion, error } = await checkForUpdateDetailed()
2590
+ if (error) {
2591
+ state.settingsUpdateState = 'error'
2592
+ state.settingsUpdateLatestVersion = null
2593
+ state.settingsUpdateError = error
2594
+ return
2595
+ }
2596
+ if (latestVersion) {
2597
+ state.settingsUpdateState = 'available'
2598
+ state.settingsUpdateLatestVersion = latestVersion
2599
+ state.settingsUpdateError = null
2600
+ return
2601
+ }
2602
+ state.settingsUpdateState = 'up-to-date'
2603
+ state.settingsUpdateLatestVersion = null
2604
+ state.settingsUpdateError = null
2605
+ }
2606
+
2607
+ // πŸ“– Leaves TUI cleanly, then runs npm global update command.
2608
+ function launchUpdateFromSettings(latestVersion) {
2609
+ if (!latestVersion) return
2610
+ state.settingsUpdateState = 'installing'
2611
+ clearInterval(ticker)
2612
+ clearTimeout(state.pingIntervalObj)
2613
+ process.stdin.removeListener('keypress', onKeyPress)
2614
+ if (process.stdin.isTTY) process.stdin.setRawMode(false)
2615
+ process.stdin.pause()
2616
+ process.stdout.write(ALT_LEAVE)
2617
+ runUpdate(latestVersion)
2618
+ }
2619
+
2451
2620
  // Apply CLI --tier filter if provided
2452
2621
  if (cliArgs.tierFilter) {
2453
2622
  const allowed = TIER_LETTER_MAP[cliArgs.tierFilter]
2454
2623
  state.results.forEach(r => {
2455
- r.hidden = !allowed.includes(r.tier)
2624
+ r.hidden = r.isFavorite ? false : !allowed.includes(r.tier)
2456
2625
  })
2457
2626
  }
2458
2627
 
@@ -2476,6 +2645,7 @@ async function main() {
2476
2645
  if (state.settingsOpen) {
2477
2646
  const providerKeys = Object.keys(sources)
2478
2647
  const telemetryRowIdx = providerKeys.length
2648
+ const updateRowIdx = providerKeys.length + 1
2479
2649
 
2480
2650
  // πŸ“– Edit mode: capture typed characters for the API key
2481
2651
  if (state.settingsEditMode) {
@@ -2518,6 +2688,11 @@ async function main() {
2518
2688
  // πŸ“– Re-index results
2519
2689
  results.forEach((r, i) => { r.idx = i + 1 })
2520
2690
  state.results = results
2691
+ syncFavoriteFlags(state.results, state.config)
2692
+ applyTierFilter()
2693
+ const visible = state.results.filter(r => !r.hidden)
2694
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
2695
+ if (state.cursor >= state.visibleSorted.length) state.cursor = Math.max(0, state.visibleSorted.length - 1)
2521
2696
  adjustScrollOffset(state)
2522
2697
  // πŸ“– Re-ping all models that were 'noauth' (got 401 without key) but now have a key
2523
2698
  // πŸ“– This makes the TUI react immediately when a user adds an API key in settings
@@ -2537,7 +2712,7 @@ async function main() {
2537
2712
  return
2538
2713
  }
2539
2714
 
2540
- if (key.name === 'down' && state.settingsCursor < telemetryRowIdx) {
2715
+ if (key.name === 'down' && state.settingsCursor < updateRowIdx) {
2541
2716
  state.settingsCursor++
2542
2717
  return
2543
2718
  }
@@ -2550,6 +2725,14 @@ async function main() {
2550
2725
  saveConfig(state.config)
2551
2726
  return
2552
2727
  }
2728
+ if (state.settingsCursor === updateRowIdx) {
2729
+ if (state.settingsUpdateState === 'available' && state.settingsUpdateLatestVersion) {
2730
+ launchUpdateFromSettings(state.settingsUpdateLatestVersion)
2731
+ return
2732
+ }
2733
+ checkUpdatesFromSettings()
2734
+ return
2735
+ }
2553
2736
 
2554
2737
  // πŸ“– Enter edit mode for the selected provider's key
2555
2738
  const pk = providerKeys[state.settingsCursor]
@@ -2566,6 +2749,7 @@ async function main() {
2566
2749
  saveConfig(state.config)
2567
2750
  return
2568
2751
  }
2752
+ if (state.settingsCursor === updateRowIdx) return
2569
2753
 
2570
2754
  // πŸ“– Toggle enabled/disabled for selected provider
2571
2755
  const pk = providerKeys[state.settingsCursor]
@@ -2577,7 +2761,7 @@ async function main() {
2577
2761
  }
2578
2762
 
2579
2763
  if (key.name === 't') {
2580
- if (state.settingsCursor === telemetryRowIdx) return
2764
+ if (state.settingsCursor === telemetryRowIdx || state.settingsCursor === updateRowIdx) return
2581
2765
 
2582
2766
  // πŸ“– Test the selected provider's key (fires a real ping)
2583
2767
  const pk = providerKeys[state.settingsCursor]
@@ -2585,6 +2769,11 @@ async function main() {
2585
2769
  return
2586
2770
  }
2587
2771
 
2772
+ if (key.name === 'u') {
2773
+ checkUpdatesFromSettings()
2774
+ return
2775
+ }
2776
+
2588
2777
  if (key.ctrl && key.name === 'c') { exit(0); return }
2589
2778
  return // πŸ“– Swallow all other keys while settings is open
2590
2779
  }
@@ -2617,12 +2806,29 @@ async function main() {
2617
2806
  }
2618
2807
  // πŸ“– Recompute visible sorted list and reset cursor to top to avoid stale index
2619
2808
  const visible = state.results.filter(r => !r.hidden)
2620
- state.visibleSorted = sortResults(visible, state.sortColumn, state.sortDirection)
2809
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
2621
2810
  state.cursor = 0
2622
2811
  state.scrollOffset = 0
2623
2812
  return
2624
2813
  }
2625
2814
 
2815
+ // πŸ“– F key: toggle favorite on the currently selected row and persist to config.
2816
+ if (key.name === 'f') {
2817
+ const selected = state.visibleSorted[state.cursor]
2818
+ if (!selected) return
2819
+ toggleFavoriteModel(state.config, selected.providerKey, selected.modelId)
2820
+ syncFavoriteFlags(state.results, state.config)
2821
+ applyTierFilter()
2822
+ const selectedKey = toFavoriteKey(selected.providerKey, selected.modelId)
2823
+ const visible = state.results.filter(r => !r.hidden)
2824
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
2825
+ const newCursor = state.visibleSorted.findIndex(r => toFavoriteKey(r.providerKey, r.modelId) === selectedKey)
2826
+ if (newCursor >= 0) state.cursor = newCursor
2827
+ else if (state.cursor >= state.visibleSorted.length) state.cursor = Math.max(0, state.visibleSorted.length - 1)
2828
+ adjustScrollOffset(state)
2829
+ return
2830
+ }
2831
+
2626
2832
  // πŸ“– Interval adjustment keys: W=decrease (faster), X=increase (slower)
2627
2833
  // πŸ“– Minimum 1s, maximum 60s
2628
2834
  if (key.name === 'w') {
@@ -2637,7 +2843,7 @@ async function main() {
2637
2843
  applyTierFilter()
2638
2844
  // πŸ“– Recompute visible sorted list and reset cursor to avoid stale index into new filtered set
2639
2845
  const visible = state.results.filter(r => !r.hidden)
2640
- state.visibleSorted = sortResults(visible, state.sortColumn, state.sortDirection)
2846
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
2641
2847
  state.cursor = 0
2642
2848
  state.scrollOffset = 0
2643
2849
  return
@@ -2649,7 +2855,7 @@ async function main() {
2649
2855
  applyTierFilter()
2650
2856
  // πŸ“– Recompute visible sorted list and reset cursor to avoid stale index into new filtered set
2651
2857
  const visible = state.results.filter(r => !r.hidden)
2652
- state.visibleSorted = sortResults(visible, state.sortColumn, state.sortDirection)
2858
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
2653
2859
  state.cursor = 0
2654
2860
  state.scrollOffset = 0
2655
2861
  return
@@ -2759,7 +2965,7 @@ async function main() {
2759
2965
  // πŸ“– Cache visible+sorted models each frame so Enter handler always matches the display
2760
2966
  if (!state.settingsOpen) {
2761
2967
  const visible = state.results.filter(r => !r.hidden)
2762
- state.visibleSorted = sortResults(visible, state.sortColumn, state.sortDirection)
2968
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
2763
2969
  }
2764
2970
  const content = state.settingsOpen
2765
2971
  ? renderSettings()
@@ -2771,7 +2977,7 @@ async function main() {
2771
2977
 
2772
2978
  // πŸ“– Populate visibleSorted before the first frame so Enter works immediately
2773
2979
  const initialVisible = state.results.filter(r => !r.hidden)
2774
- state.visibleSorted = sortResults(initialVisible, state.sortColumn, state.sortDirection)
2980
+ state.visibleSorted = sortResultsWithPinnedFavorites(initialVisible, state.sortColumn, state.sortDirection)
2775
2981
 
2776
2982
  process.stdout.write(ALT_HOME + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, tierFilterMode, state.scrollOffset, state.terminalRows, originFilterMode))
2777
2983
 
package/lib/config.js CHANGED
@@ -41,6 +41,9 @@
41
41
  * "scaleway": { "enabled": true },
42
42
  * "googleai": { "enabled": true }
43
43
  * },
44
+ * "favorites": [
45
+ * "nvidia/deepseek-ai/deepseek-v3.2"
46
+ * },
44
47
  * "telemetry": {
45
48
  * "enabled": true,
46
49
  * "consentVersion": 1,
@@ -56,6 +59,7 @@
56
59
  * β†’ loadConfig() β€” Read ~/.free-coding-models.json; auto-migrate old plain-text config if needed
57
60
  * β†’ saveConfig(config) β€” Write config to ~/.free-coding-models.json with 0o600 permissions
58
61
  * β†’ getApiKey(config, providerKey) β€” Get effective API key (env var override > config > null)
62
+ * β†’ isProviderEnabled(config, providerKey) β€” Check if provider is enabled (defaults true)
59
63
  *
60
64
  * @exports loadConfig, saveConfig, getApiKey
61
65
  * @exports CONFIG_PATH β€” path to the JSON config file
@@ -103,7 +107,7 @@ const ENV_VARS = {
103
107
  * πŸ“– The migration reads the old file as a plain nvidia API key and writes
104
108
  * a proper JSON config. The old file is NOT deleted (safety first).
105
109
  *
106
- * @returns {{ apiKeys: Record<string,string>, providers: Record<string,{enabled:boolean}>, telemetry: { enabled: boolean | null, consentVersion: number, anonymousId: string | null } }}
110
+ * @returns {{ apiKeys: Record<string,string>, providers: Record<string,{enabled:boolean}>, favorites: string[], telemetry: { enabled: boolean | null, consentVersion: number, anonymousId: string | null } }}
107
111
  */
108
112
  export function loadConfig() {
109
113
  // πŸ“– Try new JSON config first
@@ -114,6 +118,9 @@ export function loadConfig() {
114
118
  // πŸ“– Ensure the shape is always complete β€” fill missing sections with defaults
115
119
  if (!parsed.apiKeys) parsed.apiKeys = {}
116
120
  if (!parsed.providers) parsed.providers = {}
121
+ // πŸ“– Favorites: list of "providerKey/modelId" pinned rows.
122
+ if (!Array.isArray(parsed.favorites)) parsed.favorites = []
123
+ parsed.favorites = parsed.favorites.filter((fav) => typeof fav === 'string' && fav.trim().length > 0)
117
124
  if (!parsed.telemetry || typeof parsed.telemetry !== 'object') parsed.telemetry = { enabled: null, consentVersion: 0, anonymousId: null }
118
125
  if (typeof parsed.telemetry.enabled !== 'boolean') parsed.telemetry.enabled = null
119
126
  if (typeof parsed.telemetry.consentVersion !== 'number') parsed.telemetry.consentVersion = 0
@@ -150,7 +157,7 @@ export function loadConfig() {
150
157
  * πŸ“– Uses mode 0o600 so the file is only readable by the owning user (API keys!).
151
158
  * πŸ“– Pretty-prints JSON for human readability.
152
159
  *
153
- * @param {{ apiKeys: Record<string,string>, providers: Record<string,{enabled:boolean}>, telemetry?: { enabled?: boolean | null, consentVersion?: number, anonymousId?: string | null } }} config
160
+ * @param {{ apiKeys: Record<string,string>, providers: Record<string,{enabled:boolean}>, favorites?: string[], telemetry?: { enabled?: boolean | null, consentVersion?: number, anonymousId?: string | null } }} config
154
161
  */
155
162
  export function saveConfig(config) {
156
163
  try {
@@ -208,6 +215,8 @@ function _emptyConfig() {
208
215
  return {
209
216
  apiKeys: {},
210
217
  providers: {},
218
+ // πŸ“– Pinned favorites rendered at top of the table ("providerKey/modelId").
219
+ favorites: [],
211
220
  // πŸ“– Telemetry consent is explicit. null = not decided yet.
212
221
  telemetry: {
213
222
  enabled: null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.1.64",
3
+ "version": "0.1.65",
4
4
  "description": "Find the fastest coding LLM models in seconds β€” ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
5
5
  "keywords": [
6
6
  "nvidia",