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 +6 -6
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/app.js +31 -7
- package/src/command-palette.js +2 -2
- package/src/endpoint-installer.js +17 -0
- package/src/favorites.js +22 -1
- package/src/key-handler.js +135 -35
- package/src/overlays.js +8 -6
- package/src/render-table.js +15 -5
- package/src/router-daemon.js +11 -9
- package/src/router-dashboard.js +99 -22
- package/web/dist/assets/{index-A4ph-qbA.js → index-CwIXdKao.js} +1 -1
- package/web/dist/index.html +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
## [0.3.
|
|
1
|
+
## [0.3.64] - 2026-05-06
|
|
2
2
|
|
|
3
|
-
###
|
|
3
|
+
### Fixed
|
|
4
4
|
|
|
5
|
-
- **E
|
|
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
|
-
###
|
|
7
|
+
### Changed
|
|
9
8
|
|
|
10
|
-
- **
|
|
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` |
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
})
|
package/src/command-palette.js
CHANGED
|
@@ -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',
|
|
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
|
+
}
|
package/src/key-handler.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
// 📖
|
|
1451
|
-
if (key.
|
|
1452
|
-
|
|
1453
|
-
|
|
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
|
-
// 📖
|
|
1470
|
-
if (key.
|
|
1471
|
-
|
|
1472
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1621
|
-
state.
|
|
1622
|
-
|
|
1623
|
-
|
|
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
|
|
2597
|
-
// 📖
|
|
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
|
|
2651
|
-
// 📖
|
|
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
|
|
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
|
-
// 📖
|
|
2813
|
+
// 📖 Reset view key: N = reset all filters and sort back to default (Health)
|
|
2708
2814
|
if (key.name === 'n') {
|
|
2709
|
-
|
|
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
|
-
?
|
|
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')}
|
|
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')}
|
|
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('')
|