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.
- package/README.md +110 -881
- package/bin/free-coding-models.js +4 -1
- package/changelog/v0.5.86.md +10 -0
- package/changelog/v0.5.87.md +48 -0
- package/package.json +8 -5
- package/sources.js +4 -4
- package/src/core/cache.js +3 -1
- package/src/core/config.js +43 -5
- package/src/core/endpoint-installer.js +23 -12
- package/src/core/installed-models-manager.js +43 -20
- package/src/core/kilo-config.js +51 -5
- package/src/core/kilo.js +5 -3
- package/src/core/legacy-proxy-cleanup.js +22 -5
- package/src/core/model-merger.js +4 -0
- package/src/core/models-drift.js +9 -0
- package/src/core/opencode-config.js +57 -5
- package/src/core/opencode.js +67 -41
- package/src/core/playground.js +7 -0
- package/src/core/probe-cache.js +35 -4
- package/src/core/provider-key-tester.js +10 -2
- package/src/core/provider-metadata.js +10 -0
- package/src/core/provider-quota-fetchers.js +14 -7
- package/src/core/router-daemon.js +208 -26
- package/src/core/runtime-telemetry.js +27 -1
- package/src/core/shared-helpers.js +12 -1
- package/src/core/shell-env.js +3 -1
- package/src/core/telemetry.js +18 -5
- package/src/core/tool-launchers.js +9 -6
- package/src/core/updater.js +28 -2
- package/src/core/utils.js +76 -71
- package/src/tui/app.js +47 -16
- package/src/tui/command-palette.js +22 -2
- package/src/tui/key-handler.js +123 -179
- package/src/tui/overlays.js +35 -75
- package/src/tui/render-helpers.js +62 -4
- package/src/tui/render-table.js +46 -5
- package/src/tui/theme.js +36 -5
- package/src/tui/tui-filters.js +28 -8
- package/src/tui/tui-state.js +22 -6
- package/web/dist/assets/index-BpTpPS0g.js +44 -0
- package/web/dist/assets/index-CAzFIt8P.css +1 -0
- package/web/dist/index.html +5 -9
- package/web/index.html +3 -7
- package/web/server.js +97 -23
- package/web/vite.config.js +8 -0
- package/web/app.legacy.js +0 -900
- package/web/dist/assets/index-C5-Ywvv3.css +0 -1
- package/web/dist/assets/index-DWUely84.js +0 -40
- package/web/public/favicon.ico +0 -0
- package/web/public/favicons/apple-touch-icon.png +0 -0
- package/web/public/favicons/browserconfig.xml +0 -12
- package/web/public/favicons/favicon-16x16.png +0 -0
- package/web/public/favicons/favicon-192x192.png +0 -0
- package/web/public/favicons/favicon-32x32.png +0 -0
- package/web/public/favicons/favicon-48x48.png +0 -0
- package/web/public/favicons/favicon-512x512.png +0 -0
- package/web/public/favicons/favicon-96x96.png +0 -0
- package/web/public/favicons/favicon.ico +0 -0
- package/web/public/favicons/mstile-150x150.png +0 -0
- package/web/public/favicons/mstile-310x150.png +0 -0
- package/web/public/favicons/mstile-310x310.png +0 -0
- package/web/public/favicons/mstile-512x512.png +0 -0
- package/web/public/favicons/mstile-70x70.png +0 -0
- package/web/public/favicons/site.webmanifest +0 -25
- package/web/src/App.jsx +0 -521
- package/web/src/components/analytics/AnalyticsView.jsx +0 -147
- package/web/src/components/analytics/AnalyticsView.module.css +0 -190
- package/web/src/components/analytics/TokenUsagePanel.jsx +0 -105
- package/web/src/components/analytics/TokenUsagePanel.module.css +0 -126
- package/web/src/components/atoms/AILatencyCell.jsx +0 -38
- package/web/src/components/atoms/AILatencyCell.module.css +0 -51
- package/web/src/components/atoms/HealthCell.jsx +0 -143
- package/web/src/components/atoms/HealthCell.module.css +0 -60
- package/web/src/components/atoms/LastPingCell.jsx +0 -35
- package/web/src/components/atoms/LastPingCell.module.css +0 -45
- package/web/src/components/atoms/MoodCell.jsx +0 -25
- package/web/src/components/atoms/MoodCell.module.css +0 -6
- package/web/src/components/atoms/NoKeyIcon.jsx +0 -65
- package/web/src/components/atoms/NoKeyIcon.module.css +0 -30
- package/web/src/components/atoms/ProviderLogo.jsx +0 -187
- package/web/src/components/atoms/ProviderLogo.module.css +0 -103
- package/web/src/components/atoms/RankCell.jsx +0 -9
- package/web/src/components/atoms/RankCell.module.css +0 -9
- package/web/src/components/atoms/Sparkline.jsx +0 -63
- package/web/src/components/atoms/StabilityCell.jsx +0 -18
- package/web/src/components/atoms/StabilityCell.module.css +0 -13
- package/web/src/components/atoms/StatusDot.jsx +0 -13
- package/web/src/components/atoms/StatusDot.module.css +0 -29
- package/web/src/components/atoms/TPSCell.jsx +0 -36
- package/web/src/components/atoms/TPSCell.module.css +0 -44
- package/web/src/components/atoms/TierBadge.jsx +0 -10
- package/web/src/components/atoms/TierBadge.module.css +0 -29
- package/web/src/components/atoms/Toast.jsx +0 -25
- package/web/src/components/atoms/Toast.module.css +0 -41
- package/web/src/components/atoms/ToastContainer.jsx +0 -16
- package/web/src/components/atoms/ToastContainer.module.css +0 -10
- package/web/src/components/atoms/VerdictBadge.jsx +0 -36
- package/web/src/components/atoms/VerdictBadge.module.css +0 -40
- package/web/src/components/changelog/ChangelogView.jsx +0 -135
- package/web/src/components/changelog/ChangelogView.module.css +0 -159
- package/web/src/components/dashboard/DetailPanel.jsx +0 -217
- package/web/src/components/dashboard/DetailPanel.module.css +0 -260
- package/web/src/components/dashboard/ExpandedDetailRow.jsx +0 -361
- package/web/src/components/dashboard/ExpandedDetailRow.module.css +0 -356
- package/web/src/components/dashboard/ExportModal.jsx +0 -84
- package/web/src/components/dashboard/ExportModal.module.css +0 -99
- package/web/src/components/dashboard/FilterBar.jsx +0 -338
- package/web/src/components/dashboard/FilterBar.module.css +0 -342
- package/web/src/components/dashboard/ModelTable.jsx +0 -672
- package/web/src/components/dashboard/ModelTable.module.css +0 -446
- package/web/src/components/dashboard/ProviderDropdown.jsx +0 -156
- package/web/src/components/dashboard/ProviderDropdown.module.css +0 -248
- package/web/src/components/help/HelpView.jsx +0 -201
- package/web/src/components/help/HelpView.module.css +0 -156
- package/web/src/components/install/InstallEndpointsView.jsx +0 -309
- package/web/src/components/install/InstallEndpointsView.module.css +0 -427
- package/web/src/components/installed/InstalledModelsView.jsx +0 -89
- package/web/src/components/installed/InstalledModelsView.module.css +0 -200
- package/web/src/components/launch/IncompatibleFallbackModal.jsx +0 -69
- package/web/src/components/launch/LaunchButton.jsx +0 -30
- package/web/src/components/launch/LaunchButton.module.css +0 -35
- package/web/src/components/launch/LaunchModal.module.css +0 -125
- package/web/src/components/layout/Footer.jsx +0 -19
- package/web/src/components/layout/Footer.module.css +0 -10
- package/web/src/components/layout/Header.jsx +0 -265
- package/web/src/components/layout/Header.module.css +0 -357
- package/web/src/components/palette/CommandPalette.jsx +0 -301
- package/web/src/components/palette/CommandPalette.module.css +0 -121
- package/web/src/components/playground/PlaygroundChat.jsx +0 -527
- package/web/src/components/playground/PlaygroundChat.module.css +0 -382
- package/web/src/components/playground/PlaygroundView.jsx +0 -371
- package/web/src/components/playground/PlaygroundView.module.css +0 -504
- package/web/src/components/recommend/RecommendView.jsx +0 -121
- package/web/src/components/recommend/RecommendView.module.css +0 -57
- package/web/src/components/router/RouterView.jsx +0 -1212
- package/web/src/components/router/RouterView.module.css +0 -1289
- package/web/src/components/settings/SettingsView.jsx +0 -555
- package/web/src/components/settings/SettingsView.module.css +0 -550
- package/web/src/components/tools/ToolPicker.jsx +0 -86
- package/web/src/components/tools/ToolPicker.module.css +0 -97
- package/web/src/components/update/UpdateChip.jsx +0 -104
- package/web/src/components/update/UpdateChip.module.css +0 -146
- package/web/src/global.css +0 -298
- package/web/src/hooks/urlState.constants.js +0 -28
- package/web/src/hooks/useChangelog.js +0 -51
- package/web/src/hooks/useColumnSizing.js +0 -211
- package/web/src/hooks/useFavorites.js +0 -150
- package/web/src/hooks/useFilter.js +0 -310
- package/web/src/hooks/useInstalledModels.js +0 -42
- package/web/src/hooks/useRecommend.js +0 -67
- package/web/src/hooks/useRouterDashboard.js +0 -133
- package/web/src/hooks/useSSE.js +0 -73
- package/web/src/hooks/useSocket.js +0 -203
- package/web/src/hooks/useTheme.js +0 -83
- package/web/src/hooks/useTokenUsage.js +0 -103
- package/web/src/hooks/useToolMode.js +0 -77
- package/web/src/hooks/useUpdateChecker.js +0 -91
- package/web/src/hooks/useUrlState.js +0 -170
- package/web/src/main.jsx +0 -15
- package/web/src/utils/download.js +0 -15
- package/web/src/utils/format.js +0 -42
- package/web/src/utils/m3.js +0 -57
- package/web/src/utils/ranks.js +0 -37
- package/web/styles.legacy.css +0 -963
package/src/core/telemetry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
// 📖
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
`
|
|
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
|
|
423
|
+
`export LLM_API_KEY=${shellSingleQuote(apiKey)}`,
|
|
421
424
|
`export LLM_BASE_URL="${baseUrl}"`,
|
|
422
425
|
`export LLM_MODEL="openai/${model.modelId}"`,
|
|
423
426
|
]
|
package/src/core/updater.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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> (
|
|
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
|
-
// 📖
|
|
509
|
-
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
const
|
|
516
|
-
const
|
|
517
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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 =
|
|
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 =
|
|
630
|
-
let sortColumn =
|
|
631
|
-
let originFilter =
|
|
632
|
-
let
|
|
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 =
|
|
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
|
|
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:
|
|
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,
|
|
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,
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
771
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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')
|