free-coding-models 0.5.86 → 0.5.87
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -881
- package/bin/free-coding-models.js +4 -1
- 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 +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-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-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/tui/overlays.js
CHANGED
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
* @see ./key-handler.js — handles keypresses for all overlay interactions
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { loadChangelog } from '../core/changelog-loader.js'
|
|
23
22
|
import { buildCliHelpLines } from './cli-help.js'
|
|
24
23
|
import { renderRouterDashboard as renderRouterDashboardOverlay } from '../core/router-dashboard.js'
|
|
25
24
|
import { renderPlayground as renderPlaygroundOverlay } from '../core/playground.js'
|
|
26
25
|
import { themeColors, getThemeStatusLabel, getProviderRgb } from './theme.js'
|
|
27
26
|
import { getProviderBillingNote, getProviderLabelWithBilling } from '../core/provider-metadata.js'
|
|
28
27
|
import { detectTerminalCapabilities } from '../core/utils.js'
|
|
29
|
-
import { truncateAnsiWidth } from './render-helpers.js'
|
|
28
|
+
import { truncateAnsiWidth, loadChangelogCached } from './render-helpers.js'
|
|
29
|
+
import { COMMAND_PALETTE_MAX_RESULTS } from './command-palette.js'
|
|
30
30
|
|
|
31
31
|
export function createOverlayRenderers(state, deps) {
|
|
32
32
|
const {
|
|
@@ -516,14 +516,17 @@ export function createOverlayRenderers(state, deps) {
|
|
|
516
516
|
}
|
|
517
517
|
|
|
518
518
|
const targetLine = cursorLineByRow[state.installEndpointsCursor] ?? 0
|
|
519
|
-
|
|
520
|
-
|
|
519
|
+
// 📖 Own its scroll offset (fix): this renderer used to read/write
|
|
520
|
+
// 📖 toolInstallPromptScrollOffset, sharing (and fighting over) state with
|
|
521
|
+
// 📖 the tool-install prompt overlay. Each overlay now scrolls independently.
|
|
522
|
+
state.installEndpointsScrollOffset = keepOverlayTargetVisible(
|
|
523
|
+
state.installEndpointsScrollOffset,
|
|
521
524
|
targetLine,
|
|
522
525
|
lines.length,
|
|
523
526
|
state.terminalRows
|
|
524
527
|
)
|
|
525
|
-
const { visible, offset } = sliceOverlayLines(lines, state.
|
|
526
|
-
state.
|
|
528
|
+
const { visible, offset } = sliceOverlayLines(lines, state.installEndpointsScrollOffset, state.terminalRows)
|
|
529
|
+
state.installEndpointsScrollOffset = offset
|
|
527
530
|
|
|
528
531
|
const tintedLines = tintOverlayLines(visible, themeColors.overlayBgSettings, state.terminalCols)
|
|
529
532
|
const cleared = tintedLines.map((line) => line + EL)
|
|
@@ -710,14 +713,15 @@ export function createOverlayRenderers(state, deps) {
|
|
|
710
713
|
}
|
|
711
714
|
|
|
712
715
|
const targetLine = cursorLineByRow[state.toolInstallPromptCursor] ?? 0
|
|
713
|
-
|
|
714
|
-
|
|
716
|
+
// 📖 Own its scroll offset (fix): was reading/writing installEndpointsScrollOffset.
|
|
717
|
+
state.toolInstallPromptScrollOffset = keepOverlayTargetVisible(
|
|
718
|
+
state.toolInstallPromptScrollOffset,
|
|
715
719
|
targetLine,
|
|
716
720
|
lines.length,
|
|
717
721
|
state.terminalRows
|
|
718
722
|
)
|
|
719
|
-
const { visible, offset } = sliceOverlayLines(lines, state.
|
|
720
|
-
state.
|
|
723
|
+
const { visible, offset } = sliceOverlayLines(lines, state.toolInstallPromptScrollOffset, state.terminalRows)
|
|
724
|
+
state.toolInstallPromptScrollOffset = offset
|
|
721
725
|
|
|
722
726
|
const tintedLines = tintOverlayLines(visible, themeColors.overlayBgSettings, state.terminalCols)
|
|
723
727
|
const cleared = tintedLines.map((line) => line + EL)
|
|
@@ -773,7 +777,7 @@ export function createOverlayRenderers(state, deps) {
|
|
|
773
777
|
return out
|
|
774
778
|
}
|
|
775
779
|
|
|
776
|
-
const allResults = Array.isArray(state.commandPaletteResults) ? state.commandPaletteResults.slice(0,
|
|
780
|
+
const allResults = Array.isArray(state.commandPaletteResults) ? state.commandPaletteResults.slice(0, COMMAND_PALETTE_MAX_RESULTS) : []
|
|
777
781
|
const panelLines = []
|
|
778
782
|
const cursorLineByRow = {}
|
|
779
783
|
|
|
@@ -1266,7 +1270,9 @@ export function createOverlayRenderers(state, deps) {
|
|
|
1266
1270
|
function renderChangelog() {
|
|
1267
1271
|
const EL = '\x1b[K'
|
|
1268
1272
|
const lines = []
|
|
1269
|
-
|
|
1273
|
+
// 📖 TTL-cached load (fix): this ran readdirSync + readFileSync of every
|
|
1274
|
+
// 📖 changelog/*.md on EVERY render frame while the overlay was open.
|
|
1275
|
+
const changelogData = loadChangelogCached()
|
|
1270
1276
|
const { versions } = changelogData
|
|
1271
1277
|
const versionList = Object.keys(versions).sort((a, b) => {
|
|
1272
1278
|
const aParts = a.split('.').map(Number)
|
|
@@ -1628,64 +1634,9 @@ export function createOverlayRenderers(state, deps) {
|
|
|
1628
1634
|
return tintedLines.map((l) => l + EL).join('\n')
|
|
1629
1635
|
}
|
|
1630
1636
|
|
|
1631
|
-
//
|
|
1632
|
-
// 📖
|
|
1633
|
-
// 📖
|
|
1634
|
-
// 📖 Two options: Enable (Y) or Not now (N). Phase 6 — Smart Model Router.
|
|
1635
|
-
function renderRouterOnboarding() {
|
|
1636
|
-
const EL = '\x1b[K'
|
|
1637
|
-
const lines = []
|
|
1638
|
-
const cursorLineByRow = {}
|
|
1639
|
-
|
|
1640
|
-
lines.push('')
|
|
1641
|
-
lines.push(` ${themeColors.accent('🚀')} ${themeColors.accentBold('free-coding-models')} ${themeColors.dim(`v${LOCAL_VERSION}`)}`)
|
|
1642
|
-
lines.push(` ${themeColors.textBold('🔀 Smart Router Available!')}`)
|
|
1643
|
-
lines.push('')
|
|
1644
|
-
lines.push(themeColors.dim(' FCM can run a background daemon that automatically'))
|
|
1645
|
-
lines.push(themeColors.dim(' routes your requests to the fastest healthy model —'))
|
|
1646
|
-
lines.push(themeColors.dim(' with zero manual intervention after initial setup.'))
|
|
1647
|
-
lines.push('')
|
|
1648
|
-
|
|
1649
|
-
const options = [
|
|
1650
|
-
{ label: 'Yes, enable the router', hint: 'Recommended — creates default set and starts daemon', key: 'Y' },
|
|
1651
|
-
{ label: 'Not now', hint: 'You can enable it later from the TUI', key: 'N' },
|
|
1652
|
-
]
|
|
1653
|
-
|
|
1654
|
-
if (state.routerOnboardingPhase === 'loading') {
|
|
1655
|
-
lines.push(themeColors.info(' Enabling router, please wait...'))
|
|
1656
|
-
} else if (state.routerOnboardingPhase === 'success') {
|
|
1657
|
-
lines.push(themeColors.success(' ✅ Router enabled! Dashboard opening...'))
|
|
1658
|
-
lines.push(themeColors.dim(' Setup complete. Return to the main table to continue.'))
|
|
1659
|
-
} else if (state.routerOnboardingPhase === 'error') {
|
|
1660
|
-
lines.push(themeColors.error(` ❌ ${state.routerOnboardingError || 'Failed to enable router'}`))
|
|
1661
|
-
lines.push(themeColors.dim(' Press Esc or Enter to continue to the main table'))
|
|
1662
|
-
} else {
|
|
1663
|
-
for (let i = 0; i < options.length; i++) {
|
|
1664
|
-
const opt = options[i]
|
|
1665
|
-
const isCursor = i === state.routerOnboardingCursor
|
|
1666
|
-
const keyLabel = themeColors.hotkey(` ${opt.key}]`)
|
|
1667
|
-
const row = `${bullet(isCursor)}${keyLabel} ${isCursor ? themeColors.textBold(opt.label) : themeColors.text(opt.label)}`
|
|
1668
|
-
cursorLineByRow[i] = lines.length
|
|
1669
|
-
lines.push(isCursor ? themeColors.bgCursorSettingsList(row) : row)
|
|
1670
|
-
lines.push(themeColors.dim(` ${opt.hint}`))
|
|
1671
|
-
lines.push('')
|
|
1672
|
-
}
|
|
1673
|
-
lines.push(themeColors.dim(' ↑↓ Navigate • Enter Select • Esc Skip for now'))
|
|
1674
|
-
lines.push('')
|
|
1675
|
-
lines.push(
|
|
1676
|
-
themeColors.dim(' 💬 ') +
|
|
1677
|
-
themeColors.footerDiscord('\x1b]8;;https://discord.gg/ZTNFHvvCkU\x1b\\Join the Discord community\x1b]8;;\x1b\\') +
|
|
1678
|
-
themeColors.dim(' • Get help, share feedback, follow updates')
|
|
1679
|
-
)
|
|
1680
|
-
}
|
|
1681
|
-
|
|
1682
|
-
const targetLine = cursorLineByRow[state.routerOnboardingCursor] ?? 0
|
|
1683
|
-
state.routerOnboardingScrollOffset = keepOverlayTargetVisible(state.routerOnboardingScrollOffset, targetLine, lines.length, state.terminalRows)
|
|
1684
|
-
const { visible, offset } = sliceOverlayLines(lines, state.routerOnboardingScrollOffset, state.terminalRows)
|
|
1685
|
-
state.routerOnboardingScrollOffset = offset
|
|
1686
|
-
const tintedLines = tintOverlayLines(visible, themeColors.overlayBgSettings, state.terminalCols)
|
|
1687
|
-
return tintedLines.map((l) => l + EL).join('\n')
|
|
1688
|
-
}
|
|
1637
|
+
// 📖 renderRouterOnboarding removed (dead code): nothing ever set
|
|
1638
|
+
// 📖 state.routerOnboardingOpen = true, so the overlay was unreachable.
|
|
1639
|
+
// 📖 The TUI auto-enables the router silently at startup instead (app.js).
|
|
1689
1640
|
|
|
1690
1641
|
return {
|
|
1691
1642
|
renderSettings,
|
|
@@ -1700,7 +1651,6 @@ export function createOverlayRenderers(state, deps) {
|
|
|
1700
1651
|
renderPlayground,
|
|
1701
1652
|
renderIncompatibleFallback,
|
|
1702
1653
|
renderTokenUsage,
|
|
1703
|
-
renderRouterOnboarding,
|
|
1704
1654
|
startRecommendAnalysis,
|
|
1705
1655
|
stopRecommendAnalysis,
|
|
1706
1656
|
overlayLayout,
|
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
* - `calculateViewport`: Compute visible slice of model rows
|
|
32
32
|
* - `sortResultsWithPinnedFavorites`: Sort with pinned items at top
|
|
33
33
|
* - `adjustScrollOffset`: Clamp scrollOffset so cursor stays visible
|
|
34
|
+
* - `runWithConcurrency`: Run async task factories with limited parallelism
|
|
35
|
+
* - `loadChangelogCached`: TTL-cached changelog reader for overlay renders
|
|
34
36
|
* - `fadedRow`: Multiply every 24-bit RGB channel by a factor to fade an
|
|
35
37
|
* entire ANSI-colored line uniformly — used for "unusable" rows.
|
|
36
38
|
*
|
|
@@ -51,12 +53,23 @@
|
|
|
51
53
|
import chalk from 'chalk'
|
|
52
54
|
import { OVERLAY_PANEL_WIDTH, TABLE_FIXED_LINES, TABLE_HEADER_LINES, TABLE_FOOTER_LINES } from '../core/constants.js'
|
|
53
55
|
import { sortResults } from '../core/utils.js'
|
|
56
|
+
import { loadChangelog } from '../core/changelog-loader.js'
|
|
57
|
+
|
|
58
|
+
// 📖 Shared ANSI escape pattern: CSI sequences (colors, \x1b[K, cursor moves),
|
|
59
|
+
// 📖 OSC sequences terminated by EITHER ST ("\x1b\\") or BEL ("\x07") so OSC 8
|
|
60
|
+
// 📖 hyperlinks never inflate width regardless of which terminator the emitter
|
|
61
|
+
// 📖 used, and any leftover escape pair. stripAnsi() and truncateAnsiWidth()
|
|
62
|
+
// 📖 both derive from this one source so width accounting can never disagree.
|
|
63
|
+
const ANSI_SEQUENCE_SOURCE = '\\x1b\\[[0-9;?]*[A-Za-z]|\\x1b\\][^\\x1b\\x07]*(?:\\x1b\\\\|\\x07)|\\x1b.'
|
|
64
|
+
const ANSI_SEQUENCE = new RegExp(ANSI_SEQUENCE_SOURCE, 'g') // 📖 match + drop
|
|
65
|
+
const ANSI_SEQUENCE_KEEP = new RegExp(`(${ANSI_SEQUENCE_SOURCE})`, 'g') // 📖 split + keep tokens
|
|
54
66
|
|
|
55
67
|
// 📖 stripAnsi: Remove ANSI control sequences to estimate visible text width before padding.
|
|
56
68
|
// 📖 Strips ALL CSI sequences (SGR colors, \x1b[K clear, cursor moves) and OSC
|
|
57
|
-
// 📖 sequences (hyperlinks) - every escape is zero-width
|
|
69
|
+
// 📖 sequences (hyperlinks, BEL- or ST-terminated) - every escape is zero-width
|
|
70
|
+
// 📖 for measurement purposes.
|
|
58
71
|
export function stripAnsi(input) {
|
|
59
|
-
return String(input).replace(
|
|
72
|
+
return String(input).replace(ANSI_SEQUENCE, '')
|
|
60
73
|
}
|
|
61
74
|
|
|
62
75
|
// 📖 fadedRow: Multiply every 24-bit RGB channel inside an ANSI-colored string by `factor`
|
|
@@ -173,8 +186,7 @@ export function truncateAnsiWidth(input, maxWidth, { ellipsis = '…' } = {}) {
|
|
|
173
186
|
|
|
174
187
|
// 📖 Split into ANSI sequences and visible characters so escapes are kept verbatim.
|
|
175
188
|
// 📖 Capturing group makes String.split keep the separators (ANSI tokens) in the result.
|
|
176
|
-
const
|
|
177
|
-
const tokens = text.split(ANSI_TOKEN).filter((t) => t !== '')
|
|
189
|
+
const tokens = text.split(ANSI_SEQUENCE_KEEP).filter((t) => t !== '')
|
|
178
190
|
let out = ''
|
|
179
191
|
let w = 0
|
|
180
192
|
const ellWidth = displayWidth(ellipsis)
|
|
@@ -333,3 +345,49 @@ export function adjustScrollOffset(st) {
|
|
|
333
345
|
if (st.scrollOffset > maxOffset) st.scrollOffset = maxOffset
|
|
334
346
|
if (st.scrollOffset < 0) st.scrollOffset = 0
|
|
335
347
|
}
|
|
348
|
+
|
|
349
|
+
// ─── Shared TUI task utilities ────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
// 📖 runWithConcurrency: Execute async task factories with limited parallelism
|
|
352
|
+
// 📖 (max `maxConcurrent` simultaneous). Per-task rejections are captured as
|
|
353
|
+
// 📖 { error } in the results array so one failure never rejects the whole
|
|
354
|
+
// 📖 batch (the 404 probe and the startup ping burst both rely on that).
|
|
355
|
+
// 📖 Previously this lived inside key-handler.js only, so app.js's startup
|
|
356
|
+
// 📖 burst had to fall back to unbounded Promise.all (~226 simultaneous pings,
|
|
357
|
+
// 📖 a guaranteed 429 storm). Exported so every call site shares one impl.
|
|
358
|
+
export async function runWithConcurrency(tasks, maxConcurrent) {
|
|
359
|
+
const results = new Array(tasks.length)
|
|
360
|
+
let nextIndex = 0
|
|
361
|
+
const workers = new Array(Math.max(1, maxConcurrent)).fill(null).map(async () => {
|
|
362
|
+
while (true) {
|
|
363
|
+
const index = nextIndex++
|
|
364
|
+
if (index >= tasks.length) break
|
|
365
|
+
try {
|
|
366
|
+
results[index] = await tasks[index]()
|
|
367
|
+
} catch (err) {
|
|
368
|
+
results[index] = { error: err }
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
})
|
|
372
|
+
return Promise.all(workers).then(() => results)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ─── Cached changelog loader ──────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
// 📖 loadChangelogCached: TTL-cached wrapper around core loadChangelog().
|
|
378
|
+
// 📖 The raw loader does readdirSync + readFileSync of every changelog/*.md on
|
|
379
|
+
// 📖 each call, and the TUI used to call it on EVERY keypress AND every render
|
|
380
|
+
// 📖 frame while the changelog overlay was open (~12 disk reads/sec). The
|
|
381
|
+
// 📖 parsed result is cached in module scope for a few seconds; the file set
|
|
382
|
+
// 📖 only changes on a release, so a short TTL is plenty fresh (and keeps the
|
|
383
|
+
// 📖 overlay correct across version bumps during development).
|
|
384
|
+
const CHANGELOG_CACHE_TTL_MS = 5000
|
|
385
|
+
let changelogCache = null
|
|
386
|
+
let changelogCacheAt = 0
|
|
387
|
+
export function loadChangelogCached() {
|
|
388
|
+
const now = Date.now()
|
|
389
|
+
if (changelogCache && (now - changelogCacheAt) < CHANGELOG_CACHE_TTL_MS) return changelogCache
|
|
390
|
+
changelogCache = loadChangelog()
|
|
391
|
+
changelogCacheAt = now
|
|
392
|
+
return changelogCache
|
|
393
|
+
}
|
package/src/tui/render-table.js
CHANGED
|
@@ -76,6 +76,12 @@ let _lastLayout = {
|
|
|
76
76
|
hasBelowIndicator: false, // 📖 whether "... N more below ..." is shown
|
|
77
77
|
footerHotkeys: [], // 📖 Array of { key, row, xStart, xEnd } for footer click zones
|
|
78
78
|
updateBannerRow: 0, // 📖 1-based terminal row of the fluorescent update banner (0 = none)
|
|
79
|
+
// 📖 Space-expanded detail card (issue #168): the 2 extra lines rendered under
|
|
80
|
+
// 📖 the cursor row shift every following row down. expandedDetailAfterIdx is
|
|
81
|
+
// 📖 the sorted index the card belongs to; the mouse handler subtracts
|
|
82
|
+
// 📖 expandedDetailRows from row offsets BELOW it so clicks map to the right model.
|
|
83
|
+
expandedDetailAfterIdx: -1,
|
|
84
|
+
expandedDetailRows: 0,
|
|
79
85
|
}
|
|
80
86
|
export function getLastLayout() { return _lastLayout }
|
|
81
87
|
|
|
@@ -691,6 +697,22 @@ export function renderTable({
|
|
|
691
697
|
}
|
|
692
698
|
return out
|
|
693
699
|
}
|
|
700
|
+
// 📖 clipToWidth: Width-aware HARD clip without ellipsis (used for the Model
|
|
701
|
+
// 📖 column label). The old `r.label.slice(0, nameWidth)` cut by UTF-16 units
|
|
702
|
+
// 📖 and could split surrogate pairs, rendering emoji model names as mojibake.
|
|
703
|
+
// 📖 This walks code points and stops at the max DISPLAY width instead.
|
|
704
|
+
const clipToWidth = (text, max) => {
|
|
705
|
+
if (max <= 0) return ''
|
|
706
|
+
let w = 0
|
|
707
|
+
let out = ''
|
|
708
|
+
for (const ch of String(text)) {
|
|
709
|
+
const cw = displayWidth(ch)
|
|
710
|
+
if (w + cw > max) break
|
|
711
|
+
out += ch
|
|
712
|
+
w += cw
|
|
713
|
+
}
|
|
714
|
+
return out
|
|
715
|
+
}
|
|
694
716
|
const paintSweScore = (score, paddedText) => {
|
|
695
717
|
if (score >= 70) return chalk.bold.rgb(...getTierRgb('S+'))(paddedText)
|
|
696
718
|
if (score >= 60) return chalk.bold.rgb(...getTierRgb('S'))(paddedText)
|
|
@@ -713,6 +735,9 @@ export function renderTable({
|
|
|
713
735
|
_lastLayout.viewportEndIdx = vp.endIdx
|
|
714
736
|
_lastLayout.hasAboveIndicator = vp.hasAbove
|
|
715
737
|
_lastLayout.hasBelowIndicator = vp.hasBelow
|
|
738
|
+
// 📖 Reset per-frame expansion layout info (re-set below when a card renders).
|
|
739
|
+
_lastLayout.expandedDetailAfterIdx = -1
|
|
740
|
+
_lastLayout.expandedDetailRows = 0
|
|
716
741
|
|
|
717
742
|
for (let i = vp.startIdx; i < vp.endIdx; i++) {
|
|
718
743
|
const r = sorted[i]
|
|
@@ -747,7 +772,9 @@ export function renderTable({
|
|
|
747
772
|
}
|
|
748
773
|
const prefixDisplayWidth = displayWidth(favoritePrefix)
|
|
749
774
|
const nameWidth = Math.max(0, wModel - prefixDisplayWidth)
|
|
750
|
-
|
|
775
|
+
// 📖 Width-aware clip + display padding keeps emoji labels intact and the
|
|
776
|
+
// 📖 column perfectly aligned (see clipToWidth above).
|
|
777
|
+
const name = favoritePrefix + padEndDisplay(clipToWidth(r.label, nameWidth), nameWidth)
|
|
751
778
|
const sweScore = r.sweScore ?? '—'
|
|
752
779
|
// 📖 SWE% colorized on the same gradient as Tier:
|
|
753
780
|
// ≥70% bright neon green (S+), ≥60% green (S), ≥50% yellow-green (A+),
|
|
@@ -1090,6 +1117,10 @@ export function renderTable({
|
|
|
1090
1117
|
// 📖 truncate them. Plain text is built first, clipped to the terminal
|
|
1091
1118
|
// 📖 width, then colorized (same pattern as the table header).
|
|
1092
1119
|
if (isCursor && expansionActive) {
|
|
1120
|
+
// 📖 Mouse support (fix 15): record that 2 extra rows follow sorted index i
|
|
1121
|
+
// 📖 so the click handler can shift row mappings below the card.
|
|
1122
|
+
_lastLayout.expandedDetailAfterIdx = i
|
|
1123
|
+
_lastLayout.expandedDetailRows = EXPANDED_DETAIL_LINES
|
|
1093
1124
|
const maxDetailWidth = Math.max(0, (terminalCols || 80) - 4)
|
|
1094
1125
|
const keyText = r.hasApiKey ? 'key configured' : 'no key'
|
|
1095
1126
|
const endpoint = sources[r.providerKey]?.url ?? 'unknown endpoint'
|
|
@@ -1390,9 +1421,9 @@ export function renderTable({
|
|
|
1390
1421
|
if (releaseLabel || speedTestLabel || globalBenchmarkLabel || probeLabel || probeCacheLabel || pausedProvidersLabel || enrichmentLabel || quotaLabel || actionErrorLabel) {
|
|
1391
1422
|
const parts = [
|
|
1392
1423
|
{ text: ' ', key: null },
|
|
1393
|
-
{ text: speedTestLabel, key: 'a' },
|
|
1424
|
+
{ text: speedTestLabel, key: 'ctrl+a' },
|
|
1394
1425
|
{ text: ' ', key: null },
|
|
1395
|
-
{ text: globalBenchmarkLabel, key: 'u' },
|
|
1426
|
+
{ text: globalBenchmarkLabel, key: 'ctrl+u' },
|
|
1396
1427
|
{ text: probeLabel ? ' ' : '', key: null },
|
|
1397
1428
|
{ text: probeLabel, key: null },
|
|
1398
1429
|
{ text: actionErrorLabel ? ' ' : '', key: null },
|
package/src/tui/theme.js
CHANGED
|
@@ -280,6 +280,35 @@ export function getReadableTextRgb(bgRgb) {
|
|
|
280
280
|
return yiq >= 150 ? [10, 16, 28] : [248, 251, 255]
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
// 📖 Cached OS-level appearance probe ('dark' | 'light'). The sync execSync
|
|
284
|
+
// 📖 probe blocks the event loop up to 100ms and used to run inside the G
|
|
285
|
+
// 📖 keypress, visibly freezing the UI on every theme cycle. It now resolves
|
|
286
|
+
// 📖 once at startup; later calls reuse the cache and kick off a non-blocking
|
|
287
|
+
// 📖 re-resolve so a mid-session macOS appearance change is picked up on the
|
|
288
|
+
// 📖 NEXT press instead of freezing the current one.
|
|
289
|
+
let osThemeCache = null
|
|
290
|
+
|
|
291
|
+
function execOsThemeProbeSync() {
|
|
292
|
+
try {
|
|
293
|
+
const style = execSync('defaults read -g AppleInterfaceStyle 2>/dev/null', { timeout: 100 }).toString().trim()
|
|
294
|
+
return style === 'Dark' ? 'dark' : 'light'
|
|
295
|
+
} catch {
|
|
296
|
+
// 📖 The key is absent in Light mode, so a non-zero exit means light.
|
|
297
|
+
return 'light'
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function refreshOsThemeAsync() {
|
|
302
|
+
import('node:child_process')
|
|
303
|
+
.then(({ execFile }) => {
|
|
304
|
+
execFile('defaults', ['read', '-g', 'AppleInterfaceStyle'], { timeout: 500 }, (err, stdout) => {
|
|
305
|
+
// 📖 Same semantics as the sync probe: read error = Light mode.
|
|
306
|
+
osThemeCache = (!err && String(stdout).trim() === 'Dark') ? 'dark' : 'light'
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
.catch(() => {})
|
|
310
|
+
}
|
|
311
|
+
|
|
283
312
|
export function detectActiveTheme(configTheme = 'auto') {
|
|
284
313
|
if (configTheme === 'dark' || configTheme === 'light') {
|
|
285
314
|
activeTheme = configTheme
|
|
@@ -297,12 +326,14 @@ export function detectActiveTheme(configTheme = 'auto') {
|
|
|
297
326
|
}
|
|
298
327
|
|
|
299
328
|
if (process.platform === 'darwin') {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
|
|
329
|
+
if (osThemeCache === null) {
|
|
330
|
+
// 📖 First call (startup): one sync probe, then never again this session.
|
|
331
|
+
osThemeCache = execOsThemeProbeSync()
|
|
332
|
+
} else {
|
|
333
|
+
// 📖 Subsequent G presses: reuse the cache, refresh it in the background.
|
|
334
|
+
refreshOsThemeAsync()
|
|
305
335
|
}
|
|
336
|
+
activeTheme = osThemeCache
|
|
306
337
|
return activeTheme
|
|
307
338
|
}
|
|
308
339
|
|
package/src/tui/tui-filters.js
CHANGED
|
@@ -8,14 +8,16 @@
|
|
|
8
8
|
* keypress that changes a filter mode, so the visible set is always consistent.
|
|
9
9
|
*
|
|
10
10
|
* 🎯 Filter precedence (first failing check hides the row):
|
|
11
|
-
* 1.
|
|
12
|
-
* 2.
|
|
13
|
-
* 3.
|
|
14
|
-
* 4.
|
|
15
|
-
* 5.
|
|
16
|
-
* 6.
|
|
17
|
-
* 7.
|
|
18
|
-
* 8.
|
|
11
|
+
* 1. Probe-cache broken models - cachedBroken rows stay hidden (Shift+B shows them)
|
|
12
|
+
* 2. 404-probe hidden models - keys in config.hiddenModels stay hidden
|
|
13
|
+
* 3. Configure-only mode (E key) - hide models with no API key or bad health
|
|
14
|
+
* 4. Usable-only mode (E key, second cycle) - only Health UP + verdict Normal/Perfect/Slow
|
|
15
|
+
* 5. Sticky favorites (Y key) - pinned favorites bypass tier/origin/text filters
|
|
16
|
+
* 6. Tier filter (T key) - only show models in the selected tier family
|
|
17
|
+
* 7. Origin filter (D key) - only show models from the selected provider
|
|
18
|
+
* 8. Verdict filter (Shift+V key) - only show models with the selected verdict
|
|
19
|
+
* 9. Health filter (Shift+H key) - only show models with the selected health status
|
|
20
|
+
* 10. Custom text filter (Ctrl+P) - case-insensitive match on name, ctx, provider
|
|
19
21
|
*
|
|
20
22
|
* @functions
|
|
21
23
|
* → createTuiFilters(state, deps) — Returns { applyTierFilter, buildOriginCycle }
|
|
@@ -54,6 +56,24 @@ export function createTuiFilters(state, { sources, getApiKey, PROVIDER_METADATA
|
|
|
54
56
|
const activeHealth = HEALTH_CYCLE[state.healthFilterMode]
|
|
55
57
|
|
|
56
58
|
state.results.forEach(r => {
|
|
59
|
+
// 📖 Probe-cache / 404-probe auto-hide must survive every frame: this filter
|
|
60
|
+
// 📖 runs at ~12 FPS and used to reset r.hidden=false for passing rows,
|
|
61
|
+
// 📖 clobbering the hidden state set at startup (app.js probe-cache prefill)
|
|
62
|
+
// 📖 and by the Shift+P / Ctrl+Shift+P 404 probe (config.hiddenModels).
|
|
63
|
+
// 📖 Same representation as app.js + key-handler Shift+B: cachedBroken rows
|
|
64
|
+
// 📖 hide unless showBrokenMode, and hiddenModels stores `provider/modelId`
|
|
65
|
+
// 📖 keys. autoHideBrokenModels=false unhides both (matches startup behavior).
|
|
66
|
+
const autoHideBrokenEnabled = state.config.settings?.autoHideBrokenModels !== false
|
|
67
|
+
if (r.cachedBroken && !state.showBrokenMode && autoHideBrokenEnabled) {
|
|
68
|
+
r.hidden = true
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
if (autoHideBrokenEnabled
|
|
72
|
+
&& state.config.hiddenModels instanceof Set
|
|
73
|
+
&& state.config.hiddenModels.has(`${r.providerKey}/${r.modelId}`)) {
|
|
74
|
+
r.hidden = true
|
|
75
|
+
return
|
|
76
|
+
}
|
|
57
77
|
const stickyFavorite = state.favoritesPinnedAndSticky && r.isFavorite
|
|
58
78
|
// 📖 CLI-only tools and Zen models don't need traditional API keys —
|
|
59
79
|
// 📖 they authenticate via their own CLI login flow, so "configured only" should never hide them.
|
package/src/tui/tui-state.js
CHANGED
|
@@ -44,6 +44,22 @@ export const SPEED_MODE_DURATION_MS = 60_000
|
|
|
44
44
|
// 📖 After this much inactivity, the ping loop slows down to save quota.
|
|
45
45
|
export const IDLE_SLOW_AFTER_MS = 5 * 60_000
|
|
46
46
|
|
|
47
|
+
// 📖 Max ping results kept per model row. Older entries shift out so r.pings
|
|
48
|
+
// 📖 cannot grow unbounded during long sessions (avg/p95/uptime become a
|
|
49
|
+
// 📖 rolling window over the last 300 pings instead of the whole session).
|
|
50
|
+
export const PING_HISTORY_CAP = 300
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 📖 sanitizePingInterval: Accept only a positive finite number, else fallback.
|
|
54
|
+
* 📖 Shared guard for config.settings.pingInterval readers (TUI state, palette).
|
|
55
|
+
* @param {unknown} value
|
|
56
|
+
* @param {number} fallbackMs
|
|
57
|
+
* @returns {number}
|
|
58
|
+
*/
|
|
59
|
+
export function sanitizePingInterval(value, fallbackMs) {
|
|
60
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallbackMs
|
|
61
|
+
}
|
|
62
|
+
|
|
47
63
|
/**
|
|
48
64
|
* 📖 intervalToPingMode: Map a raw interval (ms) to the closest ping mode label.
|
|
49
65
|
* @param {number} intervalMs
|
|
@@ -88,13 +104,12 @@ export function createTuiState({
|
|
|
88
104
|
const now = Date.now()
|
|
89
105
|
|
|
90
106
|
// 📖 Dynamic normal interval from config (default 10s)
|
|
91
|
-
const normalInterval = config.settings?.pingInterval
|
|
107
|
+
const normalInterval = sanitizePingInterval(config.settings?.pingInterval, PING_MODE_INTERVALS.normal)
|
|
92
108
|
// 📖 Respect config.settings.pingInterval at startup (issue #155).
|
|
93
109
|
// 📖 CLI override is already merged into config.settings.pingInterval in app.js.
|
|
94
|
-
|
|
95
|
-
const hasConfiguredInterval =
|
|
96
|
-
|
|
97
|
-
const initialInterval = hasConfiguredInterval ? configuredInterval : PING_MODE_INTERVALS.speed
|
|
110
|
+
// 📖 sanitizePingInterval rejects 0/negative/NaN so a corrupt config can't break pinging.
|
|
111
|
+
const hasConfiguredInterval = sanitizePingInterval(config.settings?.pingInterval, 0) > 0
|
|
112
|
+
const initialInterval = hasConfiguredInterval ? normalInterval : PING_MODE_INTERVALS.speed
|
|
98
113
|
const initialMode = intervalToPingMode(initialInterval)
|
|
99
114
|
|
|
100
115
|
return {
|
|
@@ -164,6 +179,7 @@ export function createTuiState({
|
|
|
164
179
|
settingsAddKeyMode: false,
|
|
165
180
|
settingsEditBuffer: '',
|
|
166
181
|
settingsErrorMsg: null,
|
|
182
|
+
settingsErrorMsgClearTimer: null, // 📖 Tracked auto-clear timer (see key-handler scheduleErrorMsgClear)
|
|
167
183
|
settingsTestResults: {},
|
|
168
184
|
settingsTestDetails: {},
|
|
169
185
|
settingsUpdateState: 'idle',
|
|
@@ -251,6 +267,7 @@ export function createTuiState({
|
|
|
251
267
|
installedModelsScrollOffset: 0,
|
|
252
268
|
installedModelsData: [],
|
|
253
269
|
installedModelsErrorMsg: null,
|
|
270
|
+
installedModelsErrorMsgClearTimer: null, // 📖 Tracked auto-clear timer (see key-handler scheduleErrorMsgClear)
|
|
254
271
|
|
|
255
272
|
// 📖 Router Dashboard overlay (Shift+R)
|
|
256
273
|
routerDashboardOpen: false,
|
|
@@ -272,7 +289,6 @@ export function createTuiState({
|
|
|
272
289
|
routerDashboardEventError: null,
|
|
273
290
|
routerDashboardNotice: null,
|
|
274
291
|
routerDashboardNoticeTimer: null,
|
|
275
|
-
routerOnboardingScrollOffset: 0,
|
|
276
292
|
routerDashboardEverOpened: false,
|
|
277
293
|
routerDashboardCursorIndex: 0,
|
|
278
294
|
|