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.
Files changed (165) hide show
  1. package/README.md +109 -888
  2. package/bin/free-coding-models.js +4 -1
  3. package/changelog/v0.5.87.md +48 -0
  4. package/changelog/v0.5.88.md +16 -0
  5. package/package.json +13 -5
  6. package/sources.js +4 -4
  7. package/src/core/cache.js +3 -1
  8. package/src/core/config.js +43 -5
  9. package/src/core/endpoint-installer.js +23 -12
  10. package/src/core/installed-models-manager.js +43 -20
  11. package/src/core/kilo-config.js +51 -5
  12. package/src/core/kilo.js +5 -3
  13. package/src/core/legacy-proxy-cleanup.js +22 -5
  14. package/src/core/model-merger.js +4 -0
  15. package/src/core/models-drift.js +9 -0
  16. package/src/core/opencode-config.js +57 -5
  17. package/src/core/opencode.js +67 -41
  18. package/src/core/playground.js +7 -0
  19. package/src/core/probe-cache.js +35 -4
  20. package/src/core/provider-key-tester.js +10 -2
  21. package/src/core/provider-metadata.js +10 -0
  22. package/src/core/provider-quota-fetchers.js +14 -7
  23. package/src/core/router-daemon.js +208 -26
  24. package/src/core/runtime-telemetry.js +27 -1
  25. package/src/core/security.js +170 -24
  26. package/src/core/shared-helpers.js +12 -1
  27. package/src/core/shell-env.js +3 -1
  28. package/src/core/telemetry.js +18 -5
  29. package/src/core/tool-launchers.js +9 -6
  30. package/src/core/updater.js +28 -2
  31. package/src/core/utils.js +173 -71
  32. package/src/tui/app.js +38 -14
  33. package/src/tui/command-palette.js +22 -2
  34. package/src/tui/key-handler.js +123 -179
  35. package/src/tui/overlays.js +21 -71
  36. package/src/tui/render-helpers.js +62 -4
  37. package/src/tui/render-table.js +34 -3
  38. package/src/tui/theme.js +36 -5
  39. package/src/tui/tui-filters.js +28 -8
  40. package/src/tui/tui-state.js +22 -6
  41. package/web/dist/assets/index-CAzFIt8P.css +1 -0
  42. package/web/dist/assets/index-DFg1h0Nd.js +44 -0
  43. package/web/dist/index.html +5 -9
  44. package/web/index.html +3 -7
  45. package/web/server.js +97 -23
  46. package/web/vite.config.js +8 -0
  47. package/web/app.legacy.js +0 -900
  48. package/web/dist/assets/index-C5-Ywvv3.css +0 -1
  49. package/web/dist/assets/index-Dm-Xfby9.js +0 -40
  50. package/web/public/favicon.ico +0 -0
  51. package/web/public/favicons/apple-touch-icon.png +0 -0
  52. package/web/public/favicons/browserconfig.xml +0 -12
  53. package/web/public/favicons/favicon-16x16.png +0 -0
  54. package/web/public/favicons/favicon-192x192.png +0 -0
  55. package/web/public/favicons/favicon-32x32.png +0 -0
  56. package/web/public/favicons/favicon-48x48.png +0 -0
  57. package/web/public/favicons/favicon-512x512.png +0 -0
  58. package/web/public/favicons/favicon-96x96.png +0 -0
  59. package/web/public/favicons/favicon.ico +0 -0
  60. package/web/public/favicons/mstile-150x150.png +0 -0
  61. package/web/public/favicons/mstile-310x150.png +0 -0
  62. package/web/public/favicons/mstile-310x310.png +0 -0
  63. package/web/public/favicons/mstile-512x512.png +0 -0
  64. package/web/public/favicons/mstile-70x70.png +0 -0
  65. package/web/public/favicons/site.webmanifest +0 -25
  66. package/web/src/App.jsx +0 -521
  67. package/web/src/components/analytics/AnalyticsView.jsx +0 -147
  68. package/web/src/components/analytics/AnalyticsView.module.css +0 -190
  69. package/web/src/components/analytics/TokenUsagePanel.jsx +0 -105
  70. package/web/src/components/analytics/TokenUsagePanel.module.css +0 -126
  71. package/web/src/components/atoms/AILatencyCell.jsx +0 -38
  72. package/web/src/components/atoms/AILatencyCell.module.css +0 -51
  73. package/web/src/components/atoms/HealthCell.jsx +0 -143
  74. package/web/src/components/atoms/HealthCell.module.css +0 -60
  75. package/web/src/components/atoms/LastPingCell.jsx +0 -35
  76. package/web/src/components/atoms/LastPingCell.module.css +0 -45
  77. package/web/src/components/atoms/MoodCell.jsx +0 -25
  78. package/web/src/components/atoms/MoodCell.module.css +0 -6
  79. package/web/src/components/atoms/NoKeyIcon.jsx +0 -65
  80. package/web/src/components/atoms/NoKeyIcon.module.css +0 -30
  81. package/web/src/components/atoms/ProviderLogo.jsx +0 -187
  82. package/web/src/components/atoms/ProviderLogo.module.css +0 -103
  83. package/web/src/components/atoms/RankCell.jsx +0 -9
  84. package/web/src/components/atoms/RankCell.module.css +0 -9
  85. package/web/src/components/atoms/Sparkline.jsx +0 -63
  86. package/web/src/components/atoms/StabilityCell.jsx +0 -18
  87. package/web/src/components/atoms/StabilityCell.module.css +0 -13
  88. package/web/src/components/atoms/StatusDot.jsx +0 -13
  89. package/web/src/components/atoms/StatusDot.module.css +0 -29
  90. package/web/src/components/atoms/TPSCell.jsx +0 -36
  91. package/web/src/components/atoms/TPSCell.module.css +0 -44
  92. package/web/src/components/atoms/TierBadge.jsx +0 -10
  93. package/web/src/components/atoms/TierBadge.module.css +0 -29
  94. package/web/src/components/atoms/Toast.jsx +0 -25
  95. package/web/src/components/atoms/Toast.module.css +0 -41
  96. package/web/src/components/atoms/ToastContainer.jsx +0 -16
  97. package/web/src/components/atoms/ToastContainer.module.css +0 -10
  98. package/web/src/components/atoms/VerdictBadge.jsx +0 -36
  99. package/web/src/components/atoms/VerdictBadge.module.css +0 -40
  100. package/web/src/components/changelog/ChangelogView.jsx +0 -135
  101. package/web/src/components/changelog/ChangelogView.module.css +0 -159
  102. package/web/src/components/dashboard/DetailPanel.jsx +0 -217
  103. package/web/src/components/dashboard/DetailPanel.module.css +0 -260
  104. package/web/src/components/dashboard/ExpandedDetailRow.jsx +0 -361
  105. package/web/src/components/dashboard/ExpandedDetailRow.module.css +0 -356
  106. package/web/src/components/dashboard/ExportModal.jsx +0 -84
  107. package/web/src/components/dashboard/ExportModal.module.css +0 -99
  108. package/web/src/components/dashboard/FilterBar.jsx +0 -338
  109. package/web/src/components/dashboard/FilterBar.module.css +0 -342
  110. package/web/src/components/dashboard/ModelTable.jsx +0 -672
  111. package/web/src/components/dashboard/ModelTable.module.css +0 -446
  112. package/web/src/components/dashboard/ProviderDropdown.jsx +0 -156
  113. package/web/src/components/dashboard/ProviderDropdown.module.css +0 -248
  114. package/web/src/components/help/HelpView.jsx +0 -201
  115. package/web/src/components/help/HelpView.module.css +0 -156
  116. package/web/src/components/install/InstallEndpointsView.jsx +0 -309
  117. package/web/src/components/install/InstallEndpointsView.module.css +0 -427
  118. package/web/src/components/installed/InstalledModelsView.jsx +0 -89
  119. package/web/src/components/installed/InstalledModelsView.module.css +0 -200
  120. package/web/src/components/launch/IncompatibleFallbackModal.jsx +0 -69
  121. package/web/src/components/launch/LaunchButton.jsx +0 -30
  122. package/web/src/components/launch/LaunchButton.module.css +0 -35
  123. package/web/src/components/launch/LaunchModal.module.css +0 -125
  124. package/web/src/components/layout/Footer.jsx +0 -19
  125. package/web/src/components/layout/Footer.module.css +0 -10
  126. package/web/src/components/layout/Header.jsx +0 -265
  127. package/web/src/components/layout/Header.module.css +0 -357
  128. package/web/src/components/palette/CommandPalette.jsx +0 -301
  129. package/web/src/components/palette/CommandPalette.module.css +0 -121
  130. package/web/src/components/playground/PlaygroundChat.jsx +0 -527
  131. package/web/src/components/playground/PlaygroundChat.module.css +0 -382
  132. package/web/src/components/playground/PlaygroundView.jsx +0 -371
  133. package/web/src/components/playground/PlaygroundView.module.css +0 -504
  134. package/web/src/components/recommend/RecommendView.jsx +0 -121
  135. package/web/src/components/recommend/RecommendView.module.css +0 -57
  136. package/web/src/components/router/RouterView.jsx +0 -1212
  137. package/web/src/components/router/RouterView.module.css +0 -1289
  138. package/web/src/components/settings/SettingsView.jsx +0 -555
  139. package/web/src/components/settings/SettingsView.module.css +0 -550
  140. package/web/src/components/tools/ToolPicker.jsx +0 -86
  141. package/web/src/components/tools/ToolPicker.module.css +0 -97
  142. package/web/src/components/update/UpdateChip.jsx +0 -104
  143. package/web/src/components/update/UpdateChip.module.css +0 -146
  144. package/web/src/global.css +0 -298
  145. package/web/src/hooks/urlState.constants.js +0 -28
  146. package/web/src/hooks/useChangelog.js +0 -51
  147. package/web/src/hooks/useColumnSizing.js +0 -211
  148. package/web/src/hooks/useFavorites.js +0 -150
  149. package/web/src/hooks/useFilter.js +0 -310
  150. package/web/src/hooks/useInstalledModels.js +0 -42
  151. package/web/src/hooks/useRecommend.js +0 -67
  152. package/web/src/hooks/useRouterDashboard.js +0 -133
  153. package/web/src/hooks/useSSE.js +0 -73
  154. package/web/src/hooks/useSocket.js +0 -203
  155. package/web/src/hooks/useTheme.js +0 -83
  156. package/web/src/hooks/useTokenUsage.js +0 -103
  157. package/web/src/hooks/useToolMode.js +0 -77
  158. package/web/src/hooks/useUpdateChecker.js +0 -91
  159. package/web/src/hooks/useUrlState.js +0 -170
  160. package/web/src/main.jsx +0 -15
  161. package/web/src/utils/download.js +0 -15
  162. package/web/src/utils/format.js +0 -42
  163. package/web/src/utils/m3.js +0 -57
  164. package/web/src/utils/ranks.js +0 -37
  165. package/web/styles.legacy.css +0 -963
@@ -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
- state.toolInstallPromptScrollOffset = keepOverlayTargetVisible(
520
- state.toolInstallPromptScrollOffset,
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.toolInstallPromptScrollOffset, state.terminalRows)
526
- state.toolInstallPromptScrollOffset = offset
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
- state.installEndpointsScrollOffset = keepOverlayTargetVisible(
714
- state.installEndpointsScrollOffset,
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.installEndpointsScrollOffset, state.terminalRows)
720
- state.installEndpointsScrollOffset = offset
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, 80) : []
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
- const changelogData = loadChangelog()
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
- // ─── Router Onboarding overlay renderer ─────────────────────────────────────
1632
- // 📖 renderRouterOnboarding: shown on first launch (no config.router) or
1633
- // 📖 first launch after upgrade (existing config but router.onboardingSeen !== true).
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 for measurement purposes.
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(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/\x1b\][^\x1b]*\x1b\\/g, '')
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 ANSI_TOKEN = /(\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x1b]*(?:\x1b\\|\u0007)?|\x1b.)/g
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
+ }
@@ -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
- const name = favoritePrefix + r.label.slice(0, nameWidth).padEnd(nameWidth)
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
- try {
301
- const style = execSync('defaults read -g AppleInterfaceStyle 2>/dev/null', { timeout: 100 }).toString().trim()
302
- activeTheme = style === 'Dark' ? 'dark' : 'light'
303
- } catch {
304
- activeTheme = 'light'
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
 
@@ -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. Configure-only mode (E key) hide models with no API key or bad health
12
- * 2. Usable-only mode (E key, second cycle) only Health UP + verdict Normal/Perfect/Slow
13
- * 3. Sticky favorites (Y key) pinned favorites bypass tier/origin/text filters
14
- * 4. Tier filter (T key) only show models in the selected tier family
15
- * 5. Origin filter (O key) only show models from the selected provider
16
- * 6. Verdict filter (V key) only show models with the selected verdict
17
- * 7. Health filter (H key) only show models with the selected health status
18
- * 8. Custom text filter (Ctrl+P) — case-insensitive match on name, ctx, provider
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.
@@ -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 ?? PING_MODE_INTERVALS.normal
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
- const configuredInterval = config.settings?.pingInterval
95
- const hasConfiguredInterval =
96
- typeof configuredInterval === 'number' && Number.isFinite(configuredInterval) && configuredInterval > 0
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