free-coding-models 0.5.86 → 0.5.88
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 +109 -888
- package/bin/free-coding-models.js +4 -1
- package/changelog/v0.5.87.md +48 -0
- package/changelog/v0.5.88.md +16 -0
- package/package.json +13 -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/security.js +170 -24
- 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 +173 -71
- package/src/tui/app.js +38 -14
- package/src/tui/command-palette.js +22 -2
- package/src/tui/key-handler.js +123 -179
- package/src/tui/overlays.js +21 -71
- package/src/tui/render-helpers.js +62 -4
- package/src/tui/render-table.js +34 -3
- 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-CAzFIt8P.css +1 -0
- package/web/dist/assets/index-DFg1h0Nd.js +44 -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-Dm-Xfby9.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/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
|
}
|
|
@@ -748,6 +750,103 @@ export function resolveSecurityAction({ configExists, isSecure, autoFixRequested
|
|
|
748
750
|
return 'prompt'
|
|
749
751
|
}
|
|
750
752
|
|
|
753
|
+
// 📖 parseIcaclsOutput: PURE parser for Windows `icacls <file>` output, used by
|
|
754
|
+
// 📖 the config security check to get a REAL answer about NTFS access.
|
|
755
|
+
// 📖 WHY: on win32 Node reports POSIX-style modes as 0666 (writable) or 0444
|
|
756
|
+
// 📖 (read-only) only - 0600 is unreachable - so the old `(mode & 0o777) === 0o600`
|
|
757
|
+
// 📖 test was always false and the warning re-fired on every launch (issue #173
|
|
758
|
+
// 📖 follow-up from rutexd). Only the ACL tells the truth on NTFS.
|
|
759
|
+
// 📖
|
|
760
|
+
// 📖 Sample output we must handle (ACEs, one per line, path on the first line):
|
|
761
|
+
// C:\Users\rutex\.free-coding-models.json NT AUTHORITY\SYSTEM:(I)(F)
|
|
762
|
+
// BUILTIN\Administrators:(I)(F)
|
|
763
|
+
// DESKTOP-XYZ\rutex:(F)
|
|
764
|
+
// Successfully processed 1 files; Failed processing 0 files
|
|
765
|
+
// 📖 `(I)` marks an inherited ACE, so "inheritance disabled" = no `(I)` anywhere.
|
|
766
|
+
// 📖 Locale-proof whitelists: names are machine-localized, so we whitelist by
|
|
767
|
+
// 📖 substring (SYSTEM, Administra* covers Administrators/Administrateurs) and
|
|
768
|
+
// 📖 by well-known SID (S-1-5-18 = SYSTEM, S-1-5-32-544 = Administrators), plus
|
|
769
|
+
// 📖 the current user (case-insensitive, domain-qualified or bare).
|
|
770
|
+
// 📖
|
|
771
|
+
// 📖 Params:
|
|
772
|
+
// output - raw stdout of `icacls <path>` (string)
|
|
773
|
+
// userName - current user name (string), e.g. "rutex"
|
|
774
|
+
// filePath - the exact path passed to icacls (optional but STRONGLY
|
|
775
|
+
// recommended: icacls echoes it on the first line, glued to the
|
|
776
|
+
// first ACE, and paths/account names can both contain spaces)
|
|
777
|
+
// 📖 Returns { inheritanceEnabled, othersHaveAccess, ownerHasAccess, grants }
|
|
778
|
+
// grants = [{ name, flags }] for every ACE line; empty when output unparseable.
|
|
779
|
+
export function parseIcaclsOutput({ output, userName, filePath = '' }) {
|
|
780
|
+
const text = String(output ?? '')
|
|
781
|
+
const user = String(userName ?? '').trim().toLowerCase()
|
|
782
|
+
const knownPath = String(filePath ?? '')
|
|
783
|
+
const lines = text.split(/\r?\n/)
|
|
784
|
+
|
|
785
|
+
// 📖 The rights/flags groups always end the line: "(I)(F)", "(F)", "(R,W)"...
|
|
786
|
+
const aceTail = /((?:\([^)]*\)\s*)+)$/
|
|
787
|
+
const grants = []
|
|
788
|
+
for (const rawLine of lines) {
|
|
789
|
+
const tail = aceTail.exec(rawLine)
|
|
790
|
+
if (!tail) continue
|
|
791
|
+
|
|
792
|
+
let before = rawLine.slice(0, tail.index).trim()
|
|
793
|
+
if (before === '') continue
|
|
794
|
+
// 📖 Drop the colon that separates the account name from its flags.
|
|
795
|
+
before = before.replace(/:$/, '').trim()
|
|
796
|
+
// 📖 First ACE shares its line with the echoed path: strip the path we
|
|
797
|
+
// 📖 passed to icacls. Without a known path, keep only the segment after
|
|
798
|
+
// 📖 the last whitespace (exact for simple names; callers should pass
|
|
799
|
+
// 📖 filePath when the path or account name contains spaces).
|
|
800
|
+
if (knownPath && before.startsWith(knownPath)) {
|
|
801
|
+
before = before.slice(knownPath.length).trim()
|
|
802
|
+
} else if (knownPath === '' && /\s/.test(before)) {
|
|
803
|
+
before = before.slice(before.lastIndexOf(' ') + 1).trim()
|
|
804
|
+
}
|
|
805
|
+
if (before === '') continue
|
|
806
|
+
grants.push({ name: before, flags: tail[1].replace(/\s+/g, '') })
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const isTrusted = (name) => {
|
|
810
|
+
const n = name.toLowerCase()
|
|
811
|
+
if (user !== '' && (n === user || n.endsWith('\\' + user))) return true
|
|
812
|
+
if (n.includes('system') || n.includes('s-1-5-18')) return true
|
|
813
|
+
if (n.includes('administra') || n.includes('s-1-5-32-544')) return true
|
|
814
|
+
return false
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const inheritanceEnabled = grants.some((g) => g.flags.includes('I'))
|
|
818
|
+
const others = grants.filter((g) => !isTrusted(g.name)).map((g) => g.name)
|
|
819
|
+
const ownerHasAccess = grants.some((g) => {
|
|
820
|
+
const n = g.name.toLowerCase()
|
|
821
|
+
return user !== '' && (n === user || n.endsWith('\\' + user))
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
return {
|
|
825
|
+
inheritanceEnabled,
|
|
826
|
+
othersHaveAccess: others.length > 0,
|
|
827
|
+
ownerHasAccess,
|
|
828
|
+
grants,
|
|
829
|
+
otherNames: others,
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// 📖 shouldSkipSecurityWarn: PURE anti-nag gate for the config security warning.
|
|
834
|
+
// 📖 WHY: when the permission fix cannot be applied or verified (rare: icacls
|
|
835
|
+
// 📖 missing on Windows, chmod failing on POSIX), the insecure state persists and
|
|
836
|
+
// 📖 the warning used to re-fire on every single launch. Once we warned and the
|
|
837
|
+
// 📖 fix did not stick, stay quiet for `intervalDays` (default 30) instead of
|
|
838
|
+
// 📖 nagging daily. `--fix-permissions` bypasses the gate in the caller.
|
|
839
|
+
// 📖 Params: ackedAt - last warning timestamp (ms epoch or ISO string or null)
|
|
840
|
+
// now - current time in ms epoch; intervalDays - quiet window length
|
|
841
|
+
// 📖 Returns true only when ackedAt parses AND is less than intervalDays old.
|
|
842
|
+
export function shouldSkipSecurityWarn({ ackedAt, now = Date.now(), intervalDays = 30 }) {
|
|
843
|
+
if (ackedAt === null || ackedAt === undefined || ackedAt === '') return false
|
|
844
|
+
const then = typeof ackedAt === 'number' ? ackedAt : Date.parse(String(ackedAt))
|
|
845
|
+
if (!Number.isFinite(then)) return false
|
|
846
|
+
const intervalMs = Math.max(0, intervalDays) * 24 * 60 * 60 * 1000
|
|
847
|
+
return now - then < intervalMs && now >= then
|
|
848
|
+
}
|
|
849
|
+
|
|
751
850
|
// 📖 detectTerminalCapabilities: PURE terminal capability probe for TUI overlays.
|
|
752
851
|
// 📖 WHY: basic server consoles (IPMI/KVM viewers, ASPEED framebuffer, serial
|
|
753
852
|
// 📖 terminals) often run 80x24 or smaller with no or limited color support, and
|
|
@@ -1031,6 +1130,9 @@ export function getVersionStatusInfo(updateState, latestVersion, startupLatestVe
|
|
|
1031
1130
|
* @returns {string} JSON string of formatted results
|
|
1032
1131
|
*/
|
|
1033
1132
|
export function formatResultsAsJSON(results, sortBy = 'avg', limit = 0) {
|
|
1133
|
+
// 📖 Only slice for a usable positive limit: a negative limit used to drop the
|
|
1134
|
+
// 📖 last row(s) via slice(0, -1), and 0/undefined means "all results".
|
|
1135
|
+
const effectiveLimit = Number.isInteger(limit) && limit > 0 ? limit : undefined
|
|
1034
1136
|
const formatted = results
|
|
1035
1137
|
.map((r, idx) => ({
|
|
1036
1138
|
rank: r.idx || idx + 1,
|
|
@@ -1050,7 +1152,7 @@ export function formatResultsAsJSON(results, sortBy = 'avg', limit = 0) {
|
|
|
1050
1152
|
status: r.status || null,
|
|
1051
1153
|
httpCode: r.httpCode || null
|
|
1052
1154
|
}))
|
|
1053
|
-
.slice(0,
|
|
1155
|
+
.slice(0, effectiveLimit)
|
|
1054
1156
|
|
|
1055
1157
|
return JSON.stringify(formatted, null, 2)
|
|
1056
1158
|
}
|
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
|
|
|
@@ -1103,8 +1122,6 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
|
|
|
1103
1122
|
? overlays.renderPlayground()
|
|
1104
1123
|
: state.tokenUsageOpen
|
|
1105
1124
|
? overlays.renderTokenUsage()
|
|
1106
|
-
: state.routerOnboardingOpen
|
|
1107
|
-
? overlays.renderRouterOnboarding()
|
|
1108
1125
|
: state.incompatibleFallbackOpen
|
|
1109
1126
|
? overlays.renderIncompatibleFallback()
|
|
1110
1127
|
: state.commandPaletteOpen
|
|
@@ -1207,7 +1224,14 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
|
|
|
1207
1224
|
const initialDueModels = state.results.filter(r => !isCacheFresh(r.providerKey, r.modelId, {
|
|
1208
1225
|
ttlMs: state.probeCacheTtlMs,
|
|
1209
1226
|
}))
|
|
1210
|
-
|
|
1227
|
+
// 📖 Startup burst runs through runWithConcurrency(…, 5) - the same bounded
|
|
1228
|
+
// 📖 helper the 404 probe uses. The old Promise.all fired up to ~226
|
|
1229
|
+
// 📖 simultaneous pings at once, a guaranteed 429 storm on rate-limited
|
|
1230
|
+
// 📖 providers (and per-task errors are captured instead of rejecting the batch).
|
|
1231
|
+
const initialPing = runWithConcurrency(
|
|
1232
|
+
initialDueModels.map(r => () => pingModel(r)),
|
|
1233
|
+
5
|
|
1234
|
+
)
|
|
1211
1235
|
|
|
1212
1236
|
// 📖 Continuous ping loop with mode-driven cadence.
|
|
1213
1237
|
const runPingCycle = async () => {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* → `fuzzyMatchCommand` — scores a query against one string and returns match positions
|
|
10
10
|
* → `filterCommandPaletteEntries` — returns sorted command matches for a query
|
|
11
11
|
*
|
|
12
|
-
* @exports { buildCommandPaletteTree, flattenCommandTree, fuzzyMatchCommand, filterCommandPaletteEntries }
|
|
12
|
+
* @exports { buildCommandPaletteTree, flattenCommandTree, fuzzyMatchCommand, filterCommandPaletteEntries, COMMAND_PALETTE_MAX_RESULTS, clampPaletteCursor }
|
|
13
13
|
*
|
|
14
14
|
* @see src/key-handler.js
|
|
15
15
|
* @see src/overlays.js
|
|
@@ -17,6 +17,25 @@
|
|
|
17
17
|
|
|
18
18
|
import { TOOL_METADATA, TOOL_MODE_ORDER } from '../core/tool-metadata.js'
|
|
19
19
|
import { sources } from '../../sources.js'
|
|
20
|
+
import { sanitizePingInterval } from './tui-state.js'
|
|
21
|
+
|
|
22
|
+
// 📖 Max result rows the palette renders (overlays.js slices to this). The
|
|
23
|
+
// 📖 cursor must never point past the rendered slice, so key-handler clamps
|
|
24
|
+
// 📖 with clampPaletteCursor() against the same constant.
|
|
25
|
+
export const COMMAND_PALETTE_MAX_RESULTS = 80
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 📖 clampPaletteCursor: Keep the palette cursor inside the RENDERED slice.
|
|
29
|
+
* 📖 The overlay only paints the first COMMAND_PALETTE_MAX_RESULTS rows, so a
|
|
30
|
+
* 📖 cursor clamped against the full result list could sit on an invisible row.
|
|
31
|
+
* @param {number} cursor
|
|
32
|
+
* @param {number} totalResults
|
|
33
|
+
* @returns {number}
|
|
34
|
+
*/
|
|
35
|
+
export function clampPaletteCursor(cursor, totalResults) {
|
|
36
|
+
const max = Math.max(0, Math.min(totalResults, COMMAND_PALETTE_MAX_RESULTS) - 1)
|
|
37
|
+
return Math.min(Math.max(0, cursor), max)
|
|
38
|
+
}
|
|
20
39
|
|
|
21
40
|
const PROVIDER_FILTER_COMMANDS = Object.entries(sources).map(([providerKey, source]) => {
|
|
22
41
|
const label = source?.name || providerKey
|
|
@@ -229,7 +248,8 @@ export function buildCommandPaletteTree(visibleModels = [], config = null) {
|
|
|
229
248
|
|
|
230
249
|
// 📖 Update Normal mode label if config is provided
|
|
231
250
|
if (config) {
|
|
232
|
-
|
|
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')
|