free-coding-models 0.3.62 → 0.3.64

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/CHANGELOG.md CHANGED
@@ -1,10 +1,10 @@
1
- ## [0.3.58] - 2026-05-04
1
+ ## [0.3.64] - 2026-05-06
2
2
 
3
- ### Changed
3
+ ### Fixed
4
4
 
5
- - **E key filter ("Working only") now also hides `noauth` and `auth_error` models** Previously E only filtered by missing API key. Now it also filters out models whose provider returned a 401/403 auth rejection, while still keeping `timeout` and `429` (rate-limited) models visible.
6
- - **Google AI Studio renamed to Google AI** — Shortened to avoid column overflow in the provider column.
5
+ - **E footer spacing and color** Fixed the active `E` footer label so the hotkey letter keeps its distinct hotkey color and is separated from the active filter text by a readable space. This makes the active filter state easier to scan in the TUI footer.
7
6
 
8
- ### Fixed
7
+ ### Changed
9
8
 
10
- - **NVIDIA NIM column label** — The provider column in the TUI table now shows `NVIDIA NIM` instead of `NIM` to match the official provider branding.
9
+ - **Clearer E filter names** — Renamed the `E` cycle from `Working only` / `Best mode` to `Configured only` / `Usable only`. The behavior is unchanged: `Configured only` keeps configured providers visible, while `Usable only` narrows the table to models with healthy status and usable verdicts.
10
+ - **README TUI key reference** — Updated the `E` shortcut documentation to describe the full visibility cycle: `Active only → Configured only → Usable only`.
package/README.md CHANGED
@@ -319,7 +319,7 @@ When a tool mode is active (via `Z`), models incompatible with that tool are hig
319
319
  | `Z` | Cycle target tool |
320
320
  | `T` | Cycle tier filter |
321
321
  | `D` | Cycle provider filter |
322
- | `E` | Toggle configured-only mode |
322
+ | `E` | Cycle visibility filter (`Active only Configured only → Usable only`) |
323
323
  | `F` | Favorite / unfavorite model |
324
324
  | `Y` | Toggle favorites mode (`Normal filter/sort` default ↔ `Pinned + always visible`) |
325
325
  | `X` | Clear active custom text filter |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.3.62",
3
+ "version": "0.3.64",
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/src/app.js CHANGED
@@ -111,7 +111,7 @@ import { resolveCloudflareUrl, buildPingRequest, ping, extractQuotaPercent, getP
111
111
  import { runFiableMode, filterByTierOrExit, fetchOpenRouterFreeModels } from '../src/analysis.js'
112
112
  import { PROVIDER_METADATA, ENV_VAR_NAMES, isWindows, isMac } from '../src/provider-metadata.js'
113
113
  import { parseTelemetryEnv, isTelemetryDebugEnabled, telemetryDebug, ensureTelemetryConfig, getTelemetryDistinctId, getTelemetrySystem, getTelemetryTerminal, isTelemetryEnabled, sendUsageTelemetry } from '../src/telemetry.js'
114
- import { ensureFavoritesConfig, toFavoriteKey, syncFavoriteFlags, toggleFavoriteModel, reorderFavorite } from '../src/favorites.js'
114
+ import { ensureFavoritesConfig, toFavoriteKey, syncFavoriteFlags, toggleFavoriteModel, reorderFavorite, pruneOrphanedFavorites } from '../src/favorites.js'
115
115
  import { checkForUpdateDetailed, checkForUpdate, runUpdate, promptUpdateNotification, fetchLastReleaseDate } from './updater.js'
116
116
  import { promptApiKey } from '../src/setup.js'
117
117
  import { syncShellEnv, ensureShellRcSource, promptShellEnvMigration, removeShellEnv } from '../src/shell-env.js'
@@ -372,6 +372,9 @@ export async function runApp(cliArgs, config) {
372
372
  hidden: false, // 📖 Simple flag to hide/show models
373
373
  }))
374
374
  syncFavoriteFlags(results, config)
375
+ // 📖 Garbage-collect favorites that reference models no longer in sources.js,
376
+ // 📖 so the router dashboard only shows real, launchable models.
377
+ pruneOrphanedFavorites(results, config)
375
378
 
376
379
  // 📖 Load usage data from token-stats.json and attach usagePercent to each result row.
377
380
  // 📖 usagePercent is the quota percent remaining (0–100). undefined = no data available.
@@ -434,6 +437,7 @@ export async function runApp(cliArgs, config) {
434
437
  verdictFilterMode: 0, // 📖 Index into VERDICT_CYCLE (0=All, then verdicts)
435
438
  healthFilterMode: 0, // 📖 Index into HEALTH_CYCLE (0=All, then health states)
436
439
  hideUnconfiguredModels: config.settings?.hideUnconfiguredModels === true, // 📖 Hide providers with no configured API key when true.
440
+ bestModeOnly: false, // 📖 E cycles Normal → Configured only → Usable only (Health UP + Verdict ≤ Slow)
437
441
  favoritesPinnedAndSticky: config.settings?.favoritesPinnedAndSticky === true, // 📖 false by default: favorites follow normal sort/filter rules until Y enables pinned+sticky mode.
438
442
  scrollOffset: 0, // 📖 First visible model index in viewport
439
443
  terminalRows: process.stdout.rows || 24, // 📖 Current terminal height
@@ -579,7 +583,8 @@ export async function runApp(cliArgs, config) {
579
583
  const scheduleNextPing = () => {
580
584
  clearTimeout(state.pingIntervalObj)
581
585
  const elapsed = Date.now() - state.lastPingTime
582
- const delay = Math.max(0, state.pingInterval - elapsed)
586
+ const interval = state.routerDashboardOpen ? 1000 : state.pingInterval
587
+ const delay = Math.max(0, interval - elapsed)
583
588
  state.pingIntervalObj = setTimeout(runPingCycle, delay)
584
589
  }
585
590
 
@@ -787,6 +792,16 @@ const unconfiguredHide = state.hideUnconfiguredModels && !noKeyNeeded && (!getAp
787
792
  if (unconfiguredHide) {
788
793
  r.hidden = true
789
794
  return
795
+ }
796
+ // 📖 Usable only: only show models with Health UP and Verdict Perfect/Normal/Slow
797
+ if (state.bestModeOnly) {
798
+ const bmVerdict = getVerdict(r)
799
+ const bmVerdictOk = ['Perfect', 'Normal', 'Slow'].includes(bmVerdict)
800
+ const bmHealthOk = r.status === 'up'
801
+ if (!bmHealthOk || !bmVerdictOk) {
802
+ r.hidden = true
803
+ return
804
+ }
790
805
  }
791
806
  // 📖 Apply tier, origin, verdict, and health filters — model is hidden if it fails any
792
807
  const allowedTiers = (activeTier && TIER_LETTER_MAP[activeTier]) ? TIER_LETTER_MAP[activeTier] : [activeTier]
@@ -1087,8 +1102,9 @@ if (unconfiguredHide) {
1087
1102
  state.customTextFilter,
1088
1103
  state.lastReleaseDate,
1089
1104
  false,
1090
- state.verdictFilterMode,
1091
- state.healthFilterMode
1105
+ state.verdictFilterMode,
1106
+ state.healthFilterMode,
1107
+ state.bestModeOnly
1092
1108
  )
1093
1109
  }
1094
1110
  tableContent = state.commandPaletteFrozenTable
@@ -1125,8 +1141,9 @@ if (unconfiguredHide) {
1125
1141
  state.customTextFilter,
1126
1142
  state.lastReleaseDate,
1127
1143
  false,
1128
- state.verdictFilterMode,
1129
- state.healthFilterMode
1144
+ state.verdictFilterMode,
1145
+ state.healthFilterMode,
1146
+ state.bestModeOnly
1130
1147
  )
1131
1148
  }
1132
1149
 
@@ -1174,7 +1191,7 @@ if (unconfiguredHide) {
1174
1191
  pinFavorites: state.favoritesPinnedAndSticky,
1175
1192
  })
1176
1193
 
1177
- process.stdout.write(ALT_HOME + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.terminalCols, state.originFilterMode, null, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels, state.widthWarningStartedAt, state.widthWarningDismissed, state.widthWarningShowCount, state.settingsUpdateState, state.settingsUpdateLatestVersion, false, state.startupLatestVersion, state.versionAlertsEnabled, state.favoritesPinnedAndSticky, state.customTextFilter, state.lastReleaseDate, false, state.verdictFilterMode, state.healthFilterMode))
1194
+ process.stdout.write(ALT_HOME + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.terminalCols, state.originFilterMode, null, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels, state.widthWarningStartedAt, state.widthWarningDismissed, state.widthWarningShowCount, state.settingsUpdateState, state.settingsUpdateLatestVersion, false, state.startupLatestVersion, state.versionAlertsEnabled, state.favoritesPinnedAndSticky, state.customTextFilter, state.lastReleaseDate, false, state.verdictFilterMode, state.healthFilterMode, state.bestModeOnly))
1178
1195
  if (process.stdout.isTTY) {
1179
1196
  process.stdout.flush && process.stdout.flush()
1180
1197
  }
@@ -1224,6 +1241,13 @@ if (unconfiguredHide) {
1224
1241
  }
1225
1242
 
1226
1243
  state.results.forEach(r => {
1244
+ // 📖 When router dashboard is open, ONLY ping favorites every second
1245
+ // 📖 to prevent massive rate limiting across the entire 90+ model catalog.
1246
+ if (state.routerDashboardOpen) {
1247
+ const favKey = `${r.providerKey}/${r.modelId}`
1248
+ if (!state.config.favorites.includes(favKey)) return
1249
+ }
1250
+
1227
1251
  pingModel(r).catch(() => {
1228
1252
  // Individual ping failures don't crash the loop
1229
1253
  })
@@ -201,13 +201,13 @@ const BASE_COMMAND_TREE = [
201
201
  ],
202
202
  },
203
203
  { id: 'action-cycle-theme', label: 'Cycle theme', shortcut: 'G', icon: '🌗', description: 'Switch dark/light/auto', keywords: ['theme', 'dark', 'light', 'auto'] },
204
- { id: 'action-reset-view', label: 'Reset view', icon: '🔄', description: 'Reset filters and sort', keywords: ['reset', 'view', 'sort', 'filters'] },
204
+ { id: 'action-reset-view', label: 'Reset view', shortcut: 'N', icon: '🔄', description: 'Reset filters and sort', keywords: ['reset', 'view', 'sort', 'filters'] },
205
205
  ],
206
206
  },
207
207
  // 📖 Pages - directly at root level, not in submenu
208
208
  { id: 'open-settings', label: 'Settings', shortcut: 'P', icon: '⚙️', type: 'page', description: 'API keys and preferences', keywords: ['settings', 'config', 'api key'] },
209
209
  { id: 'open-help', label: 'Help', shortcut: 'K', icon: '❓', type: 'page', description: 'Show all shortcuts', keywords: ['help', 'shortcuts', 'hotkeys'] },
210
- { id: 'open-changelog', label: 'Changelog', shortcut: 'N', icon: '📋', type: 'page', description: 'Version history', keywords: ['changelog', 'release'] },
210
+ { id: 'open-changelog', label: 'Changelog', icon: '📋', type: 'page', description: 'Version history', keywords: ['changelog', 'release'] },
211
211
 
212
212
  { id: 'open-recommend', label: 'Smart recommend', shortcut: 'Q', icon: '🎯', type: 'page', description: 'Find best model for task', keywords: ['recommend', 'best model'] },
213
213
  { id: 'open-install-endpoints', label: 'Install endpoints', icon: '🔌', type: 'page', description: 'Install provider catalogs', keywords: ['install', 'endpoints', 'providers'] },
@@ -136,10 +136,12 @@ function getManagedProviderId(providerKey) {
136
136
  }
137
137
 
138
138
  function getProviderLabel(providerKey) {
139
+ if (providerKey === 'fcm_router') return 'Smart Router Daemon'
139
140
  return PROVIDER_METADATA[providerKey]?.label || sources[providerKey]?.name || providerKey
140
141
  }
141
142
 
142
143
  function getManagedProviderLabel(providerKey) {
144
+ if (providerKey === 'fcm_router') return 'FCM Smart Router'
143
145
  return `FCM ${getProviderLabel(providerKey)}`
144
146
  }
145
147
 
@@ -157,6 +159,10 @@ function getDefaultMaxTokens(contextWindow) {
157
159
  }
158
160
 
159
161
  function resolveProviderBaseUrl(providerKey) {
162
+ if (providerKey === 'fcm_router') {
163
+ return `http://localhost:${process.env.FCM_ROUTER_PORT || '19280'}/v1`
164
+ }
165
+
160
166
  const providerUrl = sources[providerKey]?.url
161
167
  if (!providerUrl) return null
162
168
 
@@ -173,6 +179,9 @@ function resolveProviderBaseUrl(providerKey) {
173
179
  }
174
180
 
175
181
  function resolveGooseBaseUrl(providerKey) {
182
+ if (providerKey === 'fcm_router') {
183
+ return `http://localhost:${process.env.FCM_ROUTER_PORT || '19280'}/v1`
184
+ }
176
185
  const providerUrl = sources[providerKey]?.url
177
186
  if (!providerUrl) return null
178
187
  if (providerKey === 'cloudflare') {
@@ -184,6 +193,7 @@ function resolveGooseBaseUrl(providerKey) {
184
193
  }
185
194
 
186
195
  function getDirectInstallSupport(providerKey) {
196
+ if (providerKey === 'fcm_router') return { supported: true, reason: null }
187
197
  if (!sources[providerKey]) {
188
198
  return { supported: false, reason: 'Unknown provider' }
189
199
  }
@@ -220,6 +230,12 @@ function buildCatalogModel(modelId, label, tier, sweScore, ctx) {
220
230
  }
221
231
 
222
232
  export function getProviderCatalogModels(providerKey) {
233
+ if (providerKey === 'fcm_router') {
234
+ return [
235
+ buildCatalogModel('fcm', 'FCM Smart Router', 'S+', 100, '200k')
236
+ ]
237
+ }
238
+
223
239
  const seen = new Set()
224
240
  return MODELS
225
241
  .filter((entry) => entry[5] === providerKey)
@@ -252,6 +268,7 @@ export function getInstallTargetModes() {
252
268
  }
253
269
 
254
270
  function requireConfiguredProviderKey(config, providerKey) {
271
+ if (providerKey === 'fcm_router') return 'fcm-local'
255
272
  const apiKey = getApiKey(config, providerKey)
256
273
  if (!apiKey) {
257
274
  throw new Error(`No configured API key found for ${getProviderLabel(providerKey)}`)
package/src/favorites.js CHANGED
@@ -21,9 +21,10 @@
21
21
  * → toFavoriteKey(providerKey, modelId) — Build the canonical "providerKey/modelId" string
22
22
  * → syncFavoriteFlags(results, config) — Attach isFavorite/favoriteRank to result rows
23
23
  * → toggleFavoriteModel(config, providerKey, modelId) — Add/remove favorite and persist
24
+ * → pruneOrphanedFavorites(results, config) — Remove favorites referencing models no longer in sources
24
25
  *
25
26
  * @exports
26
- * ensureFavoritesConfig, toFavoriteKey, syncFavoriteFlags, toggleFavoriteModel
27
+ * ensureFavoritesConfig, toFavoriteKey, syncFavoriteFlags, toggleFavoriteModel, pruneOrphanedFavorites
27
28
  *
28
29
  * @see src/config.js — load/save helpers keep favorite persistence atomic and merge-safe
29
30
  * @see bin/free-coding-models.js — calls syncFavoriteFlags on startup and toggleFavoriteModel on F key
@@ -125,3 +126,23 @@ export function reorderFavorite(config, providerKey, modelId, direction) {
125
126
  if (saveResult.success) replaceConfigContents(config, latestConfig)
126
127
  return true
127
128
  }
129
+
130
+ /**
131
+ * 📖 Remove favorites that reference models no longer present in the active sources.
132
+ * 📖 Called once at startup so the router dashboard does not show stale/removed models.
133
+ * 📖 Persists immediately if any orphaned entries are found.
134
+ * @param {Array<Record<string, unknown>>} results — the full result rows from sources
135
+ * @param {Record<string, unknown>} config
136
+ * @returns {number} count of removed orphaned entries
137
+ */
138
+ export function pruneOrphanedFavorites(results, config) {
139
+ ensureFavoritesConfig(config)
140
+ const validKeys = new Set(results.map(r => toFavoriteKey(r.providerKey, r.modelId)))
141
+ const before = config.favorites.length
142
+ config.favorites = config.favorites.filter(key => validKeys.has(key))
143
+ const removed = before - config.favorites.length
144
+ if (removed > 0) {
145
+ saveConfig(config, { replaceFavorites: true })
146
+ }
147
+ return removed
148
+ }
@@ -269,6 +269,7 @@ export function createKeyHandler(ctx) {
269
269
  installProviderEndpoints,
270
270
  syncFavoriteFlags,
271
271
  toggleFavoriteModel,
272
+ reorderFavorite,
272
273
  sortResultsWithPinnedFavorites,
273
274
  adjustScrollOffset,
274
275
  applyTierFilter,
@@ -1068,14 +1069,22 @@ export function createKeyHandler(ctx) {
1068
1069
  function resetViewSettings() {
1069
1070
  state.tierFilterMode = 0
1070
1071
  state.originFilterMode = 0
1072
+ state.verdictFilterMode = 0
1073
+ state.healthFilterMode = 0
1071
1074
  state.customTextFilter = null // 📖 Clear ephemeral text filter on view reset
1072
- state.sortColumn = 'avg'
1075
+ state.hideUnconfiguredModels = false
1076
+ state.bestModeOnly = false
1077
+ state.sortColumn = 'condition' // 📖 Default sort: Health
1073
1078
  state.sortDirection = 'asc'
1079
+ state.favoritesPinnedAndSticky = false
1080
+ state.cursor = 0
1081
+ state.scrollOffset = 0
1074
1082
  if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
1075
1083
  delete state.config.settings.tierFilter
1076
1084
  delete state.config.settings.originFilter
1077
1085
  delete state.config.settings.sortColumn
1078
1086
  delete state.config.settings.sortAsc
1087
+ state.config.settings.hideUnconfiguredModels = false
1079
1088
  saveConfig(state.config)
1080
1089
  applyTierFilter()
1081
1090
  refreshVisibleSorted({ resetCursor: true })
@@ -1439,7 +1448,8 @@ export function createKeyHandler(ctx) {
1439
1448
  if (state.routerDashboardOpen) {
1440
1449
  if (key.ctrl && key.name === 'c') { exit(0); return }
1441
1450
  const favorites = Array.isArray(state.config?.favorites) ? state.config.favorites : []
1442
- const maxCursor = Math.max(0, favorites.length - 1)
1451
+ // 📖 maxCursor accounts for the favorites list + 2 buttons (Start/Stop Daemon and Install Endpoint)
1452
+ const maxCursor = Math.max(0, favorites.length + 1)
1443
1453
  const pageStep = Math.max(1, (state.terminalRows || 1) - 4)
1444
1454
 
1445
1455
  if (key.name === 'escape') {
@@ -1447,10 +1457,10 @@ export function createKeyHandler(ctx) {
1447
1457
  return
1448
1458
  }
1449
1459
 
1450
- // 📖 Ctrl+↑: move the selected favorite UP in fallback priority
1451
- if (key.ctrl && key.name === 'up') {
1452
- if (favorites.length > 0) {
1453
- const cursorIdx = state.routerDashboardCursorIndex ?? 0
1460
+ // 📖 Shift+↑: move the selected favorite UP in fallback priority
1461
+ if (key.shift && key.name === 'up') {
1462
+ const cursorIdx = state.routerDashboardCursorIndex ?? 0
1463
+ if (favorites.length > 0 && cursorIdx < favorites.length) {
1454
1464
  const favKey = favorites[cursorIdx]
1455
1465
  if (favKey) {
1456
1466
  const slashIdx = favKey.indexOf('/')
@@ -1466,10 +1476,10 @@ export function createKeyHandler(ctx) {
1466
1476
  return
1467
1477
  }
1468
1478
 
1469
- // 📖 Ctrl+↓: move the selected favorite DOWN in fallback priority
1470
- if (key.ctrl && key.name === 'down') {
1471
- if (favorites.length > 0) {
1472
- const cursorIdx = state.routerDashboardCursorIndex ?? 0
1479
+ // 📖 Shift+↓: move the selected favorite DOWN in fallback priority
1480
+ if (key.shift && key.name === 'down') {
1481
+ const cursorIdx = state.routerDashboardCursorIndex ?? 0
1482
+ if (favorites.length > 0 && cursorIdx < favorites.length) {
1473
1483
  const favKey = favorites[cursorIdx]
1474
1484
  if (favKey) {
1475
1485
  const slashIdx = favKey.indexOf('/')
@@ -1477,7 +1487,7 @@ export function createKeyHandler(ctx) {
1477
1487
  const modelId = slashIdx >= 0 ? favKey.slice(slashIdx + 1) : favKey
1478
1488
  const moved = reorderFavorite(state.config, providerKey, modelId, 'down')
1479
1489
  if (moved) {
1480
- state.routerDashboardCursorIndex = Math.min(maxCursor, cursorIdx + 1)
1490
+ state.routerDashboardCursorIndex = Math.min(favorites.length - 1, cursorIdx + 1)
1481
1491
  syncFavoriteFlags(state.results, state.config)
1482
1492
  }
1483
1493
  }
@@ -1485,17 +1495,56 @@ export function createKeyHandler(ctx) {
1485
1495
  return
1486
1496
  }
1487
1497
 
1498
+ // 📖 S: Toggle daemon start/stop
1499
+ if (key.name === 's') {
1500
+ const isRunning = state.routerDashboardStatus === 'ready' || state.routerDashboardStatus === 'partial'
1501
+ const binPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'free-coding-models.js')
1502
+ const args = isRunning ? ['--daemon-stop'] : ['--daemon-bg']
1503
+
1504
+ state.routerDashboardStatus = 'loading'
1505
+ const child = spawn('node', [binPath, ...args], {
1506
+ detached: true,
1507
+ stdio: 'ignore',
1508
+ })
1509
+ child.unref()
1510
+ return
1511
+ }
1512
+
1513
+ // 📖 Enter/Return: Toggle daemon or open Install Endpoints if cursor is on a button
1514
+ if (key.name === 'return' || key.name === 'enter') {
1515
+ const btnCursor = favorites.length
1516
+ const installBtnCursor = favorites.length + 1
1517
+
1518
+ if ((state.routerDashboardCursorIndex ?? 0) === btnCursor) {
1519
+ const isRunning = state.routerDashboardStatus === 'ready' || state.routerDashboardStatus === 'partial'
1520
+ const binPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'free-coding-models.js')
1521
+ const args = isRunning ? ['--daemon-stop'] : ['--daemon-bg']
1522
+
1523
+ state.routerDashboardStatus = 'loading'
1524
+ const child = spawn('node', [binPath, ...args], {
1525
+ detached: true,
1526
+ stdio: 'ignore',
1527
+ })
1528
+ child.unref()
1529
+ } else if ((state.routerDashboardCursorIndex ?? 0) === installBtnCursor) {
1530
+ state.routerDashboardOpen = false
1531
+ state.installEndpointsOpen = true
1532
+ state.installEndpointsPhase = 'tools' // skip the provider selection phase
1533
+ state.installEndpointsCursor = 0
1534
+ state.installEndpointsProviderKey = 'fcm_router' // special provider key handled by endpoint-installer
1535
+ state.installEndpointsScrollOffset = 0
1536
+ state.installEndpointsErrorMsg = null
1537
+ }
1538
+ return
1539
+ }
1540
+
1488
1541
  // 📖 ↑/↓: navigate the favorites list cursor
1489
1542
  if (key.name === 'up' || key.name === 'k') {
1490
- if (favorites.length > 0) {
1491
- state.routerDashboardCursorIndex = Math.max(0, (state.routerDashboardCursorIndex ?? 0) - 1)
1492
- }
1543
+ state.routerDashboardCursorIndex = Math.max(0, (state.routerDashboardCursorIndex ?? 0) - 1)
1493
1544
  return
1494
1545
  }
1495
1546
  if (key.name === 'down' || key.name === 'j') {
1496
- if (favorites.length > 0) {
1497
- state.routerDashboardCursorIndex = Math.min(maxCursor, (state.routerDashboardCursorIndex ?? 0) + 1)
1498
- }
1547
+ state.routerDashboardCursorIndex = Math.min(maxCursor, (state.routerDashboardCursorIndex ?? 0) + 1)
1499
1548
  return
1500
1549
  }
1501
1550
  if (key.name === 'pageup') {
@@ -1527,7 +1576,7 @@ export function createKeyHandler(ctx) {
1527
1576
  if (key.ctrl && key.name === 'c') { exit(0); return }
1528
1577
 
1529
1578
  const providerChoices = getConfiguredInstallableProviders(state.config)
1530
- const toolChoices = getInstallTargetModes()
1579
+ const toolChoices = getInstallTargetModes().filter(t => !(state.installEndpointsProviderKey === 'fcm_router' && t === 'fcm_router'))
1531
1580
  const modelChoices = state.installEndpointsProviderKey
1532
1581
  ? getProviderCatalogModels(state.installEndpointsProviderKey)
1533
1582
  : []
@@ -1569,10 +1618,19 @@ export function createKeyHandler(ctx) {
1569
1618
  if (key.name === 'escape') {
1570
1619
  state.installEndpointsErrorMsg = null
1571
1620
  if (state.installEndpointsPhase === 'providers' || state.installEndpointsPhase === 'result') {
1621
+ const wasFcmRouter = state.installEndpointsProviderKey === 'fcm_router'
1572
1622
  resetInstallEndpointsOverlay()
1623
+ if (wasFcmRouter) {
1624
+ state.routerDashboardOpen = true
1625
+ }
1573
1626
  return
1574
1627
  }
1575
1628
  if (state.installEndpointsPhase === 'tools') {
1629
+ if (state.installEndpointsProviderKey === 'fcm_router') {
1630
+ resetInstallEndpointsOverlay()
1631
+ state.routerDashboardOpen = true
1632
+ return
1633
+ }
1576
1634
  state.installEndpointsPhase = 'providers'
1577
1635
  state.installEndpointsCursor = 0
1578
1636
  state.installEndpointsScrollOffset = 0
@@ -1617,10 +1675,25 @@ export function createKeyHandler(ctx) {
1617
1675
  if (!selectedToolMode) return
1618
1676
  state.installEndpointsToolMode = selectedToolMode
1619
1677
  state.installEndpointsConnectionMode = 'direct'
1620
- state.installEndpointsPhase = 'scope'
1621
- state.installEndpointsCursor = 0
1622
- state.installEndpointsScrollOffset = 0
1623
- state.installEndpointsErrorMsg = null
1678
+
1679
+ if (state.installEndpointsProviderKey === 'fcm_router') {
1680
+ state.installEndpointsScope = 'all'
1681
+ try {
1682
+ await runInstallEndpointsFlow()
1683
+ } catch (error) {
1684
+ state.installEndpointsResult = {
1685
+ type: 'error',
1686
+ title: 'Install failed',
1687
+ lines: [error instanceof Error ? error.message : String(error)],
1688
+ }
1689
+ state.installEndpointsPhase = 'result'
1690
+ }
1691
+ } else {
1692
+ state.installEndpointsPhase = 'scope'
1693
+ state.installEndpointsCursor = 0
1694
+ state.installEndpointsScrollOffset = 0
1695
+ state.installEndpointsErrorMsg = null
1696
+ }
1624
1697
  }
1625
1698
  return
1626
1699
  }
@@ -1695,7 +1768,11 @@ export function createKeyHandler(ctx) {
1695
1768
 
1696
1769
  if (state.installEndpointsPhase === 'result') {
1697
1770
  if (key.name === 'return' || key.name === 'y') {
1771
+ const wasFcmRouter = state.installEndpointsProviderKey === 'fcm_router'
1698
1772
  resetInstallEndpointsOverlay()
1773
+ if (wasFcmRouter) {
1774
+ state.routerDashboardOpen = true
1775
+ }
1699
1776
  }
1700
1777
  return
1701
1778
  }
@@ -2593,10 +2670,26 @@ export function createKeyHandler(ctx) {
2593
2670
 
2594
2671
  // 📖 Profile system removed - API keys now persist permanently across all sessions
2595
2672
 
2596
- // 📖 Shift+R intentionally stays unadvertised in the main UI, but remains
2597
- // 📖 available as a tester entry point for the Router Dashboard.
2673
+ // 📖 Shift+R: Open Router Dashboard AND launch OpenCode with the selected model.
2674
+ // 📖 If the dashboard is already open, just bring it to front.
2598
2675
  if (key.name === 'r' && key.shift && !key.ctrl && !key.meta) {
2676
+ if (state.routerDashboardOpen) {
2677
+ state.routerDashboardScrollOffset = 0
2678
+ return
2679
+ }
2599
2680
  openRouterDashboardOverlay(state)
2681
+ // 📖 If a model is selected in the main table, launch OpenCode with it after opening dashboard
2682
+ const selected = state.visibleSorted?.[state.cursor]
2683
+ if (selected && selected.providerKey && selected.modelId) {
2684
+ const launchModel = {
2685
+ modelId: selected.modelId,
2686
+ label: selected.label,
2687
+ tier: selected.tier,
2688
+ providerKey: selected.providerKey,
2689
+ }
2690
+ // 📖 Launch asynchronously — don't await, dashboard renders while OpenCode starts
2691
+ void startOpenCode(launchModel, state.config)
2692
+ }
2600
2693
  return
2601
2694
  }
2602
2695
 
@@ -2647,10 +2740,23 @@ export function createKeyHandler(ctx) {
2647
2740
  return
2648
2741
  }
2649
2742
 
2650
- // 📖 E toggles "Show only configured & working models": hides models whose provider has no configured API key, or whose health status is noauth/auth_error (but keeps timeout and 429).
2651
- // 📖 The preference is saved globally.
2743
+ // 📖 E cycles: Normal Configured only Usable only Normal
2744
+ // 📖 Configured only: hides models with no key or noauth/auth_error health
2745
+ // 📖 Usable only: only shows models with Health UP and Verdict ≤ Slow (Perfect/Normal/Slow)
2652
2746
  if (key.name === 'e') {
2653
- state.hideUnconfiguredModels = !state.hideUnconfiguredModels
2747
+ if (!state.hideUnconfiguredModels && !state.bestModeOnly) {
2748
+ // Normal → Configured only
2749
+ state.hideUnconfiguredModels = true
2750
+ state.bestModeOnly = false
2751
+ } else if (state.hideUnconfiguredModels && !state.bestModeOnly) {
2752
+ // Configured only → Usable only
2753
+ state.hideUnconfiguredModels = false
2754
+ state.bestModeOnly = true
2755
+ } else {
2756
+ // Usable only → Normal
2757
+ state.hideUnconfiguredModels = false
2758
+ state.bestModeOnly = false
2759
+ }
2654
2760
  if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
2655
2761
  state.config.settings.hideUnconfiguredModels = state.hideUnconfiguredModels
2656
2762
  saveConfig(state.config)
@@ -2704,15 +2810,9 @@ export function createKeyHandler(ctx) {
2704
2810
  return
2705
2811
  }
2706
2812
 
2707
- // 📖 Changelog overlay key: N = toggle changelog overlay
2813
+ // 📖 Reset view key: N = reset all filters and sort back to default (Health)
2708
2814
  if (key.name === 'n') {
2709
- state.changelogOpen = !state.changelogOpen
2710
- if (state.changelogOpen) {
2711
- state.changelogScrollOffset = 0
2712
- state.changelogPhase = 'index'
2713
- state.changelogCursor = 0
2714
- state.changelogSelectedVersion = null
2715
- }
2815
+ resetViewSettings()
2716
2816
  return
2717
2817
  }
2718
2818
 
package/src/overlays.js CHANGED
@@ -343,7 +343,7 @@ export function createOverlayRenderers(state, deps) {
343
343
  const lines = []
344
344
  const cursorLineByRow = {}
345
345
  const providerChoices = getConfiguredInstallableProviders(state.config)
346
- const toolChoices = getInstallTargetModes()
346
+ const toolChoices = getInstallTargetModes().filter(t => !(state.installEndpointsProviderKey === 'fcm_router' && t === 'fcm_router'))
347
347
  const totalSteps = 4
348
348
  const scopeChoices = [
349
349
  {
@@ -357,9 +357,11 @@ export function createOverlayRenderers(state, deps) {
357
357
  hint: 'Choose a smaller curated subset for a cleaner model picker.',
358
358
  },
359
359
  ]
360
- const selectedProviderLabel = state.installEndpointsProviderKey
361
- ? (sources[state.installEndpointsProviderKey]?.name || state.installEndpointsProviderKey)
362
- : '—'
360
+ const selectedProviderLabel = state.installEndpointsProviderKey === 'fcm_router'
361
+ ? 'Smart Router Daemon'
362
+ : state.installEndpointsProviderKey
363
+ ? (sources[state.installEndpointsProviderKey]?.name || state.installEndpointsProviderKey)
364
+ : '—'
363
365
 
364
366
  // 📖 Resolve tool label from metadata instead of hard-coded switch
365
367
  const selectedToolLabel = state.installEndpointsToolMode
@@ -926,7 +928,7 @@ export function createOverlayRenderers(state, deps) {
926
928
  lines.push(` ${heading('Controls')}`)
927
929
  lines.push(` ${key('W')} Toggle ping mode ${hint('(speed 2s → normal 10s → slow 30s → forced 4s)')}`)
928
930
  lines.push(` ${key('Ctrl+P')} Open ⚡️ command palette ${hint('(search and run actions quickly)')}`)
929
- lines.push(` ${key('E')} Toggle configured models only ${hint('(enabled by default)')}`)
931
+ lines.push(` ${key('E')} Cycle filter mode ${hint('(Normal Configured only → Usable only)')}`)
930
932
  lines.push(` ${key('Z')} Cycle tool mode ${hint('(📦 OpenCode → π Pi → 🪼 jcode → 📦 Desktop → 🦞 OpenClaw → 💘 Crush → 🪿 Goose → 🛠 Aider → 🐉 Qwen → 🤲 OpenHands → ⚡ Amp → 🦘 Rovo → ♊ Gemini)')}`)
931
933
  lines.push(` ${key('F')} Toggle favorite on selected row ${hint('(1️⃣2️⃣3️⃣ = router fallback order, capped at 🔟)')}`)
932
934
  lines.push(` ${key('⇧↑/⇧↓')} Reorder selected favorite up/down ${hint('(changes router priority)')}`)
@@ -938,7 +940,7 @@ export function createOverlayRenderers(state, deps) {
938
940
  lines.push(` ${key('P')} Open settings ${hint('(manage API keys, provider toggles, updates, legacy cleanup)')}`)
939
941
  // 📖 Profile system removed - API keys now persist permanently across all sessions
940
942
  lines.push(` ${key('Ctrl+P')} Reset view settings ${hint('(search "Reset view" in the command palette)')}`)
941
- lines.push(` ${key('N')} Changelog ${hint('(📋 browse all versions, Enter to view details)')}`)
943
+ lines.push(` ${key('N')} Reset view ${hint('(🔄 reset all filters & sort back to default)')}`)
942
944
  lines.push(` ${key('I')} / ${key('Esc')} Show/hide this help`)
943
945
  lines.push(` ${key('Ctrl+C')} Exit`)
944
946
  lines.push('')