free-coding-models 0.1.85 β†’ 0.1.86

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
@@ -2,7 +2,7 @@
2
2
  <img src="https://img.shields.io/npm/v/free-coding-models?color=76b900&label=npm&logo=npm" alt="npm version">
3
3
  <img src="https://img.shields.io/node/v/free-coding-models?color=76b900&logo=node.js" alt="node version">
4
4
  <img src="https://img.shields.io/npm/l/free-coding-models?color=76b900" alt="license">
5
- <img src="https://img.shields.io/badge/models-158-76b900?logo=nvidia" alt="models count">
5
+ <img src="https://img.shields.io/badge/models-159-76b900?logo=nvidia" alt="models count">
6
6
  <img src="https://img.shields.io/badge/providers-20-blue" alt="providers count">
7
7
  </p>
8
8
 
@@ -90,7 +90,9 @@
90
90
  - **πŸ“Ά Status indicators** β€” UP βœ… Β· No Key πŸ”‘ Β· Timeout ⏳ Β· Overloaded πŸ”₯ Β· Not Found 🚫
91
91
  - **πŸ” Keyless latency** β€” Models are pinged even without an API key
92
92
  - **🏷 Tier filtering** β€” Filter models by tier letter (S, A, B, C)
93
- - **⭐ Persistent favorites** β€” Press `F` on a selected row to pin/unpin it
93
+ - **⭐ Persistent favorites** β€” Press `F` on a selected row to pin/unpin it
94
+ - **πŸ™ˆ Configured-only by default** β€” Press `E` to toggle showing only providers with configured API keys; the choice persists across sessions and profiles
95
+ - **πŸͺŸ Width guardrail** β€” If your terminal is too narrow, the TUI shows a centered warning instead of rendering a broken table
94
96
 
95
97
  ---
96
98
 
@@ -235,7 +237,7 @@ Use `↑↓` arrows to select, `Enter` to confirm. Then the TUI launches with yo
235
237
  You can add or change keys anytime with the P key in the TUI.
236
238
  ```
237
239
 
238
- You don't need all seventeen β€” skip any provider by pressing Enter. At least one key is required.
240
+ You don't need all twenty providers β€” skip any provider by pressing Enter. At least one key is required.
239
241
 
240
242
  ### Adding or changing keys later
241
243
 
@@ -269,6 +271,7 @@ Press **`P`** to open the Settings screen at any time:
269
271
 
270
272
  Manual update is in the same Settings screen (`P`) under **Maintenance** (Enter to check, Enter again to install when an update is available).
271
273
  Favorites are also persisted in the same config file and survive restarts.
274
+ The main table now starts in `Configured Only` mode, so if nothing is set up yet you can press `P` and add your first API key immediately.
272
275
 
273
276
  ### Environment variable overrides
274
277
 
@@ -376,7 +379,7 @@ TOGETHER_API_KEY=together_xxx free-coding-models
376
379
 
377
380
  ## πŸ€– Coding Models
378
381
 
379
- **158 coding models** across 20 providers and 8 tiers, ranked by [SWE-bench Verified](https://www.swebench.com) β€” the industry-standard benchmark measuring real GitHub issue resolution. Scores are self-reported by providers unless noted.
382
+ **159 coding models** across 20 providers and 8 tiers, ranked by [SWE-bench Verified](https://www.swebench.com) β€” the industry-standard benchmark measuring real GitHub issue resolution. Scores are self-reported by providers unless noted.
380
383
 
381
384
  ### Alibaba Cloud (DashScope) (8 models)
382
385
 
@@ -733,7 +736,7 @@ This script:
733
736
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
734
737
  ```
735
738
 
736
- **Result:** Continuous monitoring interface that stays open until you select a model or press Ctrl+C. Rolling averages give you accurate long-term latency data, the stability score reveals which models are truly consistent vs. deceptively spikey, and you can configure your tool of choice with one keystroke.
739
+ **Result:** Continuous monitoring interface that stays open until you select a model or press Ctrl+C. Rolling averages give you accurate long-term latency data, the stability score reveals which models are truly consistent vs. deceptively spikey, and you can configure your tool of choice with one keystroke. If the terminal is too narrow, the app shows a centered warning instead of a truncated table.
737
740
 
738
741
  ---
739
742
 
@@ -795,6 +798,9 @@ This script:
795
798
  "perplexity": { "enabled": true },
796
799
  "zai": { "enabled": true }
797
800
  },
801
+ "settings": {
802
+ "hideUnconfiguredModels": true
803
+ },
798
804
  "favorites": [
799
805
  "nvidia/deepseek-ai/deepseek-v3.2"
800
806
  ]
@@ -830,6 +836,7 @@ This script:
830
836
  - **F** β€” Toggle favorite on selected model (⭐ in Model column, pinned at top)
831
837
  - **T** β€” Cycle tier filter (All β†’ S+ β†’ S β†’ A+ β†’ A β†’ A- β†’ B+ β†’ B β†’ C β†’ All)
832
838
  - **D** β€” Cycle provider filter (All β†’ NIM β†’ Groq β†’ ...)
839
+ - **E** β€” Toggle configured-only mode (on by default, persisted across sessions and profiles)
833
840
  - **Z** β€” Cycle mode (OpenCode CLI β†’ OpenCode Desktop β†’ OpenClaw)
834
841
  - **X** β€” **Toggle Token Logs** (view recent request/token usage logs)
835
842
  - **P** β€” Open Settings (manage API keys, toggles, updates, profiles)
@@ -177,9 +177,10 @@ async function main() {
177
177
  ensureFavoritesConfig(config)
178
178
 
179
179
  // πŸ“– If --profile <name> was passed, load that profile into the live config
180
+ let startupProfileSettings = null
180
181
  if (cliArgs.profileName) {
181
- const profileSettings = loadProfile(config, cliArgs.profileName)
182
- if (!profileSettings) {
182
+ startupProfileSettings = loadProfile(config, cliArgs.profileName)
183
+ if (!startupProfileSettings) {
183
184
  console.error(chalk.red(` Unknown profile "${cliArgs.profileName}". Available: ${listProfiles(config).join(', ') || '(none)'}`))
184
185
  process.exit(1)
185
186
  }
@@ -336,8 +337,8 @@ async function main() {
336
337
  frame: 0,
337
338
  cursor: 0,
338
339
  selectedModel: null,
339
- sortColumn: 'avg',
340
- sortDirection: 'asc',
340
+ sortColumn: startupProfileSettings?.sortColumn || 'avg',
341
+ sortDirection: startupProfileSettings?.sortAsc === false ? 'desc' : 'asc',
341
342
  pingInterval: PING_MODE_INTERVALS.speed, // πŸ“– Effective live interval derived from the active ping mode.
342
343
  pingMode: 'speed', // πŸ“– Current ping mode: speed | normal | slow | forced.
343
344
  pingModeSource: 'startup', // πŸ“– Why this mode is active: startup | manual | auto | idle | activity.
@@ -348,8 +349,10 @@ async function main() {
348
349
  mode, // πŸ“– 'opencode' or 'openclaw' β€” controls Enter action
349
350
  tierFilterMode: 0, // πŸ“– Index into TIER_CYCLE (0=All, 1=S+, 2=S, ...)
350
351
  originFilterMode: 0, // πŸ“– Index into ORIGIN_CYCLE (0=All, then providers)
352
+ hideUnconfiguredModels: startupProfileSettings?.hideUnconfiguredModels === true || config.settings?.hideUnconfiguredModels === true, // πŸ“– Hide providers with no configured API key when true.
351
353
  scrollOffset: 0, // πŸ“– First visible model index in viewport
352
354
  terminalRows: process.stdout.rows || 24, // πŸ“– Current terminal height
355
+ terminalCols: process.stdout.columns || 80, // πŸ“– Current terminal width
353
356
  // πŸ“– Settings screen state (P key opens it)
354
357
  settingsOpen: false, // πŸ“– Whether settings overlay is active
355
358
  settingsCursor: 0, // πŸ“– Which provider row is selected in settings
@@ -408,6 +411,7 @@ async function main() {
408
411
  // πŸ“– Re-clamp viewport on terminal resize
409
412
  process.stdout.on('resize', () => {
410
413
  state.terminalRows = process.stdout.rows || 24
414
+ state.terminalCols = process.stdout.columns || 80
411
415
  adjustScrollOffset(state)
412
416
  })
413
417
 
@@ -479,13 +483,18 @@ async function main() {
479
483
 
480
484
  // πŸ“– originFilterMode: index into ORIGIN_CYCLE, 0=All, then each provider key in order
481
485
  const ORIGIN_CYCLE = [null, ...Object.keys(sources)]
482
- state.tierFilterMode = 0
486
+ state.tierFilterMode = startupProfileSettings?.tierFilter ? Math.max(0, TIER_CYCLE.indexOf(startupProfileSettings.tierFilter)) : 0
483
487
  state.originFilterMode = 0
484
488
 
485
489
  function applyTierFilter() {
486
490
  const activeTier = TIER_CYCLE[state.tierFilterMode]
487
491
  const activeOrigin = ORIGIN_CYCLE[state.originFilterMode]
488
492
  state.results.forEach(r => {
493
+ const unconfiguredHide = state.hideUnconfiguredModels && !getApiKey(state.config, r.providerKey)
494
+ if (unconfiguredHide) {
495
+ r.hidden = true
496
+ return
497
+ }
489
498
  // πŸ“– Favorites stay visible regardless of tier/origin filters.
490
499
  if (r.isFavorite) {
491
500
  r.hidden = false
@@ -643,7 +652,7 @@ async function main() {
643
652
  ? overlays.renderHelp()
644
653
  : state.logVisible
645
654
  ? overlays.renderLog()
646
- : 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.originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus, state.pingMode, state.pingModeSource)
655
+ : 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, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels)
647
656
  process.stdout.write(ALT_HOME + content)
648
657
  }, Math.round(1000 / FPS))
649
658
 
@@ -651,7 +660,7 @@ async function main() {
651
660
  const initialVisible = state.results.filter(r => !r.hidden)
652
661
  state.visibleSorted = sortResultsWithPinnedFavorites(initialVisible, state.sortColumn, state.sortDirection)
653
662
 
654
- 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.originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus, state.pingMode, state.pingModeSource))
663
+ 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, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels))
655
664
 
656
665
  // πŸ“– If --recommend was passed, auto-open the Smart Recommend overlay on start
657
666
  if (cliArgs.recommendMode) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.1.85",
3
+ "version": "0.1.86",
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",
package/sources.js CHANGED
@@ -12,6 +12,8 @@
12
12
  * - ctx: Context window size in tokens (e.g., "128k", "32k")
13
13
  *
14
14
  * Add new sources here to support additional providers beyond NIM.
15
+ * Public provider catalogs drift often, so these IDs are periodically
16
+ * refreshed against official docs and live model endpoints when available.
15
17
  *
16
18
  * 🎯 Tier scale (based on SWE-bench Verified):
17
19
  * - S+: 70%+ (elite frontier coders)
@@ -114,9 +116,9 @@ export const cerebras = [
114
116
  ['llama-4-scout-17b-16e-instruct', 'Llama 4 Scout', 'A', '44.0%', '10M'],
115
117
  ['qwen-3-32b', 'Qwen3 32B', 'A+', '50.0%', '128k'],
116
118
  ['gpt-oss-120b', 'GPT OSS 120B', 'S', '60.0%', '128k'],
117
- ['qwen-3-235b-a22b', 'Qwen3 235B', 'S+', '70.0%', '128k'],
119
+ ['qwen-3-235b-a22b-instruct-2507', 'Qwen3 235B', 'S+', '70.0%', '128k'],
118
120
  ['llama3.1-8b', 'Llama 3.1 8B', 'B', '28.8%', '128k'],
119
- ['glm-4.6', 'GLM 4.6', 'A-', '38.0%', '128k'],
121
+ ['zai-glm-4.7', 'GLM 4.7', 'S+', '73.8%', '200k'],
120
122
  ]
121
123
 
122
124
  // πŸ“– SambaNova source - https://cloud.sambanova.ai
@@ -124,14 +126,15 @@ export const cerebras = [
124
126
  // πŸ“– OpenAI-compatible API, supports all major coding models including DeepSeek V3/R1, Qwen3, Llama 4
125
127
  export const sambanova = [
126
128
  // ── S+ tier ──
127
- ['Qwen3-235B-A22B-Instruct-2507', 'Qwen3 235B', 'S+', '70.0%', '128k'],
129
+ ['MiniMax-M2.5', 'MiniMax M2.5', 'S+', '74.0%', '160k'],
128
130
  // ── S tier ──
129
131
  ['DeepSeek-R1-0528', 'DeepSeek R1 0528', 'S', '61.0%', '128k'],
130
132
  ['DeepSeek-V3.1', 'DeepSeek V3.1', 'S', '62.0%', '128k'],
131
133
  ['DeepSeek-V3-0324', 'DeepSeek V3 0324', 'S', '62.0%', '128k'],
134
+ ['DeepSeek-V3.2', 'DeepSeek V3.2', 'S+', '73.1%', '8k'],
132
135
  ['Llama-4-Maverick-17B-128E-Instruct', 'Llama 4 Maverick', 'S', '62.0%', '1M'],
133
136
  ['gpt-oss-120b', 'GPT OSS 120B', 'S', '60.0%', '128k'],
134
- ['deepseek-ai/DeepSeek-V3.1-Terminus', 'DeepSeek V3.1 Term', 'S', '68.4%', '128k'],
137
+ ['DeepSeek-V3.1-Terminus', 'DeepSeek V3.1 Term', 'S', '68.4%', '128k'],
135
138
  // ── A+ tier ──
136
139
  ['Qwen3-32B', 'Qwen3 32B', 'A+', '50.0%', '128k'],
137
140
  // ── A tier ──
@@ -140,24 +143,23 @@ export const sambanova = [
140
143
  ['Meta-Llama-3.3-70B-Instruct', 'Llama 3.3 70B', 'A-', '39.5%', '128k'],
141
144
  // ── B tier ──
142
145
  ['Meta-Llama-3.1-8B-Instruct', 'Llama 3.1 8B', 'B', '28.8%', '128k'],
143
- // ── A tier β€” requested Llama3-Groq coding tuned family ──
144
- ['Llama-3-Groq-70B-Tool-Use', 'Llama3-Groq 70B', 'A', '43.0%', '128k'],
145
146
  ]
146
147
 
147
148
  // πŸ“– OpenRouter source - https://openrouter.ai
148
149
  // πŸ“– Free :free models with shared quota β€” 50 free req/day
149
150
  // πŸ“– API keys at https://openrouter.ai/keys
150
151
  export const openrouter = [
151
- ['qwen/qwen3-coder:480b-free', 'Qwen3 Coder 480B', 'S+', '70.6%', '256k'],
152
- ['mistralai/devstral-2-free', 'Devstral 2', 'S+', '72.2%', '256k'],
153
- ['mimo-v2-flash-free', 'Mimo V2 Flash', 'A', '45.0%', '128k'],
152
+ ['qwen/qwen3-coder:free', 'Qwen3 Coder 480B', 'S+', '70.6%', '262k'],
153
+ ['z-ai/glm-4.5-air:free', 'GLM 4.5 Air', 'S+', '72.0%', '128k'],
154
+ ['google/gemma-3-27b-it:free', 'Gemma 3 27B', 'B', '22.0%', '128k'],
154
155
  ['stepfun/step-3.5-flash:free', 'Step 3.5 Flash', 'S+', '74.4%', '256k'],
155
- ['deepseek/deepseek-r1-0528:free', 'DeepSeek R1 0528', 'S', '61.0%', '128k'],
156
156
  ['qwen/qwen3-next-80b-a3b-instruct:free', 'Qwen3 80B Instruct', 'S', '65.0%', '128k'],
157
157
  ['openai/gpt-oss-120b:free', 'GPT OSS 120B', 'S', '60.0%', '128k'],
158
158
  ['openai/gpt-oss-20b:free', 'GPT OSS 20B', 'A', '42.0%', '128k'],
159
159
  ['nvidia/nemotron-3-nano-30b-a3b:free', 'Nemotron Nano 30B', 'A', '43.0%', '128k'],
160
160
  ['meta-llama/llama-3.3-70b-instruct:free', 'Llama 3.3 70B', 'A-', '39.5%', '128k'],
161
+ ['mistralai/mistral-small-3.1-24b-instruct:free', 'Mistral Small 3.1', 'B+', '30.0%', '128k'],
162
+ ['google/gemma-3-12b-it:free', 'Gemma 3 12B', 'C', '15.0%', '128k'],
161
163
  ]
162
164
 
163
165
  // πŸ“– Hugging Face Inference source - https://huggingface.co
package/src/config.js CHANGED
@@ -71,7 +71,7 @@
71
71
  * - apiKeys: API keys per provider (can differ between work/personal setups)
72
72
  * - providers: enabled/disabled state per provider
73
73
  * - favorites: list of pinned favorite models
74
- * - settings: extra TUI preferences (tierFilter, sortColumn, sortAsc, pingInterval)
74
+ * - settings: extra TUI preferences (tierFilter, sortColumn, sortAsc, pingInterval, hideUnconfiguredModels)
75
75
  *
76
76
  * πŸ“– When a profile is loaded via --profile <name> or Shift+P, the main config's
77
77
  * apiKeys/providers/favorites are replaced with the profile's values. The profile
@@ -164,6 +164,8 @@ export function loadConfig() {
164
164
  // πŸ“– Ensure the shape is always complete β€” fill missing sections with defaults
165
165
  if (!parsed.apiKeys) parsed.apiKeys = {}
166
166
  if (!parsed.providers) parsed.providers = {}
167
+ if (!parsed.settings || typeof parsed.settings !== 'object') parsed.settings = {}
168
+ if (typeof parsed.settings.hideUnconfiguredModels !== 'boolean') parsed.settings.hideUnconfiguredModels = true
167
169
  // πŸ“– Favorites: list of "providerKey/modelId" pinned rows.
168
170
  if (!Array.isArray(parsed.favorites)) parsed.favorites = []
169
171
  parsed.favorites = parsed.favorites.filter((fav) => typeof fav === 'string' && fav.trim().length > 0)
@@ -173,6 +175,10 @@ export function loadConfig() {
173
175
  if (typeof parsed.telemetry.anonymousId !== 'string' || !parsed.telemetry.anonymousId.trim()) parsed.telemetry.anonymousId = null
174
176
  // πŸ“– Ensure profiles section exists (added in profile system)
175
177
  if (!parsed.profiles || typeof parsed.profiles !== 'object') parsed.profiles = {}
178
+ for (const profile of Object.values(parsed.profiles)) {
179
+ if (!profile || typeof profile !== 'object') continue
180
+ profile.settings = profile.settings ? { ..._emptyProfileSettings(), ...profile.settings } : _emptyProfileSettings()
181
+ }
176
182
  if (parsed.activeProfile && typeof parsed.activeProfile !== 'string') parsed.activeProfile = null
177
183
  return parsed
178
184
  } catch {
@@ -385,7 +391,7 @@ export function isProviderEnabled(config, providerKey) {
385
391
  * πŸ“– These settings are saved/restored when switching profiles so each profile
386
392
  * can have different sort, filter, and ping preferences.
387
393
  *
388
- * @returns {{ tierFilter: string|null, sortColumn: string, sortAsc: boolean, pingInterval: number }}
394
+ * @returns {{ tierFilter: string|null, sortColumn: string, sortAsc: boolean, pingInterval: number, hideUnconfiguredModels: boolean }}
389
395
  */
390
396
  export function _emptyProfileSettings() {
391
397
  return {
@@ -393,6 +399,7 @@ export function _emptyProfileSettings() {
393
399
  sortColumn: 'avg', // πŸ“– default sort column
394
400
  sortAsc: true, // πŸ“– true = ascending (fastest first for latency)
395
401
  pingInterval: 10000, // πŸ“– default ms between pings in the steady "normal" mode
402
+ hideUnconfiguredModels: true, // πŸ“– true = default to providers that are actually configured
396
403
  }
397
404
  }
398
405
 
@@ -505,6 +512,10 @@ function _emptyConfig() {
505
512
  return {
506
513
  apiKeys: {},
507
514
  providers: {},
515
+ // πŸ“– Global TUI preferences that should persist even without a named profile.
516
+ settings: {
517
+ hideUnconfiguredModels: true,
518
+ },
508
519
  // πŸ“– Pinned favorites rendered at top of the table ("providerKey/modelId").
509
520
  favorites: [],
510
521
  // πŸ“– Telemetry consent is explicit. null = not decided yet.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file key-handler.js
3
- * @description Factory for the main TUI keypress handler.
3
+ * @description Factory for the main TUI keypress handler and provider key-test model selection.
4
4
  *
5
5
  * @details
6
6
  * This module encapsulates the full onKeyPress switch used by the TUI,
@@ -8,11 +8,93 @@
8
8
  * OpenCode/OpenClaw launch actions. It also keeps the live key bindings
9
9
  * aligned with the highlighted letters shown in the table headers.
10
10
  *
11
+ * It also owns the "test key" model selection used by the Settings overlay.
12
+ * Some providers expose models in `/v1/models` that are not actually callable
13
+ * on the chat-completions endpoint. To avoid false negatives when a user
14
+ * presses `T` in Settings, the helpers below discover candidate model IDs,
15
+ * merge them with repo defaults, then probe several until one is accepted.
16
+ *
11
17
  * β†’ Functions:
18
+ * - `buildProviderModelsUrl` β€” derive the matching `/models` endpoint when available
19
+ * - `parseProviderModelIds` β€” extract model ids from an OpenAI-style `/models` payload
20
+ * - `listProviderTestModels` β€” build an ordered candidate list for provider key verification
21
+ * - `classifyProviderTestOutcome` β€” convert attempted HTTP codes into a settings badge state
12
22
  * - `createKeyHandler` β€” returns the async keypress handler
13
23
  *
14
- * @exports { createKeyHandler }
24
+ * @exports { buildProviderModelsUrl, parseProviderModelIds, listProviderTestModels, classifyProviderTestOutcome, createKeyHandler }
25
+ */
26
+
27
+ // πŸ“– Some providers need an explicit probe model because the first catalog entry
28
+ // πŸ“– is not guaranteed to be accepted by their chat endpoint.
29
+ const PROVIDER_TEST_MODEL_OVERRIDES = {
30
+ sambanova: ['DeepSeek-V3-0324'],
31
+ nvidia: ['deepseek-ai/deepseek-v3.1-terminus', 'openai/gpt-oss-120b'],
32
+ }
33
+
34
+ /**
35
+ * πŸ“– buildProviderModelsUrl derives the matching `/models` endpoint for providers
36
+ * πŸ“– that expose an OpenAI-compatible model list next to `/chat/completions`.
37
+ * @param {string} url
38
+ * @returns {string|null}
15
39
  */
40
+ export function buildProviderModelsUrl(url) {
41
+ if (typeof url !== 'string' || !url.includes('/chat/completions')) return null
42
+ return url.replace(/\/chat\/completions$/, '/models')
43
+ }
44
+
45
+ /**
46
+ * πŸ“– parseProviderModelIds extracts ids from a standard OpenAI-style `/models` response.
47
+ * πŸ“– Invalid payloads return an empty list so the key-test flow can safely fall back.
48
+ * @param {unknown} data
49
+ * @returns {string[]}
50
+ */
51
+ export function parseProviderModelIds(data) {
52
+ if (!data || typeof data !== 'object' || !Array.isArray(data.data)) return []
53
+ return data.data
54
+ .map(entry => (entry && typeof entry.id === 'string') ? entry.id.trim() : '')
55
+ .filter(Boolean)
56
+ }
57
+
58
+ /**
59
+ * πŸ“– listProviderTestModels builds the ordered probe list used by the Settings `T` key.
60
+ * πŸ“– Order matters:
61
+ * πŸ“– 1. provider-specific known-good overrides
62
+ * πŸ“– 2. discovered `/models` ids that also exist in this repo
63
+ * πŸ“– 3. all discovered `/models` ids
64
+ * πŸ“– 4. repo static model ids as final fallback
65
+ * @param {string} providerKey
66
+ * @param {{ models?: Array<[string, string, string, string, string]> } | undefined} src
67
+ * @param {string[]} [discoveredModelIds=[]]
68
+ * @returns {string[]}
69
+ */
70
+ export function listProviderTestModels(providerKey, src, discoveredModelIds = []) {
71
+ const staticModelIds = Array.isArray(src?.models) ? src.models.map(model => model[0]).filter(Boolean) : []
72
+ const staticModelSet = new Set(staticModelIds)
73
+ const preferredDiscoveredIds = discoveredModelIds.filter(modelId => staticModelSet.has(modelId))
74
+ const orderedCandidates = [
75
+ ...(PROVIDER_TEST_MODEL_OVERRIDES[providerKey] ?? []),
76
+ ...preferredDiscoveredIds,
77
+ ...discoveredModelIds,
78
+ ...staticModelIds,
79
+ ]
80
+ return [...new Set(orderedCandidates)]
81
+ }
82
+
83
+ /**
84
+ * πŸ“– classifyProviderTestOutcome maps attempted probe codes to a user-facing test result.
85
+ * πŸ“– This keeps Settings more honest than a binary success/fail badge:
86
+ * πŸ“– - `rate_limited` means the key is valid but the provider is currently throttling
87
+ * πŸ“– - `no_callable_model` means the provider responded, but none of the attempted models were callable
88
+ * @param {string[]} codes
89
+ * @returns {'ok'|'fail'|'rate_limited'|'no_callable_model'}
90
+ */
91
+ export function classifyProviderTestOutcome(codes) {
92
+ if (codes.includes('200')) return 'ok'
93
+ if (codes.includes('401') || codes.includes('403')) return 'fail'
94
+ if (codes.length > 0 && codes.every(code => code === '429')) return 'rate_limited'
95
+ if (codes.length > 0 && codes.every(code => code === '404' || code === '410')) return 'no_callable_model'
96
+ return 'fail'
97
+ }
16
98
 
17
99
  export function createKeyHandler(ctx) {
18
100
  const {
@@ -83,13 +165,45 @@ export function createKeyHandler(ctx) {
83
165
  const testKey = getApiKey(state.config, providerKey)
84
166
  if (!testKey) { state.settingsTestResults[providerKey] = 'fail'; return }
85
167
 
86
- // πŸ“– Use the first model in the provider's list for the test ping
87
- const testModel = src.models[0]?.[0]
88
- if (!testModel) { state.settingsTestResults[providerKey] = 'fail'; return }
89
-
90
168
  state.settingsTestResults[providerKey] = 'pending'
91
- const { code } = await ping(testKey, testModel, providerKey, src.url)
92
- state.settingsTestResults[providerKey] = code === '200' ? 'ok' : 'fail'
169
+ const discoveredModelIds = []
170
+ const modelsUrl = buildProviderModelsUrl(src.url)
171
+
172
+ if (modelsUrl) {
173
+ try {
174
+ const headers = { Authorization: `Bearer ${testKey}` }
175
+ if (providerKey === 'openrouter') {
176
+ headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
177
+ headers['X-Title'] = 'free-coding-models'
178
+ }
179
+ const modelsResp = await fetch(modelsUrl, { headers })
180
+ if (modelsResp.ok) {
181
+ const data = await modelsResp.json()
182
+ discoveredModelIds.push(...parseProviderModelIds(data))
183
+ }
184
+ } catch {
185
+ // πŸ“– Discovery failure is non-fatal; we still have repo-defined fallbacks.
186
+ }
187
+ }
188
+
189
+ const candidateModels = listProviderTestModels(providerKey, src, discoveredModelIds)
190
+ if (candidateModels.length === 0) { state.settingsTestResults[providerKey] = 'fail'; return }
191
+ const attemptedCodes = []
192
+
193
+ for (const testModel of candidateModels.slice(0, 8)) {
194
+ const { code } = await ping(testKey, testModel, providerKey, src.url)
195
+ attemptedCodes.push(code)
196
+ if (code === '200') {
197
+ state.settingsTestResults[providerKey] = 'ok'
198
+ return
199
+ }
200
+ if (code === '401' || code === '403') {
201
+ state.settingsTestResults[providerKey] = 'fail'
202
+ return
203
+ }
204
+ }
205
+
206
+ state.settingsTestResults[providerKey] = classifyProviderTestOutcome(attemptedCodes)
93
207
  }
94
208
 
95
209
  // πŸ“– Manual update checker from settings; keeps status visible in maintenance row.
@@ -146,6 +260,7 @@ export function createKeyHandler(ctx) {
146
260
  sortColumn: state.sortColumn,
147
261
  sortAsc: state.sortDirection === 'asc',
148
262
  pingInterval: state.pingInterval,
263
+ hideUnconfiguredModels: state.hideUnconfiguredModels,
149
264
  })
150
265
  setActiveProfile(state.config, name)
151
266
  state.activeProfile = name
@@ -759,6 +874,7 @@ export function createKeyHandler(ctx) {
759
874
  sortColumn: state.sortColumn,
760
875
  sortAsc: state.sortDirection === 'asc',
761
876
  pingInterval: state.pingInterval,
877
+ hideUnconfiguredModels: state.hideUnconfiguredModels,
762
878
  })
763
879
  setActiveProfile(state.config, 'default')
764
880
  state.activeProfile = 'default'
@@ -786,6 +902,7 @@ export function createKeyHandler(ctx) {
786
902
  } else {
787
903
  state.tierFilterMode = 0
788
904
  }
905
+ state.hideUnconfiguredModels = settings.hideUnconfiguredModels === true
789
906
  state.activeProfile = nextProfile
790
907
  // πŸ“– Rebuild favorites from profile data
791
908
  syncFavoriteFlags(state.results, state.config)
@@ -886,6 +1003,26 @@ export function createKeyHandler(ctx) {
886
1003
  setPingMode(PING_MODE_CYCLE[nextIdx], 'manual')
887
1004
  }
888
1005
 
1006
+ // πŸ“– E toggles hiding models whose provider has no configured API key.
1007
+ // πŸ“– The preference is saved globally and mirrored into the active profile.
1008
+ if (key.name === 'e') {
1009
+ state.hideUnconfiguredModels = !state.hideUnconfiguredModels
1010
+ if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
1011
+ state.config.settings.hideUnconfiguredModels = state.hideUnconfiguredModels
1012
+ if (state.activeProfile && state.config.profiles?.[state.activeProfile]) {
1013
+ const profile = state.config.profiles[state.activeProfile]
1014
+ if (!profile.settings || typeof profile.settings !== 'object') profile.settings = {}
1015
+ profile.settings.hideUnconfiguredModels = state.hideUnconfiguredModels
1016
+ }
1017
+ saveConfig(state.config)
1018
+ applyTierFilter()
1019
+ const visible = state.results.filter(r => !r.hidden)
1020
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1021
+ state.cursor = 0
1022
+ state.scrollOffset = 0
1023
+ return
1024
+ }
1025
+
889
1026
  // πŸ“– Tier toggle key: T = cycle through each individual tier (All β†’ S+ β†’ S β†’ A+ β†’ A β†’ A- β†’ B+ β†’ B β†’ C β†’ All)
890
1027
  if (key.name === 't') {
891
1028
  state.tierFilterMode = (state.tierFilterMode + 1) % TIER_CYCLE.length
package/src/overlays.js CHANGED
@@ -105,6 +105,8 @@ export function createOverlayRenderers(state, deps) {
105
105
  let testBadge = chalk.dim('[Test β€”]')
106
106
  if (testResult === 'pending') testBadge = chalk.yellow('[Testing…]')
107
107
  else if (testResult === 'ok') testBadge = chalk.greenBright('[Test βœ…]')
108
+ else if (testResult === 'rate_limited') testBadge = chalk.yellow('[Rate limit ⏳]')
109
+ else if (testResult === 'no_callable_model') testBadge = chalk.magenta('[No model ⚠]')
108
110
  else if (testResult === 'fail') testBadge = chalk.red('[Test ❌]')
109
111
  const rateSummary = chalk.dim((meta.rateLimits || 'No limit info').slice(0, 36))
110
112
 
@@ -279,6 +281,7 @@ export function createOverlayRenderers(state, deps) {
279
281
  lines.push('')
280
282
  lines.push(` ${chalk.bold('Controls')}`)
281
283
  lines.push(` ${chalk.yellow('W')} Toggle ping mode ${chalk.dim('(speed 2s β†’ normal 10s β†’ slow 30s β†’ forced 4s)')}`)
284
+ lines.push(` ${chalk.yellow('E')} Toggle configured models only ${chalk.dim('(enabled by default, persisted globally + in profiles)')}`)
282
285
  lines.push(` ${chalk.yellow('X')} Toggle token log page ${chalk.dim('(shows recent request usage from request-log.jsonl)')}`)
283
286
  lines.push(` ${chalk.yellow('Z')} Cycle tool mode ${chalk.dim('(OpenCode CLI β†’ OpenCode Desktop β†’ OpenClaw)')}`)
284
287
  lines.push(` ${chalk.yellow('F')} Toggle favorite on selected row ${chalk.dim('(⭐ pinned at top, persisted)')}`)
@@ -288,7 +291,7 @@ export function createOverlayRenderers(state, deps) {
288
291
  lines.push(` ${chalk.yellow('P')} Open settings ${chalk.dim('(manage API keys, provider toggles, manual update)')}`)
289
292
  lines.push(` ${chalk.yellow('Shift+P')} Cycle config profile ${chalk.dim('(switch between saved profiles live)')}`)
290
293
  lines.push(` ${chalk.yellow('Shift+S')} Save current config as a named profile ${chalk.dim('(inline prompt β€” type name + Enter)')}`)
291
- lines.push(` ${chalk.dim('Profiles store: favorites, sort, tier filter, ping interval, API keys.')}`)
294
+ lines.push(` ${chalk.dim('Profiles store: favorites, sort, tier filter, ping interval, configured-only filter, API keys.')}`)
292
295
  lines.push(` ${chalk.dim('Use --profile <name> to load a profile on startup.')}`)
293
296
  lines.push(` ${chalk.yellow('K')} / ${chalk.yellow('Esc')} Show/hide this help`)
294
297
  lines.push(` ${chalk.yellow('Ctrl+C')} Exit`)
@@ -77,140 +77,140 @@ export const OPENCODE_MODEL_MAP = {
77
77
  export const PROVIDER_METADATA = {
78
78
  nvidia: {
79
79
  label: 'NVIDIA NIM',
80
- color: chalk.rgb(118, 185, 0),
80
+ color: chalk.rgb(178, 235, 190),
81
81
  signupUrl: 'https://build.nvidia.com',
82
82
  signupHint: 'Profile β†’ API Keys β†’ Generate',
83
83
  rateLimits: 'Free tier (provider quota by model)',
84
84
  },
85
85
  groq: {
86
86
  label: 'Groq',
87
- color: chalk.rgb(249, 103, 20),
87
+ color: chalk.rgb(255, 204, 188),
88
88
  signupUrl: 'https://console.groq.com/keys',
89
89
  signupHint: 'API Keys β†’ Create API Key',
90
90
  rateLimits: 'Free dev tier (provider quota)',
91
91
  },
92
92
  cerebras: {
93
93
  label: 'Cerebras',
94
- color: chalk.rgb(0, 180, 255),
94
+ color: chalk.rgb(179, 229, 252),
95
95
  signupUrl: 'https://cloud.cerebras.ai',
96
96
  signupHint: 'API Keys β†’ Create',
97
97
  rateLimits: 'Free dev tier (provider quota)',
98
98
  },
99
99
  sambanova: {
100
100
  label: 'SambaNova',
101
- color: chalk.rgb(255, 165, 0),
101
+ color: chalk.rgb(255, 224, 178),
102
102
  signupUrl: 'https://cloud.sambanova.ai/apis',
103
103
  signupHint: 'SambaCloud portal β†’ Create API key',
104
104
  rateLimits: 'Dev tier generous quota',
105
105
  },
106
106
  openrouter: {
107
107
  label: 'OpenRouter',
108
- color: chalk.rgb(120, 80, 255),
108
+ color: chalk.rgb(225, 190, 231),
109
109
  signupUrl: 'https://openrouter.ai/keys',
110
110
  signupHint: 'API Keys β†’ Create',
111
111
  rateLimits: '50 req/day, 20/min (:free shared quota)',
112
112
  },
113
113
  huggingface: {
114
114
  label: 'Hugging Face Inference',
115
- color: chalk.rgb(255, 182, 0),
115
+ color: chalk.rgb(255, 245, 157),
116
116
  signupUrl: 'https://huggingface.co/settings/tokens',
117
117
  signupHint: 'Settings β†’ Access Tokens',
118
118
  rateLimits: 'Free monthly credits (~$0.10)',
119
119
  },
120
120
  replicate: {
121
121
  label: 'Replicate',
122
- color: chalk.rgb(120, 160, 255),
122
+ color: chalk.rgb(187, 222, 251),
123
123
  signupUrl: 'https://replicate.com/account/api-tokens',
124
124
  signupHint: 'Account β†’ API Tokens',
125
125
  rateLimits: 'Developer free quota',
126
126
  },
127
127
  deepinfra: {
128
128
  label: 'DeepInfra',
129
- color: chalk.rgb(0, 180, 140),
129
+ color: chalk.rgb(178, 223, 219),
130
130
  signupUrl: 'https://deepinfra.com/login',
131
131
  signupHint: 'Login β†’ API keys',
132
132
  rateLimits: 'Free dev tier (low-latency quota)',
133
133
  },
134
134
  fireworks: {
135
135
  label: 'Fireworks AI',
136
- color: chalk.rgb(255, 80, 50),
136
+ color: chalk.rgb(255, 205, 210),
137
137
  signupUrl: 'https://fireworks.ai',
138
138
  signupHint: 'Create account β†’ Generate API key',
139
139
  rateLimits: '$1 free credits (new dev accounts)',
140
140
  },
141
141
  codestral: {
142
142
  label: 'Mistral Codestral',
143
- color: chalk.rgb(255, 100, 100),
143
+ color: chalk.rgb(248, 187, 208),
144
144
  signupUrl: 'https://codestral.mistral.ai',
145
145
  signupHint: 'API Keys β†’ Create',
146
146
  rateLimits: '30 req/min, 2000/day',
147
147
  },
148
148
  hyperbolic: {
149
149
  label: 'Hyperbolic',
150
- color: chalk.rgb(0, 200, 150),
150
+ color: chalk.rgb(200, 230, 201),
151
151
  signupUrl: 'https://app.hyperbolic.ai/settings',
152
152
  signupHint: 'Settings β†’ API Keys',
153
153
  rateLimits: '$1 free trial credits',
154
154
  },
155
155
  scaleway: {
156
156
  label: 'Scaleway',
157
- color: chalk.rgb(130, 0, 250),
157
+ color: chalk.rgb(129, 212, 250),
158
158
  signupUrl: 'https://console.scaleway.com/iam/api-keys',
159
159
  signupHint: 'IAM β†’ API Keys',
160
160
  rateLimits: '1M free tokens',
161
161
  },
162
162
  googleai: {
163
163
  label: 'Google AI Studio',
164
- color: chalk.rgb(66, 133, 244),
164
+ color: chalk.rgb(187, 222, 251),
165
165
  signupUrl: 'https://aistudio.google.com/apikey',
166
166
  signupHint: 'Get API key',
167
167
  rateLimits: '14.4K req/day, 30/min',
168
168
  },
169
169
  siliconflow: {
170
170
  label: 'SiliconFlow',
171
- color: chalk.rgb(255, 120, 30),
171
+ color: chalk.rgb(178, 235, 242),
172
172
  signupUrl: 'https://cloud.siliconflow.cn/account/ak',
173
173
  signupHint: 'API Keys β†’ Create',
174
174
  rateLimits: 'Free models: usually 100 RPM, varies by model',
175
175
  },
176
176
  together: {
177
177
  label: 'Together AI',
178
- color: chalk.rgb(0, 180, 255),
178
+ color: chalk.rgb(197, 225, 165),
179
179
  signupUrl: 'https://api.together.ai/settings/api-keys',
180
180
  signupHint: 'Settings β†’ API keys',
181
181
  rateLimits: 'Credits/promos vary by account (check console)',
182
182
  },
183
183
  cloudflare: {
184
184
  label: 'Cloudflare Workers AI',
185
- color: chalk.rgb(242, 119, 36),
185
+ color: chalk.rgb(255, 204, 128),
186
186
  signupUrl: 'https://dash.cloudflare.com',
187
187
  signupHint: 'Create AI API token + set CLOUDFLARE_ACCOUNT_ID',
188
188
  rateLimits: 'Free: 10k neurons/day, text-gen 300 RPM',
189
189
  },
190
190
  perplexity: {
191
191
  label: 'Perplexity API',
192
- color: chalk.rgb(0, 210, 190),
192
+ color: chalk.rgb(159, 234, 201),
193
193
  signupUrl: 'https://www.perplexity.ai/settings/api',
194
194
  signupHint: 'Generate API key (billing may be required)',
195
195
  rateLimits: 'Tiered limits by spend (default ~50 RPM)',
196
196
  },
197
197
  qwen: {
198
198
  label: 'Alibaba Cloud (DashScope)',
199
- color: chalk.rgb(255, 140, 0),
199
+ color: chalk.rgb(255, 224, 130),
200
200
  signupUrl: 'https://modelstudio.console.alibabacloud.com',
201
201
  signupHint: 'Model Studio β†’ API Key β†’ Create (1M free tokens, 90 days)',
202
202
  rateLimits: '1M free tokens per model (Singapore region, 90 days)',
203
203
  },
204
204
  zai: {
205
205
  label: 'ZAI (z.ai)',
206
- color: chalk.rgb(0, 150, 255),
206
+ color: chalk.rgb(174, 213, 255),
207
207
  signupUrl: 'https://z.ai',
208
208
  signupHint: 'Sign up and generate an API key',
209
209
  rateLimits: 'Free tier (generous quota)',
210
210
  },
211
211
  iflow: {
212
212
  label: 'iFlow',
213
- color: chalk.rgb(100, 200, 255),
213
+ color: chalk.rgb(220, 231, 117),
214
214
  signupUrl: 'https://platform.iflow.cn',
215
215
  signupHint: 'Register β†’ Personal Information β†’ Generate API Key (7-day expiry)',
216
216
  rateLimits: 'Free for individuals (no request limits)',
@@ -43,29 +43,29 @@ import { calculateViewport, sortResultsWithPinnedFavorites, renderProxyStatusLin
43
43
  const require = createRequire(import.meta.url)
44
44
  const { version: LOCAL_VERSION } = require('../package.json')
45
45
 
46
- // πŸ“– Provider column palette: keep all Origins in the same visual family
47
- // πŸ“– (blue/cyan tones) while making each provider easy to distinguish at a glance.
46
+ // πŸ“– Provider column palette: soft pastel rainbow so each provider stays easy
47
+ // πŸ“– to spot without turning the table into a harsh neon wall.
48
48
  const PROVIDER_COLOR = {
49
- nvidia: [120, 205, 255],
50
- groq: [95, 185, 255],
51
- cerebras: [70, 165, 255],
52
- sambanova: [45, 145, 245],
53
- openrouter: [135, 220, 255],
54
- huggingface: [110, 190, 235],
55
- replicate: [85, 175, 230],
56
- deepinfra: [60, 160, 225],
57
- fireworks: [125, 215, 245],
58
- codestral: [100, 180, 240],
59
- hyperbolic: [75, 170, 240],
60
- scaleway: [55, 150, 235],
61
- googleai: [130, 210, 255],
62
- siliconflow: [90, 195, 245],
63
- together: [65, 155, 245],
64
- cloudflare: [115, 200, 240],
65
- perplexity: [140, 225, 255],
66
- qwen: [80, 185, 235],
67
- zai: [50, 140, 225],
68
- iflow: [145, 230, 255],
49
+ nvidia: [178, 235, 190],
50
+ groq: [255, 204, 188],
51
+ cerebras: [179, 229, 252],
52
+ sambanova: [255, 224, 178],
53
+ openrouter: [225, 190, 231],
54
+ huggingface: [255, 245, 157],
55
+ replicate: [187, 222, 251],
56
+ deepinfra: [178, 223, 219],
57
+ fireworks: [255, 205, 210],
58
+ codestral: [248, 187, 208],
59
+ hyperbolic: [200, 230, 201],
60
+ scaleway: [129, 212, 250],
61
+ googleai: [187, 222, 251],
62
+ siliconflow: [178, 235, 242],
63
+ together: [197, 225, 165],
64
+ cloudflare: [255, 204, 128],
65
+ perplexity: [159, 234, 201],
66
+ qwen: [255, 224, 130],
67
+ zai: [174, 213, 255],
68
+ iflow: [220, 231, 117],
69
69
  }
70
70
 
71
71
  // πŸ“– Active proxy reference for footer status line (set by bin/free-coding-models.js).
@@ -77,7 +77,7 @@ export function setActiveProxy(proxyInstance) {
77
77
  }
78
78
 
79
79
  // ─── renderTable: mode param controls footer hint text (opencode vs openclaw) ─────────
80
- export 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, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null, pingMode = 'normal', pingModeSource = 'auto') {
80
+ export 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, terminalCols = 0, originFilterMode = 0, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false) {
81
81
  // πŸ“– Filter out hidden models for display
82
82
  const visibleResults = results.filter(r => !r.hidden)
83
83
 
@@ -85,48 +85,48 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
85
85
  const down = visibleResults.filter(r => r.status === 'down').length
86
86
  const timeout = visibleResults.filter(r => r.status === 'timeout').length
87
87
  const pending = visibleResults.filter(r => r.status === 'pending').length
88
+ const totalVisible = visibleResults.length
89
+ const completedPings = Math.max(0, totalVisible - pending)
88
90
 
89
91
  // πŸ“– Calculate seconds until next ping
90
92
  const timeSinceLastPing = Date.now() - lastPingTime
91
93
  const timeUntilNextPing = Math.max(0, pingInterval - timeSinceLastPing)
92
- const secondsUntilNext = Math.ceil(timeUntilNextPing / 1000)
93
-
94
- const phase = pending > 0
95
- ? chalk.dim(`discovering β€” ${pending} remaining…`)
96
- : pendingPings > 0
97
- ? chalk.dim(`pinging β€” ${pendingPings} in flight…`)
98
- : chalk.dim(`next ping ${secondsUntilNext}s`)
94
+ const secondsUntilNext = timeUntilNextPing / 1000
95
+ const secondsUntilNextLabel = secondsUntilNext.toFixed(1)
99
96
 
100
97
  const intervalSec = Math.round(pingInterval / 1000)
101
98
  const pingModeMeta = {
102
- speed: { label: `${intervalSec}s speed`, color: chalk.bold.rgb(255, 210, 80) },
103
- normal: { label: `${intervalSec}s normal`, color: chalk.bold.rgb(120, 210, 255) },
104
- slow: { label: `${intervalSec}s slow`, color: chalk.bold.rgb(255, 170, 90) },
105
- forced: { label: `${intervalSec}s forced`, color: chalk.bold.rgb(255, 120, 120) },
99
+ speed: { label: 'fast', color: chalk.bold.rgb(255, 210, 80) },
100
+ normal: { label: 'normal', color: chalk.bold.rgb(120, 210, 255) },
101
+ slow: { label: 'slow', color: chalk.bold.rgb(255, 170, 90) },
102
+ forced: { label: 'forced', color: chalk.bold.rgb(255, 120, 120) },
106
103
  }
107
104
  const activePingMode = pingModeMeta[pingMode] ?? pingModeMeta.normal
108
- const pingModeBadge = activePingMode.color(` [${activePingMode.label}]`)
109
- const pingModeHint = pingModeSource === 'idle'
110
- ? chalk.dim(' idle')
111
- : pingModeSource === 'activity'
112
- ? chalk.dim(' resumed')
113
- : pingModeSource === 'startup'
114
- ? chalk.dim(' startup')
115
- : ''
116
-
117
- // πŸ“– Mode badge shown in header so user knows what Enter will do
118
- // πŸ“– Now includes key hint for mode toggle
105
+ const pingProgressText = `${completedPings}/${totalVisible}`
106
+ const nextCountdownColor = secondsUntilNext > 8
107
+ ? chalk.red.bold
108
+ : secondsUntilNext >= 4
109
+ ? chalk.yellow.bold
110
+ : secondsUntilNext < 1
111
+ ? chalk.greenBright.bold
112
+ : chalk.green.bold
113
+ const pingControlBadge =
114
+ activePingMode.color(' [ ') +
115
+ chalk.yellow.bold('W') +
116
+ activePingMode.color(` Ping Interval : ${intervalSec}s (${activePingMode.label}) - ${pingProgressText} - next : `) +
117
+ nextCountdownColor(`${secondsUntilNextLabel}s`) +
118
+ activePingMode.color(' ]')
119
+
120
+ // πŸ“– Tool badge keeps the active launch target visible in the header, so the
121
+ // πŸ“– footer no longer needs a redundant Enter action or mode toggle reminder.
119
122
  let modeBadge
120
123
  if (mode === 'openclaw') {
121
- modeBadge = chalk.bold.rgb(255, 100, 50)(' [🦞 OpenClaw]')
124
+ modeBadge = chalk.bold.rgb(255, 100, 50)(' [ ') + chalk.yellow.bold('Z') + chalk.bold.rgb(255, 100, 50)(' Tool : OpenClaw ]')
122
125
  } else if (mode === 'opencode-desktop') {
123
- modeBadge = chalk.bold.rgb(0, 200, 255)(' [πŸ–₯ Desktop]')
126
+ modeBadge = chalk.bold.rgb(0, 200, 255)(' [ ') + chalk.yellow.bold('Z') + chalk.bold.rgb(0, 200, 255)(' Tool : OpenCode Desktop ]')
124
127
  } else {
125
- modeBadge = chalk.bold.rgb(0, 200, 255)(' [πŸ’» CLI]')
128
+ modeBadge = chalk.bold.rgb(0, 200, 255)(' [ ') + chalk.yellow.bold('Z') + chalk.bold.rgb(0, 200, 255)(' Tool : OpenCode CLI ]')
126
129
  }
127
-
128
- // πŸ“– Add mode toggle hint
129
- const modeHint = chalk.dim.yellow(' (Z to toggle)')
130
130
 
131
131
  // πŸ“– Tier filter badge shown when filtering is active (shows exact tier name)
132
132
  const TIER_CYCLE_NAMES = [null, 'S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
@@ -172,16 +172,29 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
172
172
  const W_UPTIME = 6
173
173
  const W_TOKENS = 7
174
174
  const W_USAGE = 7
175
+ const MIN_TABLE_WIDTH = 166
176
+
177
+ if (terminalCols > 0 && terminalCols < MIN_TABLE_WIDTH) {
178
+ const lines = []
179
+ const blankLines = Math.max(0, Math.floor(((terminalRows || 24) - 3) / 2))
180
+ const warning = 'Please maximize your terminal for optimal use. The current terminal width is too small for the full table.'
181
+ const padLeft = Math.max(0, Math.floor((terminalCols - warning.length) / 2))
182
+ for (let i = 0; i < blankLines; i++) lines.push('')
183
+ lines.push(' '.repeat(padLeft) + chalk.red.bold(warning))
184
+ while (terminalRows > 0 && lines.length < terminalRows) lines.push('')
185
+ const EL = '\x1b[K'
186
+ return lines.map(line => line + EL).join('\n')
187
+ }
175
188
 
176
189
  // πŸ“– Sort models using the shared helper
177
190
  const sorted = sortResultsWithPinnedFavorites(visibleResults, sortColumn, sortDirection)
178
191
 
179
192
  const lines = [
180
- ` ${chalk.greenBright.bold('βœ… FCM')}${modeBadge}${pingModeBadge}${modeHint}${tierBadge}${originBadge}${profileBadge} ` +
193
+ ` ${chalk.greenBright.bold(`βœ… Free-Coding-Models v${LOCAL_VERSION}`)}${modeBadge}${pingControlBadge}${tierBadge}${originBadge}${profileBadge}${chalk.reset('')} ` +
181
194
  chalk.greenBright(`βœ… ${up}`) + chalk.dim(' up ') +
182
195
  chalk.yellow(`⏳ ${timeout}`) + chalk.dim(' timeout ') +
183
196
  chalk.red(`❌ ${down}`) + chalk.dim(' down ') +
184
- phase,
197
+ '',
185
198
  '',
186
199
  ]
187
200
 
@@ -276,6 +289,16 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
276
289
  chalk.dim('─'.repeat(W_USAGE))
277
290
  )
278
291
 
292
+ if (sorted.length === 0) {
293
+ lines.push('')
294
+ if (hideUnconfiguredModels) {
295
+ lines.push(` ${chalk.redBright.bold('Press P to configure your API key.')}`)
296
+ lines.push(` ${chalk.dim('No configured provider currently exposes visible models in the table.')}`)
297
+ } else {
298
+ lines.push(` ${chalk.yellow.bold('No models match the current filters.')}`)
299
+ }
300
+ }
301
+
279
302
  // πŸ“– Viewport clipping: only render models that fit on screen
280
303
  const vp = calculateViewport(terminalRows, scrollOffset, sorted.length)
281
304
 
@@ -303,6 +326,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
303
326
  const prefixDisplayWidth = 2
304
327
  const nameWidth = Math.max(0, W_MODEL - prefixDisplayWidth)
305
328
  const name = favoritePrefix + r.label.slice(0, nameWidth).padEnd(nameWidth)
329
+ const modelColor = chalk.rgb(...providerRgb)
306
330
  const sweScore = r.sweScore ?? 'β€”'
307
331
  // πŸ“– SWE% colorized on the same gradient as Tier:
308
332
  // β‰₯70% bright neon green (S+), β‰₯60% green (S), β‰₯50% yellow-green (A+),
@@ -490,10 +514,11 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
490
514
  uptimeCell = chalk.red(uptimeStr.padEnd(W_UPTIME))
491
515
  }
492
516
 
493
- // πŸ“– When cursor is on this row, render Model and Provider in bright white for readability
494
- const nameCell = isCursor ? chalk.white.bold(favoritePrefix + r.label.slice(0, nameWidth).padEnd(nameWidth)) : name
517
+ // πŸ“– Model text now mirrors the provider hue so provider affinity is visible
518
+ // πŸ“– even before the eye reaches the Provider column.
519
+ const nameCell = isCursor ? modelColor.bold(name) : modelColor(name)
495
520
  const sourceCursorText = providerName.padEnd(W_SOURCE)
496
- const sourceCell = isCursor ? chalk.white.bold(sourceCursorText) : source
521
+ const sourceCell = isCursor ? chalk.rgb(...providerRgb).bold(sourceCursorText) : source
497
522
 
498
523
  // πŸ“– Usage column β€” provider-scoped remaining quota when measurable,
499
524
  // πŸ“– otherwise a green dot to show "usable but not meaningfully quantifiable".
@@ -527,12 +552,12 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
527
552
  const row = ' ' + num + ' ' + tier + ' ' + sweCell + ' ' + ctxCell + ' ' + nameCell + ' ' + sourceCell + ' ' + pingCell + ' ' + avgCell + ' ' + status + ' ' + speedCell + ' ' + stabCell + ' ' + uptimeCell + ' ' + tokensCell + ' ' + usageCell
528
553
 
529
554
  if (isCursor) {
530
- lines.push(chalk.bgRgb(50, 0, 60)(row))
555
+ lines.push(chalk.bgRgb(155, 55, 135)(row))
531
556
  } else if (r.isRecommended) {
532
557
  // πŸ“– Medium green background for recommended models (distinguishable from favorites)
533
558
  lines.push(chalk.bgRgb(15, 40, 15)(row))
534
559
  } else if (r.isFavorite) {
535
- lines.push(chalk.bgRgb(35, 20, 0)(row))
560
+ lines.push(chalk.bgRgb(88, 64, 10)(row))
536
561
  } else {
537
562
  lines.push(row)
538
563
  }
@@ -548,15 +573,11 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
548
573
  } else {
549
574
  lines.push('')
550
575
  }
551
- // πŸ“– Footer hints adapt based on active mode.
576
+ // πŸ“– Footer hints keep only navigation and secondary actions now that the
577
+ // πŸ“– active tool target is already visible in the header badge.
552
578
  const hotkey = (keyLabel, text) => chalk.yellow(keyLabel) + chalk.dim(text)
553
- const actionHint = mode === 'openclaw'
554
- ? hotkey('Enter', 'β†’SetOpenClaw')
555
- : mode === 'opencode-desktop'
556
- ? hotkey('Enter', 'β†’OpenDesktop')
557
- : hotkey('Enter', 'β†’OpenCode')
558
- // πŸ“– Line 1: core navigation + sorting shortcuts
559
- lines.push(chalk.dim(` ↑↓ Navigate β€’ `) + actionHint + chalk.dim(` β€’ `) + hotkey('F', ' Toggle Favorite') + chalk.dim(` β€’ Press Highlighted letters in column named to sort & filter β€’ `) + hotkey('T', ' Tier') + chalk.dim(` β€’ `) + hotkey('D', ' Provider') + chalk.dim(` β€’ `) + hotkey('W', ' Ping Mode : FAST/NORMAL/SLOW/FORCED') + chalk.dim(` β€’ `) + hotkey('Z', ' Tool Mode') + chalk.dim(` β€’ `) + hotkey('X', ' Token Logs') + chalk.dim(` β€’ `) + hotkey('P', ' Settings') + chalk.dim(` β€’ `) + hotkey('K', ' Help'))
579
+ // πŸ“– Line 1: core navigation + filtering shortcuts
580
+ lines.push(chalk.dim(` ↑↓ Navigate β€’ `) + hotkey('F', ' Toggle Favorite') + chalk.dim(` β€’ `) + hotkey('T', ' Tier') + chalk.dim(` β€’ `) + hotkey('D', ' Provider') + chalk.dim(` β€’ `) + hotkey('E', ' Configured Only') + chalk.dim(` β€’ `) + hotkey('X', ' Token Logs') + chalk.dim(` β€’ `) + hotkey('P', ' Settings') + chalk.dim(` β€’ `) + hotkey('K', ' Help'))
560
581
  // πŸ“– Line 2: profiles, recommend, feature request, bug report, and extended hints β€” gives visibility to less-obvious features
561
582
  lines.push(chalk.dim(` `) + hotkey('⇧P', ' Cycle profile') + chalk.dim(` β€’ `) + hotkey('⇧S', ' Save profile') + chalk.dim(` β€’ `) + hotkey('Q', ' Smart Recommend') + chalk.dim(` β€’ `) + hotkey('J', ' Request feature') + chalk.dim(` β€’ `) + hotkey('I', ' Report bug'))
562
583
  // πŸ“– Proxy status line β€” always rendered with explicit state (starting/running/failed/stopped)
@@ -575,8 +596,6 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
575
596
  chalk.dim(' β†’ ') +
576
597
  chalk.rgb(200, 150, 255)('https://discord.gg/5MbTnDC3Md') +
577
598
  chalk.dim(' β€’ ') +
578
- chalk.dim(`v${LOCAL_VERSION}`) +
579
- chalk.dim(' β€’ ') +
580
599
  chalk.dim('Ctrl+C Exit')
581
600
  )
582
601