free-coding-models 0.5.85 → 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 (164) hide show
  1. package/README.md +110 -881
  2. package/bin/free-coding-models.js +4 -1
  3. package/changelog/v0.5.86.md +10 -0
  4. package/changelog/v0.5.87.md +48 -0
  5. package/package.json +8 -5
  6. package/sources.js +4 -4
  7. package/src/core/cache.js +3 -1
  8. package/src/core/config.js +43 -5
  9. package/src/core/endpoint-installer.js +23 -12
  10. package/src/core/installed-models-manager.js +43 -20
  11. package/src/core/kilo-config.js +51 -5
  12. package/src/core/kilo.js +5 -3
  13. package/src/core/legacy-proxy-cleanup.js +22 -5
  14. package/src/core/model-merger.js +4 -0
  15. package/src/core/models-drift.js +9 -0
  16. package/src/core/opencode-config.js +57 -5
  17. package/src/core/opencode.js +67 -41
  18. package/src/core/playground.js +7 -0
  19. package/src/core/probe-cache.js +35 -4
  20. package/src/core/provider-key-tester.js +10 -2
  21. package/src/core/provider-metadata.js +10 -0
  22. package/src/core/provider-quota-fetchers.js +14 -7
  23. package/src/core/router-daemon.js +208 -26
  24. package/src/core/runtime-telemetry.js +27 -1
  25. package/src/core/shared-helpers.js +12 -1
  26. package/src/core/shell-env.js +3 -1
  27. package/src/core/telemetry.js +18 -5
  28. package/src/core/tool-launchers.js +9 -6
  29. package/src/core/updater.js +28 -2
  30. package/src/core/utils.js +76 -71
  31. package/src/tui/app.js +47 -16
  32. package/src/tui/command-palette.js +22 -2
  33. package/src/tui/key-handler.js +123 -179
  34. package/src/tui/overlays.js +35 -75
  35. package/src/tui/render-helpers.js +62 -4
  36. package/src/tui/render-table.js +46 -5
  37. package/src/tui/theme.js +36 -5
  38. package/src/tui/tui-filters.js +28 -8
  39. package/src/tui/tui-state.js +22 -6
  40. package/web/dist/assets/index-BpTpPS0g.js +44 -0
  41. package/web/dist/assets/index-CAzFIt8P.css +1 -0
  42. package/web/dist/index.html +5 -9
  43. package/web/index.html +3 -7
  44. package/web/server.js +97 -23
  45. package/web/vite.config.js +8 -0
  46. package/web/app.legacy.js +0 -900
  47. package/web/dist/assets/index-C5-Ywvv3.css +0 -1
  48. package/web/dist/assets/index-DWUely84.js +0 -40
  49. package/web/public/favicon.ico +0 -0
  50. package/web/public/favicons/apple-touch-icon.png +0 -0
  51. package/web/public/favicons/browserconfig.xml +0 -12
  52. package/web/public/favicons/favicon-16x16.png +0 -0
  53. package/web/public/favicons/favicon-192x192.png +0 -0
  54. package/web/public/favicons/favicon-32x32.png +0 -0
  55. package/web/public/favicons/favicon-48x48.png +0 -0
  56. package/web/public/favicons/favicon-512x512.png +0 -0
  57. package/web/public/favicons/favicon-96x96.png +0 -0
  58. package/web/public/favicons/favicon.ico +0 -0
  59. package/web/public/favicons/mstile-150x150.png +0 -0
  60. package/web/public/favicons/mstile-310x150.png +0 -0
  61. package/web/public/favicons/mstile-310x310.png +0 -0
  62. package/web/public/favicons/mstile-512x512.png +0 -0
  63. package/web/public/favicons/mstile-70x70.png +0 -0
  64. package/web/public/favicons/site.webmanifest +0 -25
  65. package/web/src/App.jsx +0 -521
  66. package/web/src/components/analytics/AnalyticsView.jsx +0 -147
  67. package/web/src/components/analytics/AnalyticsView.module.css +0 -190
  68. package/web/src/components/analytics/TokenUsagePanel.jsx +0 -105
  69. package/web/src/components/analytics/TokenUsagePanel.module.css +0 -126
  70. package/web/src/components/atoms/AILatencyCell.jsx +0 -38
  71. package/web/src/components/atoms/AILatencyCell.module.css +0 -51
  72. package/web/src/components/atoms/HealthCell.jsx +0 -143
  73. package/web/src/components/atoms/HealthCell.module.css +0 -60
  74. package/web/src/components/atoms/LastPingCell.jsx +0 -35
  75. package/web/src/components/atoms/LastPingCell.module.css +0 -45
  76. package/web/src/components/atoms/MoodCell.jsx +0 -25
  77. package/web/src/components/atoms/MoodCell.module.css +0 -6
  78. package/web/src/components/atoms/NoKeyIcon.jsx +0 -65
  79. package/web/src/components/atoms/NoKeyIcon.module.css +0 -30
  80. package/web/src/components/atoms/ProviderLogo.jsx +0 -187
  81. package/web/src/components/atoms/ProviderLogo.module.css +0 -103
  82. package/web/src/components/atoms/RankCell.jsx +0 -9
  83. package/web/src/components/atoms/RankCell.module.css +0 -9
  84. package/web/src/components/atoms/Sparkline.jsx +0 -63
  85. package/web/src/components/atoms/StabilityCell.jsx +0 -18
  86. package/web/src/components/atoms/StabilityCell.module.css +0 -13
  87. package/web/src/components/atoms/StatusDot.jsx +0 -13
  88. package/web/src/components/atoms/StatusDot.module.css +0 -29
  89. package/web/src/components/atoms/TPSCell.jsx +0 -36
  90. package/web/src/components/atoms/TPSCell.module.css +0 -44
  91. package/web/src/components/atoms/TierBadge.jsx +0 -10
  92. package/web/src/components/atoms/TierBadge.module.css +0 -29
  93. package/web/src/components/atoms/Toast.jsx +0 -25
  94. package/web/src/components/atoms/Toast.module.css +0 -41
  95. package/web/src/components/atoms/ToastContainer.jsx +0 -16
  96. package/web/src/components/atoms/ToastContainer.module.css +0 -10
  97. package/web/src/components/atoms/VerdictBadge.jsx +0 -36
  98. package/web/src/components/atoms/VerdictBadge.module.css +0 -40
  99. package/web/src/components/changelog/ChangelogView.jsx +0 -135
  100. package/web/src/components/changelog/ChangelogView.module.css +0 -159
  101. package/web/src/components/dashboard/DetailPanel.jsx +0 -217
  102. package/web/src/components/dashboard/DetailPanel.module.css +0 -260
  103. package/web/src/components/dashboard/ExpandedDetailRow.jsx +0 -361
  104. package/web/src/components/dashboard/ExpandedDetailRow.module.css +0 -356
  105. package/web/src/components/dashboard/ExportModal.jsx +0 -84
  106. package/web/src/components/dashboard/ExportModal.module.css +0 -99
  107. package/web/src/components/dashboard/FilterBar.jsx +0 -338
  108. package/web/src/components/dashboard/FilterBar.module.css +0 -342
  109. package/web/src/components/dashboard/ModelTable.jsx +0 -672
  110. package/web/src/components/dashboard/ModelTable.module.css +0 -446
  111. package/web/src/components/dashboard/ProviderDropdown.jsx +0 -156
  112. package/web/src/components/dashboard/ProviderDropdown.module.css +0 -248
  113. package/web/src/components/help/HelpView.jsx +0 -201
  114. package/web/src/components/help/HelpView.module.css +0 -156
  115. package/web/src/components/install/InstallEndpointsView.jsx +0 -309
  116. package/web/src/components/install/InstallEndpointsView.module.css +0 -427
  117. package/web/src/components/installed/InstalledModelsView.jsx +0 -89
  118. package/web/src/components/installed/InstalledModelsView.module.css +0 -200
  119. package/web/src/components/launch/IncompatibleFallbackModal.jsx +0 -69
  120. package/web/src/components/launch/LaunchButton.jsx +0 -30
  121. package/web/src/components/launch/LaunchButton.module.css +0 -35
  122. package/web/src/components/launch/LaunchModal.module.css +0 -125
  123. package/web/src/components/layout/Footer.jsx +0 -19
  124. package/web/src/components/layout/Footer.module.css +0 -10
  125. package/web/src/components/layout/Header.jsx +0 -265
  126. package/web/src/components/layout/Header.module.css +0 -357
  127. package/web/src/components/palette/CommandPalette.jsx +0 -301
  128. package/web/src/components/palette/CommandPalette.module.css +0 -121
  129. package/web/src/components/playground/PlaygroundChat.jsx +0 -527
  130. package/web/src/components/playground/PlaygroundChat.module.css +0 -382
  131. package/web/src/components/playground/PlaygroundView.jsx +0 -371
  132. package/web/src/components/playground/PlaygroundView.module.css +0 -504
  133. package/web/src/components/recommend/RecommendView.jsx +0 -121
  134. package/web/src/components/recommend/RecommendView.module.css +0 -57
  135. package/web/src/components/router/RouterView.jsx +0 -1212
  136. package/web/src/components/router/RouterView.module.css +0 -1289
  137. package/web/src/components/settings/SettingsView.jsx +0 -555
  138. package/web/src/components/settings/SettingsView.module.css +0 -550
  139. package/web/src/components/tools/ToolPicker.jsx +0 -86
  140. package/web/src/components/tools/ToolPicker.module.css +0 -97
  141. package/web/src/components/update/UpdateChip.jsx +0 -104
  142. package/web/src/components/update/UpdateChip.module.css +0 -146
  143. package/web/src/global.css +0 -298
  144. package/web/src/hooks/urlState.constants.js +0 -28
  145. package/web/src/hooks/useChangelog.js +0 -51
  146. package/web/src/hooks/useColumnSizing.js +0 -211
  147. package/web/src/hooks/useFavorites.js +0 -150
  148. package/web/src/hooks/useFilter.js +0 -310
  149. package/web/src/hooks/useInstalledModels.js +0 -42
  150. package/web/src/hooks/useRecommend.js +0 -67
  151. package/web/src/hooks/useRouterDashboard.js +0 -133
  152. package/web/src/hooks/useSSE.js +0 -73
  153. package/web/src/hooks/useSocket.js +0 -203
  154. package/web/src/hooks/useTheme.js +0 -83
  155. package/web/src/hooks/useTokenUsage.js +0 -103
  156. package/web/src/hooks/useToolMode.js +0 -77
  157. package/web/src/hooks/useUpdateChecker.js +0 -91
  158. package/web/src/hooks/useUrlState.js +0 -170
  159. package/web/src/main.jsx +0 -15
  160. package/web/src/utils/download.js +0 -15
  161. package/web/src/utils/format.js +0 -42
  162. package/web/src/utils/m3.js +0 -57
  163. package/web/src/utils/ranks.js +0 -37
  164. package/web/styles.legacy.css +0 -963
@@ -10,7 +10,7 @@
10
10
  * - PostHog is used for product analytics (`app_start`, `app_use`, and lightweight
11
11
  * `app_action` events covering launches and key product actions).
12
12
  * - Discord webhooks carry anonymous feature requests (J key) and bug reports (I key).
13
- * - `isTelemetryEnabled()` checks: CLI flag → env var → default (enabled).
13
+ * - `isTelemetryEnabled()` checks: CLI flag → env var → config opt-out → default (enabled).
14
14
  * - `telemetryDebug()` writes to stderr only when FREE_CODING_MODELS_TELEMETRY_DEBUG=1.
15
15
  * - `sendUsageTelemetry()` has a hard 1.2 s timeout so it never blocks startup.
16
16
  *
@@ -20,6 +20,8 @@
20
20
  * - FREE_CODING_MODELS_POSTHOG_KEY — override the PostHog project key
21
21
  * - FREE_CODING_MODELS_POSTHOG_HOST — override the PostHog host
22
22
  * - POSTHOG_PROJECT_API_KEY / POSTHOG_HOST — standard PostHog env vars (fallback)
23
+ * - FCM_DISCORD_FEATURE_WEBHOOK - feature request webhook URL
24
+ * - FCM_DISCORD_BUG_WEBHOOK - bug report webhook URL
23
25
  *
24
26
  * @functions
25
27
  * → parseTelemetryEnv(value) — Convert env string to boolean or null
@@ -63,12 +65,15 @@ const POSTHOG_PROJECT_KEY_DEFAULT = 'phc_5P1n8HaLof6nHM0tKJYt4bV5pj2XPb272fLVigw
63
65
  const POSTHOG_HOST_DEFAULT = 'https://eu.i.posthog.com'
64
66
 
65
67
  // 📖 Discord feature request webhook configuration (anonymous feedback system).
66
- const DISCORD_WEBHOOK_URL = 'https://discord.com/api/webhooks/1476709155992764427/hmnHNtpducvi5LClhv8DynENjUmmg9q8HI1Bx1lNix56UHqrqZf55rW95LGvNJ2W4j7D'
68
+ // 📖 FCM_DISCORD_FEATURE_WEBHOOK: env var holding the feature-request webhook URL.
69
+ // 📖 When unset, the feature request action reports "not configured" instead of posting.
70
+ const DISCORD_WEBHOOK_URL = (process.env.FCM_DISCORD_FEATURE_WEBHOOK || '').trim()
67
71
  const DISCORD_BOT_NAME = 'TUI - Feature Requests'
68
72
  const DISCORD_EMBED_COLOR = 0x39FF14 // Vert fluo (RGB: 57, 255, 20)
69
73
 
70
74
  // 📖 Discord bug report webhook configuration (anonymous bug reports).
71
- const DISCORD_BUG_WEBHOOK_URL = 'https://discord.com/api/webhooks/1476715954409963743/5cOLf7U_891f1jwxRBLIp2RIP9xYhr4rWtOhipzKKwVdFVl1Bj89X_fB6I_uGXZiGT9E'
75
+ // 📖 FCM_DISCORD_BUG_WEBHOOK: env var holding the bug report webhook URL.
76
+ const DISCORD_BUG_WEBHOOK_URL = (process.env.FCM_DISCORD_BUG_WEBHOOK || '').trim()
72
77
  const DISCORD_BUG_BOT_NAME = 'TUI Bug Report'
73
78
  const DISCORD_BUG_EMBED_COLOR = 0xFF5733 // Rouge (RGB: 255, 87, 51)
74
79
 
@@ -194,7 +199,7 @@ export function getTelemetryTerminal() {
194
199
 
195
200
  /**
196
201
  * 📖 Resolve telemetry effective state with clear precedence:
197
- * 📖 CLI flag > env var > enabled by default (forced for all users).
202
+ * 📖 CLI flag > env var > config opt-out > enabled by default.
198
203
  * @param {Record<string, unknown>} config
199
204
  * @param {{ noTelemetry?: boolean }} cliArgs
200
205
  * @returns {boolean}
@@ -204,7 +209,9 @@ export function isTelemetryEnabled(config, cliArgs) {
204
209
  const envTelemetry = parseTelemetryEnv(process.env.FREE_CODING_MODELS_TELEMETRY)
205
210
  if (envTelemetry !== null) return envTelemetry
206
211
  ensureTelemetryConfig(config)
207
- return true
212
+ // 📖 Honor a persisted opt-out (config telemetry.enabled=false) set from the UI;
213
+ // 📖 CLI flags and FREE_CODING_MODELS_TELEMETRY still override it above.
214
+ return config.telemetry.enabled !== false
208
215
  }
209
216
 
210
217
  /**
@@ -314,6 +321,9 @@ export async function sendUsageTelemetry(config, cliArgs, payload) {
314
321
  * @returns {Promise<{ success: boolean, error: string|null }>}
315
322
  */
316
323
  export async function sendFeatureRequest(message) {
324
+ if (!DISCORD_WEBHOOK_URL) {
325
+ return { success: false, error: 'feedback webhook not configured (set FCM_DISCORD_FEATURE_WEBHOOK)' }
326
+ }
317
327
  try {
318
328
  // 📖 Collect anonymous telemetry for context (no personal data)
319
329
  const system = getTelemetrySystem()
@@ -361,6 +371,9 @@ export async function sendFeatureRequest(message) {
361
371
  * @returns {Promise<{ success: boolean, error: string|null }>}
362
372
  */
363
373
  export async function sendBugReport(message) {
374
+ if (!DISCORD_BUG_WEBHOOK_URL) {
375
+ return { success: false, error: 'feedback webhook not configured (set FCM_DISCORD_BUG_WEBHOOK)' }
376
+ }
364
377
  try {
365
378
  // 📖 Collect anonymous telemetry for context (no personal data)
366
379
  const system = getTelemetrySystem()
@@ -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
 
@@ -1075,8 +1094,15 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
1075
1094
  // 📖 Freeze the full table (including countdown and spinner glyphs) while
1076
1095
  // 📖 the command palette is open so the background remains perfectly static.
1077
1096
  state.commandPaletteFrozenTable = renderTable(tableOpts)
1097
+ tableContent = state.commandPaletteFrozenTable
1098
+ } else {
1099
+ // 📖 The frozen table is already on screen and cannot change while the
1100
+ // 📖 palette is open, so repaint the palette alone. Repainting the full
1101
+ // table every frame made large terminals flicker: the write gets split
1102
+ // into chunks, so the terminal showed the bare table for a moment
1103
+ // before the palette overlay painted over it, 30 times per second.
1104
+ tableContent = null
1078
1105
  }
1079
- tableContent = state.commandPaletteFrozenTable
1080
1106
  } else {
1081
1107
  state.commandPaletteFrozenTable = null
1082
1108
  tableContent = renderTable(tableOpts)
@@ -1096,12 +1122,10 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
1096
1122
  ? overlays.renderPlayground()
1097
1123
  : state.tokenUsageOpen
1098
1124
  ? overlays.renderTokenUsage()
1099
- : state.routerOnboardingOpen
1100
- ? overlays.renderRouterOnboarding()
1101
1125
  : state.incompatibleFallbackOpen
1102
1126
  ? overlays.renderIncompatibleFallback()
1103
1127
  : state.commandPaletteOpen
1104
- ? tableContent + overlays.renderCommandPalette()
1128
+ ? (tableContent || '') + overlays.renderCommandPalette()
1105
1129
  : state.recommendOpen
1106
1130
  ? overlays.renderRecommend()
1107
1131
  : state.helpVisible
@@ -1200,7 +1224,14 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
1200
1224
  const initialDueModels = state.results.filter(r => !isCacheFresh(r.providerKey, r.modelId, {
1201
1225
  ttlMs: state.probeCacheTtlMs,
1202
1226
  }))
1203
- 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
+ )
1204
1235
 
1205
1236
  // 📖 Continuous ping loop with mode-driven cadence.
1206
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')