free-coding-models 0.5.85 → 0.5.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +110 -881
  2. package/bin/free-coding-models.js +4 -1
  3. package/changelog/v0.5.86.md +10 -0
  4. package/changelog/v0.5.87.md +48 -0
  5. package/package.json +8 -5
  6. package/sources.js +4 -4
  7. package/src/core/cache.js +3 -1
  8. package/src/core/config.js +43 -5
  9. package/src/core/endpoint-installer.js +23 -12
  10. package/src/core/installed-models-manager.js +43 -20
  11. package/src/core/kilo-config.js +51 -5
  12. package/src/core/kilo.js +5 -3
  13. package/src/core/legacy-proxy-cleanup.js +22 -5
  14. package/src/core/model-merger.js +4 -0
  15. package/src/core/models-drift.js +9 -0
  16. package/src/core/opencode-config.js +57 -5
  17. package/src/core/opencode.js +67 -41
  18. package/src/core/playground.js +7 -0
  19. package/src/core/probe-cache.js +35 -4
  20. package/src/core/provider-key-tester.js +10 -2
  21. package/src/core/provider-metadata.js +10 -0
  22. package/src/core/provider-quota-fetchers.js +14 -7
  23. package/src/core/router-daemon.js +208 -26
  24. package/src/core/runtime-telemetry.js +27 -1
  25. package/src/core/shared-helpers.js +12 -1
  26. package/src/core/shell-env.js +3 -1
  27. package/src/core/telemetry.js +18 -5
  28. package/src/core/tool-launchers.js +9 -6
  29. package/src/core/updater.js +28 -2
  30. package/src/core/utils.js +76 -71
  31. package/src/tui/app.js +47 -16
  32. package/src/tui/command-palette.js +22 -2
  33. package/src/tui/key-handler.js +123 -179
  34. package/src/tui/overlays.js +35 -75
  35. package/src/tui/render-helpers.js +62 -4
  36. package/src/tui/render-table.js +46 -5
  37. package/src/tui/theme.js +36 -5
  38. package/src/tui/tui-filters.js +28 -8
  39. package/src/tui/tui-state.js +22 -6
  40. package/web/dist/assets/index-BpTpPS0g.js +44 -0
  41. package/web/dist/assets/index-CAzFIt8P.css +1 -0
  42. package/web/dist/index.html +5 -9
  43. package/web/index.html +3 -7
  44. package/web/server.js +97 -23
  45. package/web/vite.config.js +8 -0
  46. package/web/app.legacy.js +0 -900
  47. package/web/dist/assets/index-C5-Ywvv3.css +0 -1
  48. package/web/dist/assets/index-DWUely84.js +0 -40
  49. package/web/public/favicon.ico +0 -0
  50. package/web/public/favicons/apple-touch-icon.png +0 -0
  51. package/web/public/favicons/browserconfig.xml +0 -12
  52. package/web/public/favicons/favicon-16x16.png +0 -0
  53. package/web/public/favicons/favicon-192x192.png +0 -0
  54. package/web/public/favicons/favicon-32x32.png +0 -0
  55. package/web/public/favicons/favicon-48x48.png +0 -0
  56. package/web/public/favicons/favicon-512x512.png +0 -0
  57. package/web/public/favicons/favicon-96x96.png +0 -0
  58. package/web/public/favicons/favicon.ico +0 -0
  59. package/web/public/favicons/mstile-150x150.png +0 -0
  60. package/web/public/favicons/mstile-310x150.png +0 -0
  61. package/web/public/favicons/mstile-310x310.png +0 -0
  62. package/web/public/favicons/mstile-512x512.png +0 -0
  63. package/web/public/favicons/mstile-70x70.png +0 -0
  64. package/web/public/favicons/site.webmanifest +0 -25
  65. package/web/src/App.jsx +0 -521
  66. package/web/src/components/analytics/AnalyticsView.jsx +0 -147
  67. package/web/src/components/analytics/AnalyticsView.module.css +0 -190
  68. package/web/src/components/analytics/TokenUsagePanel.jsx +0 -105
  69. package/web/src/components/analytics/TokenUsagePanel.module.css +0 -126
  70. package/web/src/components/atoms/AILatencyCell.jsx +0 -38
  71. package/web/src/components/atoms/AILatencyCell.module.css +0 -51
  72. package/web/src/components/atoms/HealthCell.jsx +0 -143
  73. package/web/src/components/atoms/HealthCell.module.css +0 -60
  74. package/web/src/components/atoms/LastPingCell.jsx +0 -35
  75. package/web/src/components/atoms/LastPingCell.module.css +0 -45
  76. package/web/src/components/atoms/MoodCell.jsx +0 -25
  77. package/web/src/components/atoms/MoodCell.module.css +0 -6
  78. package/web/src/components/atoms/NoKeyIcon.jsx +0 -65
  79. package/web/src/components/atoms/NoKeyIcon.module.css +0 -30
  80. package/web/src/components/atoms/ProviderLogo.jsx +0 -187
  81. package/web/src/components/atoms/ProviderLogo.module.css +0 -103
  82. package/web/src/components/atoms/RankCell.jsx +0 -9
  83. package/web/src/components/atoms/RankCell.module.css +0 -9
  84. package/web/src/components/atoms/Sparkline.jsx +0 -63
  85. package/web/src/components/atoms/StabilityCell.jsx +0 -18
  86. package/web/src/components/atoms/StabilityCell.module.css +0 -13
  87. package/web/src/components/atoms/StatusDot.jsx +0 -13
  88. package/web/src/components/atoms/StatusDot.module.css +0 -29
  89. package/web/src/components/atoms/TPSCell.jsx +0 -36
  90. package/web/src/components/atoms/TPSCell.module.css +0 -44
  91. package/web/src/components/atoms/TierBadge.jsx +0 -10
  92. package/web/src/components/atoms/TierBadge.module.css +0 -29
  93. package/web/src/components/atoms/Toast.jsx +0 -25
  94. package/web/src/components/atoms/Toast.module.css +0 -41
  95. package/web/src/components/atoms/ToastContainer.jsx +0 -16
  96. package/web/src/components/atoms/ToastContainer.module.css +0 -10
  97. package/web/src/components/atoms/VerdictBadge.jsx +0 -36
  98. package/web/src/components/atoms/VerdictBadge.module.css +0 -40
  99. package/web/src/components/changelog/ChangelogView.jsx +0 -135
  100. package/web/src/components/changelog/ChangelogView.module.css +0 -159
  101. package/web/src/components/dashboard/DetailPanel.jsx +0 -217
  102. package/web/src/components/dashboard/DetailPanel.module.css +0 -260
  103. package/web/src/components/dashboard/ExpandedDetailRow.jsx +0 -361
  104. package/web/src/components/dashboard/ExpandedDetailRow.module.css +0 -356
  105. package/web/src/components/dashboard/ExportModal.jsx +0 -84
  106. package/web/src/components/dashboard/ExportModal.module.css +0 -99
  107. package/web/src/components/dashboard/FilterBar.jsx +0 -338
  108. package/web/src/components/dashboard/FilterBar.module.css +0 -342
  109. package/web/src/components/dashboard/ModelTable.jsx +0 -672
  110. package/web/src/components/dashboard/ModelTable.module.css +0 -446
  111. package/web/src/components/dashboard/ProviderDropdown.jsx +0 -156
  112. package/web/src/components/dashboard/ProviderDropdown.module.css +0 -248
  113. package/web/src/components/help/HelpView.jsx +0 -201
  114. package/web/src/components/help/HelpView.module.css +0 -156
  115. package/web/src/components/install/InstallEndpointsView.jsx +0 -309
  116. package/web/src/components/install/InstallEndpointsView.module.css +0 -427
  117. package/web/src/components/installed/InstalledModelsView.jsx +0 -89
  118. package/web/src/components/installed/InstalledModelsView.module.css +0 -200
  119. package/web/src/components/launch/IncompatibleFallbackModal.jsx +0 -69
  120. package/web/src/components/launch/LaunchButton.jsx +0 -30
  121. package/web/src/components/launch/LaunchButton.module.css +0 -35
  122. package/web/src/components/launch/LaunchModal.module.css +0 -125
  123. package/web/src/components/layout/Footer.jsx +0 -19
  124. package/web/src/components/layout/Footer.module.css +0 -10
  125. package/web/src/components/layout/Header.jsx +0 -265
  126. package/web/src/components/layout/Header.module.css +0 -357
  127. package/web/src/components/palette/CommandPalette.jsx +0 -301
  128. package/web/src/components/palette/CommandPalette.module.css +0 -121
  129. package/web/src/components/playground/PlaygroundChat.jsx +0 -527
  130. package/web/src/components/playground/PlaygroundChat.module.css +0 -382
  131. package/web/src/components/playground/PlaygroundView.jsx +0 -371
  132. package/web/src/components/playground/PlaygroundView.module.css +0 -504
  133. package/web/src/components/recommend/RecommendView.jsx +0 -121
  134. package/web/src/components/recommend/RecommendView.module.css +0 -57
  135. package/web/src/components/router/RouterView.jsx +0 -1212
  136. package/web/src/components/router/RouterView.module.css +0 -1289
  137. package/web/src/components/settings/SettingsView.jsx +0 -555
  138. package/web/src/components/settings/SettingsView.module.css +0 -550
  139. package/web/src/components/tools/ToolPicker.jsx +0 -86
  140. package/web/src/components/tools/ToolPicker.module.css +0 -97
  141. package/web/src/components/update/UpdateChip.jsx +0 -104
  142. package/web/src/components/update/UpdateChip.module.css +0 -146
  143. package/web/src/global.css +0 -298
  144. package/web/src/hooks/urlState.constants.js +0 -28
  145. package/web/src/hooks/useChangelog.js +0 -51
  146. package/web/src/hooks/useColumnSizing.js +0 -211
  147. package/web/src/hooks/useFavorites.js +0 -150
  148. package/web/src/hooks/useFilter.js +0 -310
  149. package/web/src/hooks/useInstalledModels.js +0 -42
  150. package/web/src/hooks/useRecommend.js +0 -67
  151. package/web/src/hooks/useRouterDashboard.js +0 -133
  152. package/web/src/hooks/useSSE.js +0 -73
  153. package/web/src/hooks/useSocket.js +0 -203
  154. package/web/src/hooks/useTheme.js +0 -83
  155. package/web/src/hooks/useTokenUsage.js +0 -103
  156. package/web/src/hooks/useToolMode.js +0 -77
  157. package/web/src/hooks/useUpdateChecker.js +0 -91
  158. package/web/src/hooks/useUrlState.js +0 -170
  159. package/web/src/main.jsx +0 -15
  160. package/web/src/utils/download.js +0 -15
  161. package/web/src/utils/format.js +0 -42
  162. package/web/src/utils/m3.js +0 -57
  163. package/web/src/utils/ranks.js +0 -37
  164. package/web/styles.legacy.css +0 -963
@@ -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
 
@@ -856,6 +860,11 @@ export function createOverlayRenderers(state, deps) {
856
860
  }
857
861
 
858
862
  const targetLine = cursorLineByRow[state.commandPaletteCursor] ?? 0
863
+ // 📖 Pad short result lists up to bodyRows so the panel height stays constant
864
+ // 📖 while typing: nothing repaints the cells below a shrunken panel (the
865
+ // 📖 table behind is frozen while the palette is open), so a shrinking panel
866
+ // 📖 would leave stale painted rows hanging under its bottom edge.
867
+ while (panelLines.length < bodyRows) panelLines.push('')
859
868
  state.commandPaletteScrollOffset = keepOverlayTargetVisible(
860
869
  state.commandPaletteScrollOffset,
861
870
  targetLine,
@@ -922,11 +931,16 @@ export function createOverlayRenderers(state, deps) {
922
931
  return sty.overlayBgCommandPalette(padded)
923
932
  })
924
933
 
925
- // 📖 Emit each row from column 1 and clear to end of line so stale table cells
926
- // 📖 in the margins beside the panel can never bleed through (the artifacts
927
- // 📖 reported in issue #169). \x1b[K never wraps: panel right edge <= cols.
934
+ // 📖 Emit each row directly at the panel's left column: no margin filler and
935
+ // 📖 no \x1b[K. The old form (col 1 + margin spaces + \x1b[K) painted the
936
+ // 📖 panel tint across the full screen width because the margin spaces and
937
+ // 📖 the erase-to-EOL both inherit the tint background still active from the
938
+ // 📖 previous row. On wide terminals that grew a giant dark rectangle over
939
+ // 📖 the frozen table on both sides of the panel, repainted every frame.
940
+ // 📖 Rows are width-budgeted and the panel is screen-clamped, so a row can
941
+ // 📖 never overflow past the terminal edge (the real issue #169 artifact).
928
942
  return tintedLines
929
- .map((line, idx) => `\x1b[${top + idx};1H${' '.repeat(left - 1)}${line}\x1b[K`)
943
+ .map((line, idx) => `\x1b[${top + idx};${left}H${line}`)
930
944
  .join('')
931
945
  }
932
946
 
@@ -1256,7 +1270,9 @@ export function createOverlayRenderers(state, deps) {
1256
1270
  function renderChangelog() {
1257
1271
  const EL = '\x1b[K'
1258
1272
  const lines = []
1259
- 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()
1260
1276
  const { versions } = changelogData
1261
1277
  const versionList = Object.keys(versions).sort((a, b) => {
1262
1278
  const aParts = a.split('.').map(Number)
@@ -1618,64 +1634,9 @@ export function createOverlayRenderers(state, deps) {
1618
1634
  return tintedLines.map((l) => l + EL).join('\n')
1619
1635
  }
1620
1636
 
1621
- // ─── Router Onboarding overlay renderer ─────────────────────────────────────
1622
- // 📖 renderRouterOnboarding: shown on first launch (no config.router) or
1623
- // 📖 first launch after upgrade (existing config but router.onboardingSeen !== true).
1624
- // 📖 Two options: Enable (Y) or Not now (N). Phase 6 — Smart Model Router.
1625
- function renderRouterOnboarding() {
1626
- const EL = '\x1b[K'
1627
- const lines = []
1628
- const cursorLineByRow = {}
1629
-
1630
- lines.push('')
1631
- lines.push(` ${themeColors.accent('🚀')} ${themeColors.accentBold('free-coding-models')} ${themeColors.dim(`v${LOCAL_VERSION}`)}`)
1632
- lines.push(` ${themeColors.textBold('🔀 Smart Router Available!')}`)
1633
- lines.push('')
1634
- lines.push(themeColors.dim(' FCM can run a background daemon that automatically'))
1635
- lines.push(themeColors.dim(' routes your requests to the fastest healthy model —'))
1636
- lines.push(themeColors.dim(' with zero manual intervention after initial setup.'))
1637
- lines.push('')
1638
-
1639
- const options = [
1640
- { label: 'Yes, enable the router', hint: 'Recommended — creates default set and starts daemon', key: 'Y' },
1641
- { label: 'Not now', hint: 'You can enable it later from the TUI', key: 'N' },
1642
- ]
1643
-
1644
- if (state.routerOnboardingPhase === 'loading') {
1645
- lines.push(themeColors.info(' Enabling router, please wait...'))
1646
- } else if (state.routerOnboardingPhase === 'success') {
1647
- lines.push(themeColors.success(' ✅ Router enabled! Dashboard opening...'))
1648
- lines.push(themeColors.dim(' Setup complete. Return to the main table to continue.'))
1649
- } else if (state.routerOnboardingPhase === 'error') {
1650
- lines.push(themeColors.error(` ❌ ${state.routerOnboardingError || 'Failed to enable router'}`))
1651
- lines.push(themeColors.dim(' Press Esc or Enter to continue to the main table'))
1652
- } else {
1653
- for (let i = 0; i < options.length; i++) {
1654
- const opt = options[i]
1655
- const isCursor = i === state.routerOnboardingCursor
1656
- const keyLabel = themeColors.hotkey(` ${opt.key}]`)
1657
- const row = `${bullet(isCursor)}${keyLabel} ${isCursor ? themeColors.textBold(opt.label) : themeColors.text(opt.label)}`
1658
- cursorLineByRow[i] = lines.length
1659
- lines.push(isCursor ? themeColors.bgCursorSettingsList(row) : row)
1660
- lines.push(themeColors.dim(` ${opt.hint}`))
1661
- lines.push('')
1662
- }
1663
- lines.push(themeColors.dim(' ↑↓ Navigate • Enter Select • Esc Skip for now'))
1664
- lines.push('')
1665
- lines.push(
1666
- themeColors.dim(' 💬 ') +
1667
- themeColors.footerDiscord('\x1b]8;;https://discord.gg/ZTNFHvvCkU\x1b\\Join the Discord community\x1b]8;;\x1b\\') +
1668
- themeColors.dim(' • Get help, share feedback, follow updates')
1669
- )
1670
- }
1671
-
1672
- const targetLine = cursorLineByRow[state.routerOnboardingCursor] ?? 0
1673
- state.routerOnboardingScrollOffset = keepOverlayTargetVisible(state.routerOnboardingScrollOffset, targetLine, lines.length, state.terminalRows)
1674
- const { visible, offset } = sliceOverlayLines(lines, state.routerOnboardingScrollOffset, state.terminalRows)
1675
- state.routerOnboardingScrollOffset = offset
1676
- const tintedLines = tintOverlayLines(visible, themeColors.overlayBgSettings, state.terminalCols)
1677
- return tintedLines.map((l) => l + EL).join('\n')
1678
- }
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).
1679
1640
 
1680
1641
  return {
1681
1642
  renderSettings,
@@ -1690,7 +1651,6 @@ export function createOverlayRenderers(state, deps) {
1690
1651
  renderPlayground,
1691
1652
  renderIncompatibleFallback,
1692
1653
  renderTokenUsage,
1693
- renderRouterOnboarding,
1694
1654
  startRecommendAnalysis,
1695
1655
  stopRecommendAnalysis,
1696
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
 
@@ -314,7 +320,13 @@ export function renderTable({
314
320
  const W_RANK = 6
315
321
  const W_TIER = 5
316
322
  const W_CTX = 4
317
- const W_SOURCE = 11
323
+ const W_SOURCE = 15
324
+ // 📖 Provider names are display-capped at MAX_PROVIDER_NAME_LEN chars so an
325
+ // 📖 over-long label can never push the following cells and desalign the
326
+ // 📖 whole row (Cloudflare AI at 13 chars overflowed the old 11-wide column).
327
+ // 📖 Anything longer is cut to 13 chars + '…' = 14 visible chars. Keep new
328
+ // 📖 provider names in sources.js at MAX_PROVIDER_NAME_LEN chars or less.
329
+ const MAX_PROVIDER_NAME_LEN = 14
318
330
  const W_MODEL = 26
319
331
  const W_SWE = 5
320
332
  const W_STATUS = 17
@@ -685,6 +697,22 @@ export function renderTable({
685
697
  }
686
698
  return out
687
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
+ }
688
716
  const paintSweScore = (score, paddedText) => {
689
717
  if (score >= 70) return chalk.bold.rgb(...getTierRgb('S+'))(paddedText)
690
718
  if (score >= 60) return chalk.bold.rgb(...getTierRgb('S'))(paddedText)
@@ -707,6 +735,9 @@ export function renderTable({
707
735
  _lastLayout.viewportEndIdx = vp.endIdx
708
736
  _lastLayout.hasAboveIndicator = vp.hasAbove
709
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
710
741
 
711
742
  for (let i = vp.startIdx; i < vp.endIdx; i++) {
712
743
  const r = sorted[i]
@@ -719,11 +750,15 @@ export function renderTable({
719
750
  const tier = tierFn(r.tier.padEnd(W_TIER))
720
751
  // 📖 Keep terminal view provider-specific so each row is monitorable per provider
721
752
  // 📖 In compact mode, truncate provider name to 4 chars + '…'
753
+ // 📖 In normal mode, cap names at MAX_PROVIDER_NAME_LEN chars (13 + '…')
754
+ // 📖 so an over-long label never shifts every cell to its right.
722
755
  const providerNameRaw = sources[r.providerKey]?.name ?? r.providerKey ?? 'NIM'
723
756
  const providerName = normalizeOriginLabel(providerNameRaw, r.providerKey)
724
757
  const providerDisplay = isCompact && providerName.length > 5
725
758
  ? providerName.slice(0, 4) + '…'
726
- : providerName
759
+ : providerName.length > MAX_PROVIDER_NAME_LEN
760
+ ? providerName.slice(0, MAX_PROVIDER_NAME_LEN - 1).trimEnd() + '…'
761
+ : providerName
727
762
  const source = themeColors.provider(r.providerKey, providerDisplay.padEnd(wSource))
728
763
  // 📖 Prefix: ⭐ favorite > 🎯 recommended > 🆕 new — only one emoji, never shifts the line
729
764
  const modelIsNew = isNewModel(r.addedDate)
@@ -737,7 +772,9 @@ export function renderTable({
737
772
  }
738
773
  const prefixDisplayWidth = displayWidth(favoritePrefix)
739
774
  const nameWidth = Math.max(0, wModel - prefixDisplayWidth)
740
- 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)
741
778
  const sweScore = r.sweScore ?? '—'
742
779
  // 📖 SWE% colorized on the same gradient as Tier:
743
780
  // ≥70% bright neon green (S+), ≥60% green (S), ≥50% yellow-green (A+),
@@ -1080,6 +1117,10 @@ export function renderTable({
1080
1117
  // 📖 truncate them. Plain text is built first, clipped to the terminal
1081
1118
  // 📖 width, then colorized (same pattern as the table header).
1082
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
1083
1124
  const maxDetailWidth = Math.max(0, (terminalCols || 80) - 4)
1084
1125
  const keyText = r.hasApiKey ? 'key configured' : 'no key'
1085
1126
  const endpoint = sources[r.providerKey]?.url ?? 'unknown endpoint'
@@ -1380,9 +1421,9 @@ export function renderTable({
1380
1421
  if (releaseLabel || speedTestLabel || globalBenchmarkLabel || probeLabel || probeCacheLabel || pausedProvidersLabel || enrichmentLabel || quotaLabel || actionErrorLabel) {
1381
1422
  const parts = [
1382
1423
  { text: ' ', key: null },
1383
- { text: speedTestLabel, key: 'a' },
1424
+ { text: speedTestLabel, key: 'ctrl+a' },
1384
1425
  { text: ' ', key: null },
1385
- { text: globalBenchmarkLabel, key: 'u' },
1426
+ { text: globalBenchmarkLabel, key: 'ctrl+u' },
1386
1427
  { text: probeLabel ? ' ' : '', key: null },
1387
1428
  { text: probeLabel, key: null },
1388
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