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 +17 -5
- package/bin/free-coding-models.js +226 -20
- package/lib/config.js +11 -2
- package/package.json +1 -1
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,
|
|
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
|
|
649
|
-
- **Enter** β Edit API key inline,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
426
|
-
|
|
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 =
|
|
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
|
-
|
|
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('
|
|
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 <
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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.
|
|
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",
|