free-coding-models 0.5.86 → 0.5.87

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.
Files changed (163) hide show
  1. package/README.md +110 -881
  2. package/bin/free-coding-models.js +4 -1
  3. package/changelog/v0.5.87.md +48 -0
  4. package/package.json +8 -5
  5. package/sources.js +4 -4
  6. package/src/core/cache.js +3 -1
  7. package/src/core/config.js +43 -5
  8. package/src/core/endpoint-installer.js +23 -12
  9. package/src/core/installed-models-manager.js +43 -20
  10. package/src/core/kilo-config.js +51 -5
  11. package/src/core/kilo.js +5 -3
  12. package/src/core/legacy-proxy-cleanup.js +22 -5
  13. package/src/core/model-merger.js +4 -0
  14. package/src/core/models-drift.js +9 -0
  15. package/src/core/opencode-config.js +57 -5
  16. package/src/core/opencode.js +67 -41
  17. package/src/core/playground.js +7 -0
  18. package/src/core/probe-cache.js +35 -4
  19. package/src/core/provider-key-tester.js +10 -2
  20. package/src/core/provider-metadata.js +10 -0
  21. package/src/core/provider-quota-fetchers.js +14 -7
  22. package/src/core/router-daemon.js +208 -26
  23. package/src/core/runtime-telemetry.js +27 -1
  24. package/src/core/shared-helpers.js +12 -1
  25. package/src/core/shell-env.js +3 -1
  26. package/src/core/telemetry.js +18 -5
  27. package/src/core/tool-launchers.js +9 -6
  28. package/src/core/updater.js +28 -2
  29. package/src/core/utils.js +76 -71
  30. package/src/tui/app.js +38 -14
  31. package/src/tui/command-palette.js +22 -2
  32. package/src/tui/key-handler.js +123 -179
  33. package/src/tui/overlays.js +21 -71
  34. package/src/tui/render-helpers.js +62 -4
  35. package/src/tui/render-table.js +34 -3
  36. package/src/tui/theme.js +36 -5
  37. package/src/tui/tui-filters.js +28 -8
  38. package/src/tui/tui-state.js +22 -6
  39. package/web/dist/assets/index-BpTpPS0g.js +44 -0
  40. package/web/dist/assets/index-CAzFIt8P.css +1 -0
  41. package/web/dist/index.html +5 -9
  42. package/web/index.html +3 -7
  43. package/web/server.js +97 -23
  44. package/web/vite.config.js +8 -0
  45. package/web/app.legacy.js +0 -900
  46. package/web/dist/assets/index-C5-Ywvv3.css +0 -1
  47. package/web/dist/assets/index-Dm-Xfby9.js +0 -40
  48. package/web/public/favicon.ico +0 -0
  49. package/web/public/favicons/apple-touch-icon.png +0 -0
  50. package/web/public/favicons/browserconfig.xml +0 -12
  51. package/web/public/favicons/favicon-16x16.png +0 -0
  52. package/web/public/favicons/favicon-192x192.png +0 -0
  53. package/web/public/favicons/favicon-32x32.png +0 -0
  54. package/web/public/favicons/favicon-48x48.png +0 -0
  55. package/web/public/favicons/favicon-512x512.png +0 -0
  56. package/web/public/favicons/favicon-96x96.png +0 -0
  57. package/web/public/favicons/favicon.ico +0 -0
  58. package/web/public/favicons/mstile-150x150.png +0 -0
  59. package/web/public/favicons/mstile-310x150.png +0 -0
  60. package/web/public/favicons/mstile-310x310.png +0 -0
  61. package/web/public/favicons/mstile-512x512.png +0 -0
  62. package/web/public/favicons/mstile-70x70.png +0 -0
  63. package/web/public/favicons/site.webmanifest +0 -25
  64. package/web/src/App.jsx +0 -521
  65. package/web/src/components/analytics/AnalyticsView.jsx +0 -147
  66. package/web/src/components/analytics/AnalyticsView.module.css +0 -190
  67. package/web/src/components/analytics/TokenUsagePanel.jsx +0 -105
  68. package/web/src/components/analytics/TokenUsagePanel.module.css +0 -126
  69. package/web/src/components/atoms/AILatencyCell.jsx +0 -38
  70. package/web/src/components/atoms/AILatencyCell.module.css +0 -51
  71. package/web/src/components/atoms/HealthCell.jsx +0 -143
  72. package/web/src/components/atoms/HealthCell.module.css +0 -60
  73. package/web/src/components/atoms/LastPingCell.jsx +0 -35
  74. package/web/src/components/atoms/LastPingCell.module.css +0 -45
  75. package/web/src/components/atoms/MoodCell.jsx +0 -25
  76. package/web/src/components/atoms/MoodCell.module.css +0 -6
  77. package/web/src/components/atoms/NoKeyIcon.jsx +0 -65
  78. package/web/src/components/atoms/NoKeyIcon.module.css +0 -30
  79. package/web/src/components/atoms/ProviderLogo.jsx +0 -187
  80. package/web/src/components/atoms/ProviderLogo.module.css +0 -103
  81. package/web/src/components/atoms/RankCell.jsx +0 -9
  82. package/web/src/components/atoms/RankCell.module.css +0 -9
  83. package/web/src/components/atoms/Sparkline.jsx +0 -63
  84. package/web/src/components/atoms/StabilityCell.jsx +0 -18
  85. package/web/src/components/atoms/StabilityCell.module.css +0 -13
  86. package/web/src/components/atoms/StatusDot.jsx +0 -13
  87. package/web/src/components/atoms/StatusDot.module.css +0 -29
  88. package/web/src/components/atoms/TPSCell.jsx +0 -36
  89. package/web/src/components/atoms/TPSCell.module.css +0 -44
  90. package/web/src/components/atoms/TierBadge.jsx +0 -10
  91. package/web/src/components/atoms/TierBadge.module.css +0 -29
  92. package/web/src/components/atoms/Toast.jsx +0 -25
  93. package/web/src/components/atoms/Toast.module.css +0 -41
  94. package/web/src/components/atoms/ToastContainer.jsx +0 -16
  95. package/web/src/components/atoms/ToastContainer.module.css +0 -10
  96. package/web/src/components/atoms/VerdictBadge.jsx +0 -36
  97. package/web/src/components/atoms/VerdictBadge.module.css +0 -40
  98. package/web/src/components/changelog/ChangelogView.jsx +0 -135
  99. package/web/src/components/changelog/ChangelogView.module.css +0 -159
  100. package/web/src/components/dashboard/DetailPanel.jsx +0 -217
  101. package/web/src/components/dashboard/DetailPanel.module.css +0 -260
  102. package/web/src/components/dashboard/ExpandedDetailRow.jsx +0 -361
  103. package/web/src/components/dashboard/ExpandedDetailRow.module.css +0 -356
  104. package/web/src/components/dashboard/ExportModal.jsx +0 -84
  105. package/web/src/components/dashboard/ExportModal.module.css +0 -99
  106. package/web/src/components/dashboard/FilterBar.jsx +0 -338
  107. package/web/src/components/dashboard/FilterBar.module.css +0 -342
  108. package/web/src/components/dashboard/ModelTable.jsx +0 -672
  109. package/web/src/components/dashboard/ModelTable.module.css +0 -446
  110. package/web/src/components/dashboard/ProviderDropdown.jsx +0 -156
  111. package/web/src/components/dashboard/ProviderDropdown.module.css +0 -248
  112. package/web/src/components/help/HelpView.jsx +0 -201
  113. package/web/src/components/help/HelpView.module.css +0 -156
  114. package/web/src/components/install/InstallEndpointsView.jsx +0 -309
  115. package/web/src/components/install/InstallEndpointsView.module.css +0 -427
  116. package/web/src/components/installed/InstalledModelsView.jsx +0 -89
  117. package/web/src/components/installed/InstalledModelsView.module.css +0 -200
  118. package/web/src/components/launch/IncompatibleFallbackModal.jsx +0 -69
  119. package/web/src/components/launch/LaunchButton.jsx +0 -30
  120. package/web/src/components/launch/LaunchButton.module.css +0 -35
  121. package/web/src/components/launch/LaunchModal.module.css +0 -125
  122. package/web/src/components/layout/Footer.jsx +0 -19
  123. package/web/src/components/layout/Footer.module.css +0 -10
  124. package/web/src/components/layout/Header.jsx +0 -265
  125. package/web/src/components/layout/Header.module.css +0 -357
  126. package/web/src/components/palette/CommandPalette.jsx +0 -301
  127. package/web/src/components/palette/CommandPalette.module.css +0 -121
  128. package/web/src/components/playground/PlaygroundChat.jsx +0 -527
  129. package/web/src/components/playground/PlaygroundChat.module.css +0 -382
  130. package/web/src/components/playground/PlaygroundView.jsx +0 -371
  131. package/web/src/components/playground/PlaygroundView.module.css +0 -504
  132. package/web/src/components/recommend/RecommendView.jsx +0 -121
  133. package/web/src/components/recommend/RecommendView.module.css +0 -57
  134. package/web/src/components/router/RouterView.jsx +0 -1212
  135. package/web/src/components/router/RouterView.module.css +0 -1289
  136. package/web/src/components/settings/SettingsView.jsx +0 -555
  137. package/web/src/components/settings/SettingsView.module.css +0 -550
  138. package/web/src/components/tools/ToolPicker.jsx +0 -86
  139. package/web/src/components/tools/ToolPicker.module.css +0 -97
  140. package/web/src/components/update/UpdateChip.jsx +0 -104
  141. package/web/src/components/update/UpdateChip.module.css +0 -146
  142. package/web/src/global.css +0 -298
  143. package/web/src/hooks/urlState.constants.js +0 -28
  144. package/web/src/hooks/useChangelog.js +0 -51
  145. package/web/src/hooks/useColumnSizing.js +0 -211
  146. package/web/src/hooks/useFavorites.js +0 -150
  147. package/web/src/hooks/useFilter.js +0 -310
  148. package/web/src/hooks/useInstalledModels.js +0 -42
  149. package/web/src/hooks/useRecommend.js +0 -67
  150. package/web/src/hooks/useRouterDashboard.js +0 -133
  151. package/web/src/hooks/useSSE.js +0 -73
  152. package/web/src/hooks/useSocket.js +0 -203
  153. package/web/src/hooks/useTheme.js +0 -83
  154. package/web/src/hooks/useTokenUsage.js +0 -103
  155. package/web/src/hooks/useToolMode.js +0 -77
  156. package/web/src/hooks/useUpdateChecker.js +0 -91
  157. package/web/src/hooks/useUrlState.js +0 -170
  158. package/web/src/main.jsx +0 -15
  159. package/web/src/utils/download.js +0 -15
  160. package/web/src/utils/format.js +0 -42
  161. package/web/src/utils/m3.js +0 -57
  162. package/web/src/utils/ranks.js +0 -37
  163. package/web/styles.legacy.css +0 -963
@@ -48,7 +48,7 @@ import { ENV_VAR_NAMES, isWindows } from './provider-metadata.js'
48
48
  import { getToolMeta, TOOL_METADATA } from './tool-metadata.js'
49
49
  import { PROVIDER_METADATA } from './provider-metadata.js'
50
50
  import { resolveToolBinaryPath } from './tool-bootstrap.js'
51
- import { ensureDir, readJson, writeJson } from './shared-helpers.js'
51
+ import { ensureDir, readJson, writeJson, shellSingleQuote } from './shared-helpers.js'
52
52
  import { parseContextWindow } from './endpoint-installer.js'
53
53
  import { resolveCloudflareUrl } from './ping.js'
54
54
 
@@ -70,9 +70,11 @@ function parseCtxToTokens(ctx) {
70
70
  if (!match) return null
71
71
  const num = parseFloat(match[1])
72
72
  const suffix = match[2].toLowerCase()
73
- // 📖 LLM token counts use binary (1024), not decimal (1000).
74
- if (suffix === 'k') return Math.round(num * 1024)
75
- if (suffix === 'm') return Math.round(num * 1024 * 1024)
73
+ // 📖 Catalog ctx strings use DECIMAL semantics ("1M" = 1,000,000), matching
74
+ // 📖 parseContextWindow in endpoint-installer.js. The old 1024 multipliers made
75
+ // 📖 the two parsers disagree (1048576 vs 1000000) for the same catalog value.
76
+ if (suffix === 'k') return Math.round(num * 1000)
77
+ if (suffix === 'm') return Math.round(num * 1000 * 1000)
76
78
  return Math.round(num)
77
79
  }
78
80
 
@@ -414,10 +416,11 @@ function writeOpenHandsEnv(model, apiKey, baseUrl, paths = getDefaultToolPaths()
414
416
  const backupPath = backupIfExists(filePath)
415
417
  const lines = [
416
418
  '# 📖 Managed by free-coding-models',
417
- `export OPENAI_API_KEY="${apiKey}"`,
419
+ // 📖 Single-quote the key so quotes/$()/backticks in it stay literal on `source`.
420
+ `export OPENAI_API_KEY=${shellSingleQuote(apiKey)}`,
418
421
  `export OPENAI_BASE_URL="${baseUrl}"`,
419
422
  `export OPENAI_MODEL="${model.modelId}"`,
420
- `export LLM_API_KEY="${apiKey}"`,
423
+ `export LLM_API_KEY=${shellSingleQuote(apiKey)}`,
421
424
  `export LLM_BASE_URL="${baseUrl}"`,
422
425
  `export LLM_MODEL="openai/${model.modelId}"`,
423
426
  ]
@@ -31,6 +31,7 @@
31
31
  * → resolveCurrentNpmInstallTarget() — Detect the npm prefix that owns the active package
32
32
  * → getInstallArgs(pm, version) — Build correct { bin, args } per package manager
33
33
  * → getManualInstallCmd(pm, version) — Human-readable install command string for error messages
34
+ * → isNewerVersion(remote, local) - Semver compare: true only when remote is greater
34
35
  * → checkForUpdateDetailed() — Fetch npm latest with explicit error info
35
36
  * → checkForUpdate() — Startup wrapper, returns version string or null
36
37
  * → isPackageDevMode() — Detect git/dev checkouts that must not self-update
@@ -38,7 +39,7 @@
38
39
  * → runUpdate(latestVersion) — Install new version via detected PM + relaunch
39
40
  * @exports
40
41
  * detectPackageManager, resolveCurrentNpmInstallTarget, getInstallArgs, getManualInstallCmd,
41
- * checkForUpdateDetailed, checkForUpdate, isPackageDevMode,
42
+ * isNewerVersion, checkForUpdateDetailed, checkForUpdate, isPackageDevMode,
42
43
  * enforceMandatoryStartupUpdate, runUpdate, fetchLastReleaseDate
43
44
  *
44
45
  * @see bin/free-coding-models.js — calls checkForUpdate() at startup and runUpdate() on confirm
@@ -232,6 +233,31 @@ export function getManualInstallCmd(pm, version, options = {}) {
232
233
  return [bin, ...args].map(shellQuoteArg).join(' ')
233
234
  }
234
235
 
236
+ /**
237
+ * 📖 isNewerVersion: dependency-free semver comparison of dotted numeric versions.
238
+ * 📖 Update availability used to be a string inequality, so a dist-tag rollback
239
+ * 📖 (remote < local) triggered a pointless "downgrade" install. Only a strictly
240
+ * 📖 GREATER remote version counts as an update now.
241
+ * @param {string} remoteVersion
242
+ * @param {string} localVersion
243
+ * @returns {boolean} true only when remoteVersion > localVersion
244
+ */
245
+ export function isNewerVersion(remoteVersion, localVersion) {
246
+ const parseParts = (v) => String(v ?? '')
247
+ .trim()
248
+ .replace(/^v/i, '')
249
+ .split('.')
250
+ .map(part => parseInt(part, 10) || 0)
251
+ const remote = parseParts(remoteVersion)
252
+ const local = parseParts(localVersion)
253
+ const len = Math.max(remote.length, local.length)
254
+ for (let i = 0; i < len; i++) {
255
+ const diff = (remote[i] || 0) - (local[i] || 0)
256
+ if (diff !== 0) return diff > 0
257
+ }
258
+ return false
259
+ }
260
+
235
261
  /**
236
262
  * 📖 checkForUpdateDetailed: Fetch npm latest version with explicit error details.
237
263
  * 📖 Used by settings manual-check flow to display meaningful status in the UI.
@@ -242,7 +268,7 @@ export async function checkForUpdateDetailed() {
242
268
  const res = await fetch('https://registry.npmjs.org/free-coding-models/latest', { signal: AbortSignal.timeout(5000) })
243
269
  if (!res.ok) return { latestVersion: null, error: `HTTP ${res.status}` }
244
270
  const data = await res.json()
245
- if (data.version && data.version !== LOCAL_VERSION) return { latestVersion: data.version, error: null }
271
+ if (data.version && isNewerVersion(data.version, LOCAL_VERSION)) return { latestVersion: data.version, error: null }
246
272
  return { latestVersion: null, error: null }
247
273
  } catch (error) {
248
274
  const message = error instanceof Error ? error.message : 'Unknown error'
package/src/core/utils.js CHANGED
@@ -140,8 +140,13 @@ export const NEW_MODELS = new Set([
140
140
  ]);
141
141
 
142
142
  export const getVerdict = (r) => {
143
+ // 📖 401/403 rows can still carry fast measurable pings, but calling them
144
+ // 📖 "Perfect" let --fiable / findBestModel recommend a model that cannot serve
145
+ // 📖 a single authenticated request. Auth-failing rows always get the
146
+ // 📖 least-trustworthy health label instead.
147
+ if (r.status === 'auth_error' || r.status === 'noauth') return 'Not Active'
143
148
  const avg = getAvg(r)
144
- const wasUpBefore = r.pings.length > 0 && r.pings.some(p => p.code === '200')
149
+ const wasUpBefore = (r.pings || []).length > 0 && r.pings.some(p => p.code === '200')
145
150
 
146
151
  if (r.httpCode === '429') return 'Overloaded'
147
152
  if ((r.status === 'timeout' || r.status === 'down') && wasUpBefore) return 'Unstable'
@@ -200,9 +205,10 @@ export const getVerdict = (r) => {
200
205
  // 📖 Displayed as "Up%" column in the TUI — e.g., "85%" means 85% of pings got HTTP 200.
201
206
  // 📖 This metric is useful for identifying models that are technically "up" but flaky.
202
207
  export const getUptime = (r) => {
203
- if (r.pings.length === 0) return 0
204
- const successful = r.pings.filter(p => p.code === '200').length
205
- return Math.round((successful / r.pings.length) * 100)
208
+ const pings = r.pings || []
209
+ if (pings.length === 0) return 0
210
+ const successful = pings.filter(p => p.code === '200').length
211
+ return Math.round((successful / pings.length) * 100)
206
212
  }
207
213
 
208
214
  // 📖 getP95: Calculate the 95th percentile latency from measurable pings (HTTP 200/401).
@@ -303,11 +309,13 @@ export const sortResults = (results, sortColumn, sortDirection, { benchmarkResul
303
309
  cmp = TIER_ORDER.indexOf(a.tier) - TIER_ORDER.indexOf(b.tier)
304
310
  break
305
311
  case 'origin':
306
- // 📖 Sort by providerKey (or fallback to modelId prefix) for multi-provider support
307
- cmp = (a.providerKey ?? 'nvidia').localeCompare(b.providerKey ?? 'nvidia')
312
+ // 📖 Sort by providerKey (or fallback to modelId prefix) for multi-provider support.
313
+ // 📖 Explicit 'en' locale: the default is ICU-dependent, so row order would
314
+ // 📖 otherwise vary between machines.
315
+ cmp = (a.providerKey ?? 'nvidia').localeCompare(b.providerKey ?? 'nvidia', 'en')
308
316
  break
309
317
  case 'model':
310
- cmp = a.label.localeCompare(b.label)
318
+ cmp = a.label.localeCompare(b.label, 'en')
311
319
  break
312
320
  case 'ping': {
313
321
  // 📖 Sort by LAST ping only — gives a real-time "right now" snapshot
@@ -333,7 +341,7 @@ export const sortResults = (results, sortColumn, sortDirection, { benchmarkResul
333
341
  break
334
342
  }
335
343
  case 'condition':
336
- cmp = a.status.localeCompare(b.status)
344
+ cmp = a.status.localeCompare(b.status, 'en')
337
345
  break
338
346
  case 'verdict': {
339
347
  // 📖 Sort by verdict order — "Perfect" first, "Pending" last
@@ -391,6 +399,10 @@ export const sortResults = (results, sortColumn, sortDirection, { benchmarkResul
391
399
  break
392
400
  }
393
401
 
402
+ // 📖 Non-finite differences (e.g. Infinity - Infinity) collapse to 0: a NaN
403
+ // 📖 comparator result makes sort order machine-dependent and unstable.
404
+ if (!Number.isFinite(cmp)) cmp = 0
405
+
394
406
  // 📖 Flip comparison for descending order
395
407
  return sortDirection === 'asc' ? cmp : -cmp
396
408
  })
@@ -485,7 +497,7 @@ export function findBestModel(results) {
485
497
  // --daemon-status, --no-telemetry, --json, --help/-h (case-insensitive)
486
498
  // --playground / playground subcommand (open the in-TUI chat playground)
487
499
  // --fix-permissions / --yes / -y (auto-fix config permissions without prompting)
488
- // - Value flag: --tier <letter> (the next non-flag arg is the tier value)
500
+ // - Value flag: --tier <letter> (also accepts the --tier=S equals form)
489
501
  // - Probe-cache flags (t1):
490
502
  // --reprobe / --no-cache (boolean) — force-rebuild the probe cache this run
491
503
  // --probe-ttl <ms> (value) — override the 24h default TTL
@@ -500,62 +512,56 @@ export function findBestModel(results) {
500
512
  // reprobeMode, probeTtlMs, showBrokenMode }
501
513
  //
502
514
  // 📖 Note: apiKey may be null here — the main CLI falls back to env vars and saved config.
515
+
516
+ // 📖 findValueFlag: collect every occurrence of a value flag, in both "--flag value"
517
+ // 📖 and "--flag=value" forms. Equals form used to be silently ignored (the lookup
518
+ // 📖 only matched the bare flag), and repeated flags used to leak their extra values
519
+ // 📖 into the positional API-key slot because only the first index was marked used.
520
+ // 📖 Returns { indices, values }: indices point at the VALUE args (for the equals
521
+ // 📖 form, at the "--flag=value" arg itself, which is already treated as a flag).
522
+ function findValueFlag(args, flag) {
523
+ const indices = []
524
+ const values = []
525
+ for (const [i, arg] of args.entries()) {
526
+ const lower = arg.toLowerCase()
527
+ if (lower.startsWith(`${flag}=`)) {
528
+ indices.push(i)
529
+ values.push(arg.slice(flag.length + 1))
530
+ } else if (lower === flag && args[i + 1] && !args[i + 1].startsWith('--')) {
531
+ indices.push(i + 1)
532
+ values.push(args[i + 1])
533
+ }
534
+ }
535
+ return { indices, values }
536
+ }
537
+
538
+ // 📖 First usable value of a flag, or null when the flag was not passed (or was
539
+ // 📖 passed with an empty value like "--tier=").
540
+ function firstFlagValue(found) {
541
+ return found.values.length > 0 && found.values[0] !== '' ? found.values[0] : null
542
+ }
543
+
503
544
  export function parseArgs(argv) {
504
545
  const args = argv.slice(2)
505
546
  let apiKey = null
506
547
  const flags = []
507
548
 
508
- // 📖 Determine which arg indices are consumed by --tier so we skip them
509
- const tierIdx = args.findIndex(a => a.toLowerCase() === '--tier')
510
- const tierValueIdx = (tierIdx !== -1 && args[tierIdx + 1] && !args[tierIdx + 1].startsWith('--'))
511
- ? tierIdx + 1
512
- : -1
513
-
514
- // New value flags
515
- const sortIdx = args.findIndex(a => a.toLowerCase() === '--sort')
516
- const sortValueIdx = (sortIdx !== -1 && args[sortIdx + 1] && !args[sortIdx + 1].startsWith('--'))
517
- ? sortIdx + 1
518
- : -1
519
-
520
- const originIdx = args.findIndex(a => a.toLowerCase() === '--origin')
521
- const originValueIdx = (originIdx !== -1 && args[originIdx + 1] && !args[originIdx + 1].startsWith('--'))
522
- ? originIdx + 1
523
- : -1
524
-
525
- const pingIntervalIdx = args.findIndex(a => a.toLowerCase() === '--ping-interval')
526
- const pingIntervalValueIdx = (pingIntervalIdx !== -1 && args[pingIntervalIdx + 1] && !args[pingIntervalIdx + 1].startsWith('--'))
527
- ? pingIntervalIdx + 1
528
- : -1
529
-
530
- // 📖 --probe-ttl <ms> — override the 24h probe-cache TTL (power users / debugging)
531
- const probeTtlIdx = args.findIndex(a => a.toLowerCase() === '--probe-ttl')
532
- const probeTtlValueIdx = (probeTtlIdx !== -1 && args[probeTtlIdx + 1] && !args[probeTtlIdx + 1].startsWith('--'))
533
- ? probeTtlIdx + 1
534
- : -1
535
-
536
- // 📖 --sync-set [name] — auto-discover and live-probe models into a named router set
537
- const syncSetIdx = args.findIndex(a => a.toLowerCase() === '--sync-set')
538
- const syncSetValueIdx = (syncSetIdx !== -1 && args[syncSetIdx + 1] && !args[syncSetIdx + 1].startsWith('--'))
539
- ? syncSetIdx + 1
540
- : -1
541
-
542
- // 📖 --config-dir <dir> — override where FCM's config files (config.json + backups/)
543
- // 📖 live. Any directory works, e.g. --config-dir ~/.config/free-coding-models for the
544
- // 📖 XDG layout. The bin entry point persists this as FCM_CONFIG_DIR before imports.
545
- const configDirIdx = args.findIndex(a => a.toLowerCase() === '--config-dir')
546
- const configDirValueIdx = (configDirIdx !== -1 && args[configDirIdx + 1] && !args[configDirIdx + 1].startsWith('--'))
547
- ? configDirIdx + 1
548
- : -1
549
+ // 📖 Resolve all value flags up front so every occurrence (space or equals form)
550
+ // 📖 can be marked consumed before the positional API-key scan runs.
551
+ const tierFlag = findValueFlag(args, '--tier')
552
+ const sortFlag = findValueFlag(args, '--sort')
553
+ const originFlag = findValueFlag(args, '--origin')
554
+ const pingIntervalFlag = findValueFlag(args, '--ping-interval')
555
+ const probeTtlFlag = findValueFlag(args, '--probe-ttl')
556
+ const syncSetFlag = findValueFlag(args, '--sync-set')
557
+ const configDirFlag = findValueFlag(args, '--config-dir')
558
+ const driftThresholdFlag = findValueFlag(args, '--drift-threshold')
549
559
 
550
560
  // 📖 Set of arg indices that are values for flags (not API keys)
551
561
  const skipIndices = new Set()
552
- if (tierValueIdx !== -1) skipIndices.add(tierValueIdx)
553
- if (sortValueIdx !== -1) skipIndices.add(sortValueIdx)
554
- if (originValueIdx !== -1) skipIndices.add(originValueIdx)
555
- if (pingIntervalValueIdx !== -1) skipIndices.add(pingIntervalValueIdx)
556
- if (syncSetValueIdx !== -1) skipIndices.add(syncSetValueIdx)
557
- if (probeTtlValueIdx !== -1) skipIndices.add(probeTtlValueIdx)
558
- if (configDirValueIdx !== -1) skipIndices.add(configDirValueIdx)
562
+ for (const found of [tierFlag, sortFlag, originFlag, pingIntervalFlag, probeTtlFlag, syncSetFlag, configDirFlag, driftThresholdFlag]) {
563
+ for (const idx of found.indices) skipIndices.add(idx)
564
+ }
559
565
 
560
566
  for (const [i, arg] of args.entries()) {
561
567
  // 📖 -y is a boolean flag (security auto-fix), never an API key
@@ -610,7 +616,7 @@ export function parseArgs(argv) {
610
616
 
611
617
  // 📖 --sync-set [name] — auto-discover and populate a router set with best available models
612
618
  const syncSetMode = flags.includes('--sync-set')
613
- const syncSetName = syncSetValueIdx !== -1 ? args[syncSetValueIdx] : null
619
+ const syncSetName = firstFlagValue(syncSetFlag)
614
620
 
615
621
  // 📖 --web / --gui / web subcommand — launch the web dashboard instead of the TUI
616
622
  const webMode = flags.includes('--web') || flags.includes('--gui') || args[0] === 'web'
@@ -626,10 +632,11 @@ export function parseArgs(argv) {
626
632
  const hideUnconfigured = flags.includes('--hide-unconfigured')
627
633
  const showUnconfigured = flags.includes('--show-unconfigured')
628
634
 
629
- let tierFilter = tierValueIdx !== -1 ? args[tierValueIdx].toUpperCase() : null
630
- let sortColumn = sortValueIdx !== -1 ? args[sortValueIdx].toLowerCase() : null
631
- let originFilter = originValueIdx !== -1 ? args[originValueIdx] : null
632
- let pingInterval = pingIntervalValueIdx !== -1 ? parseInt(args[pingIntervalValueIdx], 10) : null
635
+ let tierFilter = firstFlagValue(tierFlag)?.toUpperCase() ?? null
636
+ let sortColumn = firstFlagValue(sortFlag)?.toLowerCase() ?? null
637
+ let originFilter = firstFlagValue(originFlag)
638
+ let pingIntervalRaw = firstFlagValue(pingIntervalFlag)
639
+ let pingInterval = pingIntervalRaw !== null ? parseInt(pingIntervalRaw, 10) : null
633
640
  let sortDirection = sortDesc ? 'desc' : (sortAscFlag ? 'asc' : null)
634
641
 
635
642
  // 📖 Profile system removed - API keys now persist permanently across all sessions
@@ -645,19 +652,14 @@ export function parseArgs(argv) {
645
652
  // 📖 --probe-ttl overrides the 24h default; --show-broken un-hides broken models for this run.
646
653
  const reprobeMode = flags.includes('--reprobe') || flags.includes('--no-cache')
647
654
  const showBrokenMode = flags.includes('--show-broken')
648
- const probeTtlRaw = probeTtlValueIdx !== -1 ? args[probeTtlValueIdx] : null
655
+ const probeTtlRaw = firstFlagValue(probeTtlFlag)
649
656
  const probeTtlMs = probeTtlRaw !== null ? parseInt(probeTtlRaw, 10) : null
650
657
 
651
658
  // 📖 Drift detection flags (t5):
652
659
  // 📖 --check-drift (boolean) — print a drift report vs models.dev, exit non-zero on mismatch.
653
660
  // 📖 --drift-threshold <N> (value) — only fail when N+ mismatches are found.
654
661
  const checkDriftMode = flags.includes('--check-drift')
655
- const driftThresholdIdx = args.findIndex(a => a.toLowerCase() === '--drift-threshold')
656
- const driftThresholdValueIdx = (driftThresholdIdx !== -1 && args[driftThresholdIdx + 1] && !args[driftThresholdIdx + 1].startsWith('--'))
657
- ? driftThresholdIdx + 1
658
- : -1
659
- if (driftThresholdValueIdx !== -1) skipIndices.add(driftThresholdValueIdx)
660
- const driftThresholdRaw = driftThresholdValueIdx !== -1 ? args[driftThresholdValueIdx] : null
662
+ const driftThresholdRaw = firstFlagValue(driftThresholdFlag)
661
663
  const driftThreshold = driftThresholdRaw !== null ? parseInt(driftThresholdRaw, 10) : null
662
664
 
663
665
  return {
@@ -716,7 +718,7 @@ export function parseArgs(argv) {
716
718
  // 📖 Runtime telemetry flag (t3) — see src/core/runtime-telemetry.js
717
719
  clearRuntimeMode,
718
720
  // 📖 Config location flag — see src/core/config.js getConfigDir()
719
- configDir: configDirValueIdx !== -1 ? args[configDirValueIdx] : null,
721
+ configDir: firstFlagValue(configDirFlag),
720
722
  // 📖 Security auto-fix flag - see src/core/security.js checkConfigSecurity()
721
723
  fixPermissionsMode,
722
724
  }
@@ -1031,6 +1033,9 @@ export function getVersionStatusInfo(updateState, latestVersion, startupLatestVe
1031
1033
  * @returns {string} JSON string of formatted results
1032
1034
  */
1033
1035
  export function formatResultsAsJSON(results, sortBy = 'avg', limit = 0) {
1036
+ // 📖 Only slice for a usable positive limit: a negative limit used to drop the
1037
+ // 📖 last row(s) via slice(0, -1), and 0/undefined means "all results".
1038
+ const effectiveLimit = Number.isInteger(limit) && limit > 0 ? limit : undefined
1034
1039
  const formatted = results
1035
1040
  .map((r, idx) => ({
1036
1041
  rank: r.idx || idx + 1,
@@ -1050,7 +1055,7 @@ export function formatResultsAsJSON(results, sortBy = 'avg', limit = 0) {
1050
1055
  status: r.status || null,
1051
1056
  httpCode: r.httpCode || null
1052
1057
  }))
1053
- .slice(0, limit || undefined)
1058
+ .slice(0, effectiveLimit)
1054
1059
 
1055
1060
  return JSON.stringify(formatted, null, 2)
1056
1061
  }
package/src/tui/app.js CHANGED
@@ -93,7 +93,7 @@ import chalk from 'chalk'
93
93
  import { createRequire } from 'module'
94
94
  import { randomUUID } from 'crypto'
95
95
  import { MODELS, sources } from '../../sources.js'
96
- import { getAvg, getVerdict, getUptime, getP95, getJitter, getStabilityScore, sortResults, filterByTier, findBestModel, parseArgs, TIER_ORDER, VERDICT_ORDER, TIER_LETTER_MAP, scoreModelForTask, getTopRecommendations, TASK_TYPES, PRIORITY_TYPES, CONTEXT_BUDGETS, formatCtxWindow, labelFromId, formatResultsAsJSON } from '../core/utils.js'
96
+ import { getAvg, getVerdict, getUptime, getP95, getJitter, getStabilityScore, sortResults, filterByTier, parseArgs, TIER_ORDER, VERDICT_ORDER, TIER_LETTER_MAP, scoreModelForTask, getTopRecommendations, TASK_TYPES, PRIORITY_TYPES, CONTEXT_BUDGETS, formatResultsAsJSON } from '../core/utils.js'
97
97
  import { loadConfig, saveConfig, getApiKey, resolveApiKeys, addApiKey, removeApiKey, isProviderEnabled, persistApiKeysForProvider } from '../core/config.js'
98
98
  import { buildMergedModels } from '../core/model-merger.js'
99
99
  import { loadOpenCodeConfig, saveOpenCodeConfig } from '../core/opencode-config.js'
@@ -101,7 +101,7 @@ import { usageForRow as _usageForRow } from '../core/usage-reader.js'
101
101
  import { buildProviderModelTokenKey, loadTokenUsageByProviderModel } from '../core/token-usage-reader.js'
102
102
  import { parseOpenRouterResponse, fetchProviderQuota as _fetchProviderQuotaFromModule, getAllQuotas as _getAllPassiveQuotasFromModule } from '../core/provider-quota-fetchers.js'
103
103
  import { isKnownQuotaTelemetry } from '../core/quota-capabilities.js'
104
- import { ALT_ENTER, ALT_LEAVE, ALT_HOME, PING_TIMEOUT, PING_INTERVAL, FPS, COL_MODEL, COL_MS, CELL_W, FRAMES, TIER_CYCLE, VERDICT_CYCLE, HEALTH_CYCLE, SETTINGS_OVERLAY_BG, HELP_OVERLAY_BG, RECOMMEND_OVERLAY_BG, OVERLAY_PANEL_WIDTH, TABLE_HEADER_LINES, TABLE_FOOTER_LINES, TABLE_FIXED_LINES, WIDTH_WARNING_MIN_COLS, msCell, spinCell } from '../core/constants.js'
104
+ import { ALT_ENTER, ALT_LEAVE, ALT_HOME, PING_TIMEOUT, PING_INTERVAL, FPS, FRAMES, TIER_CYCLE, VERDICT_CYCLE, HEALTH_CYCLE, SETTINGS_OVERLAY_BG, HELP_OVERLAY_BG, RECOMMEND_OVERLAY_BG, OVERLAY_PANEL_WIDTH, TABLE_HEADER_LINES, TABLE_FOOTER_LINES, TABLE_FIXED_LINES, WIDTH_WARNING_MIN_COLS } from '../core/constants.js'
105
105
  import { TIER_COLOR } from './tier-colors.js'
106
106
  import { resolveCloudflareUrl, buildPingRequest, ping, extractQuotaPercent, getProviderQuotaPercentCached, usagePlaceholderForProvider } from '../core/ping.js'
107
107
  import { runFiableMode, filterByTierOrExit, fetchOpenRouterFreeModels } from '../core/analysis.js'
@@ -109,12 +109,12 @@ import { PROVIDER_METADATA, ENV_VAR_NAMES, isWindows, isMac } from '../core/prov
109
109
  import { parseTelemetryEnv, isTelemetryDebugEnabled, telemetryDebug, ensureTelemetryConfig, getTelemetryDistinctId, getTelemetrySystem, getTelemetryTerminal, isTelemetryEnabled, sendUsageTelemetry } from '../core/telemetry.js'
110
110
  import { ensureFavoritesConfig, toFavoriteKey, syncFavoriteFlags, toggleFavoriteModel, reorderFavorite, pruneOrphanedFavorites } from '../core/favorites.js'
111
111
  import { checkForUpdateDetailed, checkForUpdate, runUpdate, fetchLastReleaseDate } from '../core/updater.js'
112
- import { createTuiState, PING_MODE_INTERVALS, PING_MODE_CYCLE, SPEED_MODE_DURATION_MS, IDLE_SLOW_AFTER_MS, intervalToPingMode } from './tui-state.js'
112
+ import { createTuiState, PING_MODE_INTERVALS, PING_MODE_CYCLE, SPEED_MODE_DURATION_MS, IDLE_SLOW_AFTER_MS, PING_HISTORY_CAP, intervalToPingMode } from './tui-state.js'
113
113
  import { createPingLoop } from '../core/ping-loop.js'
114
114
  import { createTuiFilters } from './tui-filters.js'
115
115
  import { promptApiKey } from '../core/setup.js'
116
116
  import { syncShellEnv, ensureShellRcSource, removeShellEnv } from '../core/shell-env.js'
117
- import { stripAnsi, maskApiKey, displayWidth, padEndDisplay, tintOverlayLines, keepOverlayTargetVisible, sliceOverlayLines, calculateViewport, sortResultsWithPinnedFavorites, adjustScrollOffset } from './render-helpers.js'
117
+ import { displayWidth, padEndDisplay, tintOverlayLines, keepOverlayTargetVisible, sliceOverlayLines, sortResultsWithPinnedFavorites, adjustScrollOffset, runWithConcurrency } from './render-helpers.js'
118
118
  import { renderTable, PROVIDER_COLOR } from './render-table.js'
119
119
  import { setOpenCodeModelData, startOpenCode, startOpenCodeDesktop, startOpenCodeWeb } from '../core/opencode.js'
120
120
  import { startKilo } from '../core/kilo.js'
@@ -478,6 +478,12 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
478
478
  let ticker = null
479
479
  let onKeyPress = null
480
480
  let onMouseData = null // 📖 Mouse data listener — set after createMouseEventHandler
481
+ // 📖 The variables above hold raw handler functions, but what actually gets
482
+ // 📖 registered on process.stdin are the WRAPPERS below. stopUi used to remove
483
+ // 📖 the raw functions (a silent no-op) - it now removes these tracked
484
+ // 📖 wrappers so launch/update paths really detach the listeners.
485
+ let keypressWrapper = null
486
+ let mouseDataWrapper = null
481
487
  let pingModel = null
482
488
 
483
489
  // 📖 scheduleNextPing: wrapper that defers to the factory version, passing the current runPingCycle.
@@ -505,7 +511,9 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
505
511
  r.verdict = cachedModel.verdict
506
512
  r.status = cachedModel.status
507
513
  r.httpCode = cachedModel.httpCode
508
- r.pings = cachedModel.pings || []
514
+ // 📖 Trim restored history to PING_HISTORY_CAP so an oversized cache
515
+ // 📖 file cannot instantly reintroduce the unbounded-growth problem.
516
+ r.pings = (cachedModel.pings || []).slice(-PING_HISTORY_CAP)
509
517
  }
510
518
  }
511
519
  }
@@ -621,6 +629,13 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
621
629
  }
622
630
 
623
631
  r.pings.push({ ms, code })
632
+ // 📖 Cap ping history (PING_HISTORY_CAP = 300): r.pings used to grow
633
+ // 📖 unbounded and getAvg/getP95/getVerdict/getStabilityScore rescan it
634
+ // 📖 per row at ~12 FPS. Stats (uptime, avg) now roll over the last
635
+ // 📖 300 pings instead of the entire session; the oldest entries shift out.
636
+ if (r.pings.length > PING_HISTORY_CAP) {
637
+ r.pings.splice(0, r.pings.length - PING_HISTORY_CAP)
638
+ }
624
639
 
625
640
  // 📖 Probe-cache (t1): record the result so future runs can skip healthy models
626
641
  // 📖 and auto-hide broken ones. Uses the module-level in-memory cache; flushed
@@ -767,8 +782,9 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
767
782
  clearTimeout(state.pingIntervalObj)
768
783
  clearInterval(state.versionRecheckTimer)
769
784
  stopRouterDashboardClient(state)
770
- if (onKeyPress) process.stdin.removeListener('keypress', onKeyPress)
771
- if (onMouseData) process.stdin.removeListener('data', onMouseData)
785
+ // 📖 Remove the actual registered wrappers (see keypressWrapper/mouseDataWrapper).
786
+ if (keypressWrapper) process.stdin.removeListener('keypress', keypressWrapper)
787
+ if (mouseDataWrapper) process.stdin.removeListener('data', mouseDataWrapper)
772
788
  if (process.stdin.isTTY && resetRawMode) process.stdin.setRawMode(false)
773
789
  process.stdin.pause()
774
790
  process.stdout.write(ALT_LEAVE)
@@ -837,6 +853,7 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
837
853
  sortResultsWithPinnedFavorites,
838
854
  adjustScrollOffset,
839
855
  applyTierFilter,
856
+ getPingModel: () => pingModel,
840
857
  PING_INTERVAL,
841
858
  TIER_CYCLE,
842
859
  ORIGIN_CYCLE,
@@ -948,7 +965,7 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
948
965
  }
949
966
  })
950
967
 
951
- process.stdin.on('keypress', async (str, key) => {
968
+ keypressWrapper = async (str, key) => {
952
969
  try {
953
970
  // 📖 Skip keypress events that originate from mouse escape sequences.
954
971
  // 📖 readline may partially parse SGR mouse sequences as garbage keypresses.
@@ -963,18 +980,20 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
963
980
  console.error(chalk.yellow('\nPlease file an issue at https://github.com/vava-nessa/free-coding-models/issues or join the Discord to report this to the author.'));
964
981
  process.exit(1);
965
982
  }
966
- })
983
+ }
984
+ process.stdin.on('keypress', keypressWrapper)
967
985
 
968
986
  // 📖 Mouse data listener: parses SGR mouse escape sequences from raw stdin
969
987
  // 📖 and dispatches structured events (click, scroll, double-click) to the mouse handler.
970
- process.stdin.on('data', (data) => {
988
+ mouseDataWrapper = (data) => {
971
989
  try {
972
990
  if (onMouseData) onMouseData(data)
973
991
  } catch (err) {
974
992
  // 📖 Mouse errors are non-fatal — log and continue so the TUI doesn't crash.
975
993
  // 📖 This could happen on terminals that send unexpected mouse sequences.
976
994
  }
977
- })
995
+ }
996
+ process.stdin.on('data', mouseDataWrapper)
978
997
 
979
998
  process.on('SIGCONT', noteUserActivity)
980
999
 
@@ -1103,8 +1122,6 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
1103
1122
  ? overlays.renderPlayground()
1104
1123
  : state.tokenUsageOpen
1105
1124
  ? overlays.renderTokenUsage()
1106
- : state.routerOnboardingOpen
1107
- ? overlays.renderRouterOnboarding()
1108
1125
  : state.incompatibleFallbackOpen
1109
1126
  ? overlays.renderIncompatibleFallback()
1110
1127
  : state.commandPaletteOpen
@@ -1207,7 +1224,14 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
1207
1224
  const initialDueModels = state.results.filter(r => !isCacheFresh(r.providerKey, r.modelId, {
1208
1225
  ttlMs: state.probeCacheTtlMs,
1209
1226
  }))
1210
- const initialPing = Promise.all(initialDueModels.map(r => pingModel(r)))
1227
+ // 📖 Startup burst runs through runWithConcurrency(…, 5) - the same bounded
1228
+ // 📖 helper the 404 probe uses. The old Promise.all fired up to ~226
1229
+ // 📖 simultaneous pings at once, a guaranteed 429 storm on rate-limited
1230
+ // 📖 providers (and per-task errors are captured instead of rejecting the batch).
1231
+ const initialPing = runWithConcurrency(
1232
+ initialDueModels.map(r => () => pingModel(r)),
1233
+ 5
1234
+ )
1211
1235
 
1212
1236
  // 📖 Continuous ping loop with mode-driven cadence.
1213
1237
  const runPingCycle = async () => {
@@ -9,7 +9,7 @@
9
9
  * → `fuzzyMatchCommand` — scores a query against one string and returns match positions
10
10
  * → `filterCommandPaletteEntries` — returns sorted command matches for a query
11
11
  *
12
- * @exports { buildCommandPaletteTree, flattenCommandTree, fuzzyMatchCommand, filterCommandPaletteEntries }
12
+ * @exports { buildCommandPaletteTree, flattenCommandTree, fuzzyMatchCommand, filterCommandPaletteEntries, COMMAND_PALETTE_MAX_RESULTS, clampPaletteCursor }
13
13
  *
14
14
  * @see src/key-handler.js
15
15
  * @see src/overlays.js
@@ -17,6 +17,25 @@
17
17
 
18
18
  import { TOOL_METADATA, TOOL_MODE_ORDER } from '../core/tool-metadata.js'
19
19
  import { sources } from '../../sources.js'
20
+ import { sanitizePingInterval } from './tui-state.js'
21
+
22
+ // 📖 Max result rows the palette renders (overlays.js slices to this). The
23
+ // 📖 cursor must never point past the rendered slice, so key-handler clamps
24
+ // 📖 with clampPaletteCursor() against the same constant.
25
+ export const COMMAND_PALETTE_MAX_RESULTS = 80
26
+
27
+ /**
28
+ * 📖 clampPaletteCursor: Keep the palette cursor inside the RENDERED slice.
29
+ * 📖 The overlay only paints the first COMMAND_PALETTE_MAX_RESULTS rows, so a
30
+ * 📖 cursor clamped against the full result list could sit on an invisible row.
31
+ * @param {number} cursor
32
+ * @param {number} totalResults
33
+ * @returns {number}
34
+ */
35
+ export function clampPaletteCursor(cursor, totalResults) {
36
+ const max = Math.max(0, Math.min(totalResults, COMMAND_PALETTE_MAX_RESULTS) - 1)
37
+ return Math.min(Math.max(0, cursor), max)
38
+ }
20
39
 
21
40
  const PROVIDER_FILTER_COMMANDS = Object.entries(sources).map(([providerKey, source]) => {
22
41
  const label = source?.name || providerKey
@@ -229,7 +248,8 @@ export function buildCommandPaletteTree(visibleModels = [], config = null) {
229
248
 
230
249
  // 📖 Update Normal mode label if config is provided
231
250
  if (config) {
232
- const normalInterval = config.settings?.pingInterval ?? 10000
251
+ // 📖 Guard against 0/negative/garbage pingInterval (same rule as tui-state).
252
+ const normalInterval = sanitizePingInterval(config.settings?.pingInterval, 10000)
233
253
  const normalSec = Math.round(normalInterval / 1000)
234
254
  const actionsNode = tree.find(n => n.id === 'actions')
235
255
  const pingNode = actionsNode?.children.find(n => n.id === 'action-ping-mode')