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
@@ -8,7 +8,8 @@
8
8
  * tool launch actions. It also keeps the live key bindings aligned with the
9
9
  * highlighted letters shown in the table headers.
10
10
  *
11
- * 📖 Key I opens the changelog overlay.
11
+ * 📖 Key I opens the Help overlay. The changelog lives in Settings
12
+ * 📖 ("View Changelog" row) and the command palette ("Changelog" entry).
12
13
  *
13
14
  * It also owns the "test key" model selection used by the Settings overlay,
14
15
  * delegated to the shared `provider-key-tester.js` module for consistency
@@ -20,7 +21,6 @@
20
21
  * @exports { buildProviderModelsUrl, parseProviderModelIds, listProviderTestModels, classifyProviderTestOutcome, buildProviderTestDetail, createKeyHandler }
21
22
  */
22
23
 
23
- import { loadChangelog } from '../core/changelog-loader.js'
24
24
  import { getToolMeta, isModelCompatibleWithTool, getCompatibleTools, findSimilarCompatibleModels } from '../core/tool-metadata.js'
25
25
  import { loadConfig, saveConfig, replaceConfigContents, getApiKey } from '../core/config.js'
26
26
  import { sources } from '../../sources.js'
@@ -31,7 +31,8 @@ import { cleanupLegacyProxyArtifacts } from '../core/legacy-proxy-cleanup.js'
31
31
  import { getLastLayout, COLUMN_SORT_MAP } from './render-table.js'
32
32
  import { cycleThemeSetting, detectActiveTheme } from './theme.js'
33
33
  import { syncShellEnv, ensureShellRcSource, removeShellEnv } from '../core/shell-env.js'
34
- import { buildCommandPaletteTree, flattenCommandTree, filterCommandPaletteEntries } from './command-palette.js'
34
+ import { buildCommandPaletteTree, flattenCommandTree, filterCommandPaletteEntries, clampPaletteCursor } from './command-palette.js'
35
+ import { runWithConcurrency, loadChangelogCached } from './render-helpers.js'
35
36
  import { WIDTH_WARNING_MIN_COLS, VERDICT_CYCLE, HEALTH_CYCLE } from '../core/constants.js'
36
37
  import { scanAllToolConfigs, softDeleteModel } from '../core/installed-models-manager.js'
37
38
  import { startExternalTool } from '../core/tool-launchers.js'
@@ -109,6 +110,32 @@ function spawnDaemonCommand(state, args) {
109
110
  })
110
111
  }
111
112
 
113
+ // 📖 persistTableViewSettings: single source of truth for persisting table-view
114
+ // 📖 preferences (sort column/direction, tier/origin filter). Keyboard and mouse
115
+ // 📖 handlers both call this so a mouse sort persists exactly like a keyboard
116
+ // 📖 sort: via settings.sortAsc + saveConfig. The old mouse-only copy wrote a
117
+ // 📖 settings.sortDirection field nothing reads and never called saveConfig, so
118
+ // 📖 mouse sort changes were silently lost on restart. ORIGIN_CYCLE is optional:
119
+ // 📖 the mouse handler never changes the origin filter (and doesn't receive the
120
+ // 📖 cycle), so it just leaves that field untouched.
121
+ function persistTableViewSettings(state, { TIER_CYCLE = null, ORIGIN_CYCLE = null, saveConfig }) {
122
+ if (!state.config) return
123
+ if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
124
+ if (TIER_CYCLE) state.config.settings.tierFilter = TIER_CYCLE[state.tierFilterMode]
125
+ if (ORIGIN_CYCLE) state.config.settings.originFilter = ORIGIN_CYCLE[state.originFilterMode] ?? null
126
+ state.config.settings.sortColumn = state.sortColumn
127
+ state.config.settings.sortAsc = state.sortDirection === 'asc'
128
+ saveConfig(state.config)
129
+ }
130
+
131
+ // 📖 Safe "scroll to end" sentinel for overlay End keys. The overlay renderers
132
+ // 📖 clamp the offset to the real content length on the next frame
133
+ // 📖 (clampOverlayOffset via sliceOverlayLines), so the value only needs to be
134
+ // 📖 far past any plausible content. This replaces the old
135
+ // 📖 Number.MAX_SAFE_INTEGER assignments that leaked into downstream scroll
136
+ // 📖 arithmetic (pageStep +/- offsets) before the next render clamped them.
137
+ const OVERLAY_SCROLL_END_SENTINEL = 100_000
138
+
112
139
  export function createKeyHandler(ctx) {
113
140
  const {
114
141
  state,
@@ -165,9 +192,7 @@ export function createKeyHandler(ctx) {
165
192
  noteUserActivity,
166
193
  intervalToPingMode,
167
194
  PING_MODE_CYCLE,
168
- themeRowIdx,
169
195
  setResults,
170
- readline,
171
196
  } = ctx
172
197
 
173
198
  let userSelected = null
@@ -177,6 +202,27 @@ export function createKeyHandler(ctx) {
177
202
  // 📖 the probe counters and skip their reset instead of clobbering live values.
178
203
  let probeRunSeq = 0
179
204
 
205
+ // 📖 Tracked error-message timers (P2 fix): the setTimeout handles that clear
206
+ // 📖 installedModelsErrorMsg / settingsErrorMsg used to be anonymous, so
207
+ // 📖 closing + reopening an overlay within 3s let an OLD timer wipe a FRESH
208
+ // 📖 error. Handles now live on state and are cleared whenever the overlay
209
+ // 📖 opens/closes or a new error is scheduled.
210
+ function clearErrorMsgTimer(field) {
211
+ const timerKey = `${field}ClearTimer`
212
+ if (state[timerKey]) {
213
+ clearTimeout(state[timerKey])
214
+ state[timerKey] = null
215
+ }
216
+ }
217
+
218
+ function scheduleErrorMsgClear(field, ms) {
219
+ clearErrorMsgTimer(field)
220
+ state[`${field}ClearTimer`] = setTimeout(() => {
221
+ state[field] = null
222
+ state[`${field}ClearTimer`] = null
223
+ }, ms)
224
+ }
225
+
180
226
  function resetToolInstallPrompt() {
181
227
  state.toolInstallPromptOpen = false
182
228
  state.toolInstallPromptCursor = 0
@@ -680,13 +726,9 @@ export function createKeyHandler(ctx) {
680
726
  }
681
727
 
682
728
  // 📖 Persist current table-view preferences so sort/filter state survives restarts.
729
+ // 📖 Delegates to the module-level helper shared with the mouse handler.
683
730
  function persistUiSettings() {
684
- if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
685
- state.config.settings.tierFilter = TIER_CYCLE[state.tierFilterMode]
686
- state.config.settings.originFilter = ORIGIN_CYCLE[state.originFilterMode] ?? null
687
- state.config.settings.sortColumn = state.sortColumn
688
- state.config.settings.sortAsc = state.sortDirection === 'asc'
689
- saveConfig(state.config)
731
+ persistTableViewSettings(state, { TIER_CYCLE, ORIGIN_CYCLE, saveConfig })
690
732
  }
691
733
 
692
734
  // 📖 Shared table refresh helper so command-palette and hotkeys keep identical behavior.
@@ -731,6 +773,8 @@ export function createKeyHandler(ctx) {
731
773
  state.settingsAddKeyMode = false
732
774
  state.settingsEditBuffer = ''
733
775
  state.settingsScrollOffset = 0
776
+ // 📖 Fresh overlay: drop any stale auto-clear timer from a previous visit.
777
+ clearErrorMsgTimer('settingsErrorMsg')
734
778
 
735
779
  // 📖 Auto-test all configured API keys in parallel on Settings open.
736
780
  // 📖 Each provider with a saved key fires a parallel auth probe batch immediately.
@@ -785,6 +829,8 @@ export function createKeyHandler(ctx) {
785
829
  state.installedModelsOpen = true
786
830
  state.installedModelsCursor = 0
787
831
  state.installedModelsScrollOffset = 0
832
+ // 📖 Clear any stale auto-clear timer so it cannot wipe the fresh status.
833
+ clearErrorMsgTimer('installedModelsErrorMsg')
788
834
  state.installedModelsErrorMsg = 'Scanning...'
789
835
 
790
836
  try {
@@ -955,23 +1001,9 @@ export function createKeyHandler(ctx) {
955
1001
  }
956
1002
  }
957
1003
 
958
- // 📖 runWithConcurrency: Execute tasks with limited parallelism (maxConcurrent simultaneous).
959
- function runWithConcurrency(tasks, maxConcurrent) {
960
- const results = new Array(tasks.length)
961
- let nextIndex = 0
962
- const workers = new Array(maxConcurrent).fill(null).map(async () => {
963
- while (true) {
964
- const index = nextIndex++
965
- if (index >= tasks.length) break
966
- try {
967
- results[index] = await tasks[index]()
968
- } catch (err) {
969
- results[index] = { error: err }
970
- }
971
- }
972
- })
973
- return Promise.all(workers).then(() => results)
974
- }
1004
+ // 📖 runWithConcurrency now lives in render-helpers.js (shared with app.js's
1005
+ // 📖 startup ping burst, which used to fire unbounded Promise.all). Imported
1006
+ // 📖 at the top of this file.
975
1007
 
976
1008
  // 📖 setActionNotice: Surface a non-fatal error/info message as a footer chip.
977
1009
  // 📖 Part of the issue #168 "kicked out" fix: palette commands and the 404 probe
@@ -1260,6 +1292,8 @@ export function createKeyHandler(ctx) {
1260
1292
  || state.routerDashboardOpen
1261
1293
  || state.playgroundOpen
1262
1294
  || state.recommendOpen
1295
+ || state.tokenUsageOpen
1296
+ || state.runtimeReportOpen
1263
1297
 
1264
1298
  || state.helpVisible
1265
1299
  || state.changelogOpen
@@ -1325,9 +1359,9 @@ export function createKeyHandler(ctx) {
1325
1359
  })
1326
1360
  }
1327
1361
 
1328
- if (state.commandPaletteCursor >= state.commandPaletteResults.length) {
1329
- state.commandPaletteCursor = Math.max(0, state.commandPaletteResults.length - 1)
1330
- }
1362
+ // 📖 Clamp against the RENDERED slice (COMMAND_PALETTE_MAX_RESULTS rows),
1363
+ // 📖 not the full list - otherwise the cursor could sit on an invisible row.
1364
+ state.commandPaletteCursor = clampPaletteCursor(state.commandPaletteCursor, state.commandPaletteResults.length)
1331
1365
  }
1332
1366
 
1333
1367
  function openCommandPalette() {
@@ -1530,8 +1564,6 @@ export function createKeyHandler(ctx) {
1530
1564
 
1531
1565
  // 📖 Command palette captures the keyboard while active.
1532
1566
  if (state.commandPaletteOpen) {
1533
- if (key.ctrl && key.name === 'c') { exit(0); return }
1534
-
1535
1567
  const pageStep = Math.max(1, (state.terminalRows || 1) - 10)
1536
1568
  const selected = state.commandPaletteResults[state.commandPaletteCursor]
1537
1569
 
@@ -1630,7 +1662,6 @@ export function createKeyHandler(ctx) {
1630
1662
  // 📖 Router Dashboard: ↑↓ navigate favorites list, Ctrl+↑↓ reorder,
1631
1663
  // 📖 I cycles health check speed, C clears log, Esc goes back.
1632
1664
  if (state.routerDashboardOpen) {
1633
- if (key.ctrl && key.name === 'c') { exit(0); return }
1634
1665
  const favorites = Array.isArray(state.config?.favorites) ? state.config.favorites : []
1635
1666
  // 📖 maxCursor accounts for the favorites list + 2 buttons (Start/Stop Daemon and Install Endpoint)
1636
1667
  const maxCursor = Math.max(0, favorites.length + 1)
@@ -1753,8 +1784,6 @@ export function createKeyHandler(ctx) {
1753
1784
 
1754
1785
  // 📖 Install Endpoints overlay: provider → tool → connection → scope → optional model subset.
1755
1786
  if (state.installEndpointsOpen) {
1756
- if (key.ctrl && key.name === 'c') { exit(0); return }
1757
-
1758
1787
  const providerChoices = getConfiguredInstallableProviders(state.config)
1759
1788
  const toolChoices = getInstallTargetModes().filter(t => !(state.installEndpointsProviderKey === 'fcm_router' && t === 'fcm_router'))
1760
1789
  const modelChoices = state.installEndpointsProviderKey
@@ -1961,8 +1990,6 @@ export function createKeyHandler(ctx) {
1961
1990
  }
1962
1991
 
1963
1992
  if (state.toolInstallPromptOpen) {
1964
- if (key.ctrl && key.name === 'c') { exit(0); return }
1965
-
1966
1993
  const installPlan = state.toolInstallPromptPlan || getToolInstallPlan(state.toolInstallPromptMode)
1967
1994
  const installSupported = Boolean(installPlan?.supported)
1968
1995
 
@@ -2000,8 +2027,6 @@ export function createKeyHandler(ctx) {
2000
2027
 
2001
2028
  // ─── Installed Models overlay keyboard handling ───────────────────────────
2002
2029
  if (state.installedModelsOpen) {
2003
- if (key.ctrl && key.name === 'c') { exit(0); return }
2004
-
2005
2030
  const scanResults = state.installedModelsData || []
2006
2031
  let maxIndex = 0
2007
2032
  for (const toolResult of scanResults) {
@@ -2040,6 +2065,7 @@ export function createKeyHandler(ctx) {
2040
2065
  if (key.name === 'escape') {
2041
2066
  state.installedModelsOpen = false
2042
2067
  state.installedModelsCursor = 0
2068
+ clearErrorMsgTimer('installedModelsErrorMsg')
2043
2069
  return
2044
2070
  }
2045
2071
 
@@ -2079,12 +2105,12 @@ export function createKeyHandler(ctx) {
2079
2105
  openInstalledModelsOverlay()
2080
2106
  } else {
2081
2107
  state.installedModelsErrorMsg = `Failed to disable: ${result.error}`
2082
- setTimeout(() => { state.installedModelsErrorMsg = null }, 3000)
2108
+ scheduleErrorMsgClear('installedModelsErrorMsg', 3000)
2083
2109
  }
2084
2110
  })
2085
2111
  .catch((err) => {
2086
2112
  state.installedModelsErrorMsg = `Failed to disable: ${err.message}`
2087
- setTimeout(() => { state.installedModelsErrorMsg = null }, 3000)
2113
+ scheduleErrorMsgClear('installedModelsErrorMsg', 3000)
2088
2114
  })
2089
2115
  return
2090
2116
  }
@@ -2099,8 +2125,6 @@ export function createKeyHandler(ctx) {
2099
2125
  // 📖 Incompatible fallback overlay: ↑↓ navigate across tool + model sections, Enter confirms, Esc cancels.
2100
2126
  // 📖 Cursor is a flat index: 0..N-1 = compatible tools, N..N+M-1 = similar models.
2101
2127
  if (state.incompatibleFallbackOpen) {
2102
- if (key.ctrl && key.name === 'c') { exit(0); return }
2103
-
2104
2128
  const tools = state.incompatibleFallbackTools || []
2105
2129
  const similarModels = state.incompatibleFallbackSimilarModels || []
2106
2130
  const totalItems = tools.length + similarModels.length
@@ -2189,14 +2213,12 @@ export function createKeyHandler(ctx) {
2189
2213
  if (key.name === 'pageup') { state.helpScrollOffset = Math.max(0, state.helpScrollOffset - pageStep); return }
2190
2214
  if (key.name === 'pagedown') { state.helpScrollOffset += pageStep; return }
2191
2215
  if (key.name === 'home') { state.helpScrollOffset = 0; return }
2192
- if (key.name === 'end') { state.helpScrollOffset = Number.MAX_SAFE_INTEGER; return }
2193
- if (key.ctrl && key.name === 'c') { exit(0); return }
2216
+ if (key.name === 'end') { state.helpScrollOffset = OVERLAY_SCROLL_END_SENTINEL; return }
2194
2217
  return
2195
2218
  }
2196
2219
 
2197
2220
  // 📖 Token Usage overlay: Shift+T shows token history chart and today/all-time breakdowns.
2198
2221
  if (state.tokenUsageOpen) {
2199
- if (key.ctrl && key.name === 'c') { exit(0); return }
2200
2222
  const pageStep = Math.max(1, (state.terminalRows || 1) - 4)
2201
2223
  if (key.name === 'escape') {
2202
2224
  closeTokenUsageOverlay()
@@ -2223,7 +2245,7 @@ export function createKeyHandler(ctx) {
2223
2245
  return
2224
2246
  }
2225
2247
  if (key.name === 'end') {
2226
- state.tokenUsageScrollOffset = Number.MAX_SAFE_INTEGER
2248
+ state.tokenUsageScrollOffset = OVERLAY_SCROLL_END_SENTINEL
2227
2249
  return
2228
2250
  }
2229
2251
  return
@@ -2232,7 +2254,6 @@ export function createKeyHandler(ctx) {
2232
2254
  // 📖 Runtime Report overlay (t3): Shift+W. Per-model real-world breakdown +
2233
2255
  // 📖 recent calls. Scrolling with up/down/pageup/pagedown/escape.
2234
2256
  if (state.runtimeReportOpen) {
2235
- if (key.ctrl && key.name === 'c') { exit(0); return }
2236
2257
  const pageStep = Math.max(1, (state.terminalRows || 1) - 4)
2237
2258
  if (key.name === 'escape') {
2238
2259
  closeRuntimeReportOverlay()
@@ -2259,114 +2280,22 @@ export function createKeyHandler(ctx) {
2259
2280
  return
2260
2281
  }
2261
2282
  if (key.name === 'end') {
2262
- state.runtimeReportScrollOffset = Number.MAX_SAFE_INTEGER
2283
+ state.runtimeReportScrollOffset = OVERLAY_SCROLL_END_SENTINEL
2263
2284
  return
2264
2285
  }
2265
2286
  return
2266
2287
  }
2267
2288
 
2268
- // 📖 Router Onboarding overlay: shown on first launch. Y=yes enable, N=not now, Esc=cancel.
2269
- if (state.routerOnboardingOpen) {
2270
- if (key.ctrl && key.name === 'c') { exit(0); return }
2271
- if (state.routerOnboardingPhase === 'loading' || state.routerOnboardingPhase === 'success' || state.routerOnboardingPhase === 'error') {
2272
- if (key.name === 'escape' || key.name === 'return') {
2273
- state.routerOnboardingOpen = false
2274
- // 📖 Mark onboarding as seen (don't show again)
2275
- if (state.config?.router) {
2276
- state.config.router.onboardingSeen = true
2277
- }
2278
- return
2279
- }
2280
- return
2281
- }
2282
- if (key.name === 'escape' || key.name === 'n') {
2283
- state.routerOnboardingOpen = false
2284
- // 📖 Mark as seen and disabled
2285
- if (state.config?.router) {
2286
- state.config.router.onboardingSeen = true
2287
- state.config.router.enabled = false
2288
- }
2289
- return
2290
- }
2291
- if (key.name === 'up' || key.name === 'k') {
2292
- state.routerOnboardingCursor = 0
2293
- return
2294
- }
2295
- if (key.name === 'down' || key.name === 'j') {
2296
- state.routerOnboardingCursor = 1
2297
- return
2298
- }
2299
- if (key.name === 'return' || key.name === 'y') {
2300
- const shouldEnable = key.name === 'return' ? true : (state.routerOnboardingCursor === 0)
2301
- if (!shouldEnable) {
2302
- state.routerOnboardingOpen = false
2303
- if (state.config?.router) {
2304
- state.config.router.onboardingSeen = true
2305
- state.config.router.enabled = false
2306
- }
2307
- return
2308
- }
2309
- // 📖 Enable router: start daemon in background and mark onboarding seen
2310
- state.routerOnboardingPhase = 'loading'
2311
- state.routerOnboardingError = null
2312
- void (async () => {
2313
- try {
2314
- const binPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'free-coding-models.js')
2315
- const child = spawn('node', [binPath, '--daemon-bg'], {
2316
- detached: true,
2317
- stdio: ['ignore', 'pipe', 'pipe'],
2318
- env: { ...process.env, ...(isPackageDevMode() && !process.env.FCM_DEV ? { FCM_DEV: '1' } : {}) },
2319
- })
2320
- child.unref()
2321
-
2322
- // 📖 Capture daemon startup output so we can show the real error reason
2323
- let onboardingStdout = ''
2324
- let onboardingStderr = ''
2325
- child.stdout?.on('data', (chunk) => { onboardingStdout += chunk })
2326
- child.stderr?.on('data', (chunk) => { onboardingStderr += chunk })
2327
- child.on('close', (code) => {
2328
- if (code === 0) return
2329
- let detail = null
2330
- try {
2331
- const parsed = JSON.parse(onboardingStdout.trim())
2332
- if (parsed?.error) detail = parsed.error
2333
- } catch {}
2334
- if (!detail && onboardingStderr.trim()) {
2335
- const lines = onboardingStderr.trim().split('\n').filter(l => !l.startsWith('node:'))
2336
- detail = lines.slice(0, 2).join(' — ') || onboardingStderr.trim().slice(0, 120)
2337
- }
2338
- if (state.routerOnboardingPhase === 'loading') {
2339
- state.routerOnboardingPhase = 'error'
2340
- state.routerOnboardingError = detail || `Daemon exited with code ${code}`
2341
- }
2342
- })
2343
- await new Promise((r) => setTimeout(r, 2000))
2344
- if (state.routerOnboardingPhase === 'loading') {
2345
- state.routerOnboardingPhase = 'success'
2346
- if (state.config?.router) {
2347
- state.config.router.enabled = true
2348
- state.config.router.onboardingSeen = true
2349
- saveConfig(state.config)
2350
- }
2351
- trackTelemetryEvent('app_router_install', { router_version: '0.4.0' })
2352
- await new Promise((r) => setTimeout(r, 1500))
2353
- state.routerOnboardingOpen = false
2354
- openRouterDashboardOverlay(state)
2355
- }
2356
- } catch (err) {
2357
- state.routerOnboardingPhase = 'error'
2358
- state.routerOnboardingError = err?.message || 'Failed to start router'
2359
- }
2360
- })()
2361
- return
2362
- }
2363
- return
2364
- }
2289
+ // 📖 Router Onboarding overlay removed (dead code): nothing ever set
2290
+ // 📖 state.routerOnboardingOpen = true (the TUI now auto-enables the router
2291
+ // 📖 silently at startup, see app.js), so this block and its renderer were
2292
+ // 📖 unreachable. The config.router.onboardingSeen writers below are gone
2293
+ // 📖 with it; app.js still flips the flags on boot.
2365
2294
 
2366
2295
  // 📖 Changelog overlay: two-phase (index + details) with keyboard navigation
2367
2296
  if (state.changelogOpen) {
2368
2297
  const pageStep = Math.max(1, (state.terminalRows || 1) - 2)
2369
- const changelogData = loadChangelog()
2298
+ const changelogData = loadChangelogCached()
2370
2299
  const { versions } = changelogData
2371
2300
  const versionList = Object.keys(versions).sort((a, b) => {
2372
2301
  const aParts = a.split('.').map(Number)
@@ -2476,14 +2405,11 @@ export function createKeyHandler(ctx) {
2476
2405
  if (key.name === 'end') { state.changelogScrollOffset = maxScrollOffset; return }
2477
2406
  }
2478
2407
 
2479
- if (key.ctrl && key.name === 'c') { exit(0); return }
2480
2408
  return
2481
2409
  }
2482
2410
 
2483
2411
  // 📖 Smart Recommend overlay: full keyboard handling while overlay is open.
2484
2412
  if (state.recommendOpen) {
2485
- if (key.ctrl && key.name === 'c') { exit(0); return }
2486
-
2487
2413
  if (state.recommendPhase === 'questionnaire') {
2488
2414
  const questions = [
2489
2415
  { options: Object.keys(TASK_TYPES), answerKey: 'taskType' },
@@ -2615,7 +2541,7 @@ export function createKeyHandler(ctx) {
2615
2541
  state.settingsAddKeyMode = false
2616
2542
  state.settingsEditBuffer = ''
2617
2543
  state.settingsErrorMsg = '⚠️ OpenRouter keys must start with "sk-or-". Key not saved.'
2618
- setTimeout(() => { state.settingsErrorMsg = null }, 3000)
2544
+ scheduleErrorMsgClear('settingsErrorMsg', 3000)
2619
2545
  return
2620
2546
  }
2621
2547
  if (!state.config.apiKeys || typeof state.config.apiKeys !== 'object' || Array.isArray(state.config.apiKeys)) {
@@ -2634,7 +2560,7 @@ export function createKeyHandler(ctx) {
2634
2560
  const saveResult = persistApiKeysForProvider(state.config, pk)
2635
2561
  if (!saveResult.success) {
2636
2562
  state.settingsErrorMsg = `⚠️ Failed to persist ${pk} API key: ${saveResult.error || 'Unknown error'}`
2637
- setTimeout(() => { state.settingsErrorMsg = null }, 4000)
2563
+ scheduleErrorMsgClear('settingsErrorMsg', 4000)
2638
2564
  } else {
2639
2565
  trackAppAction('api_key_saved', {
2640
2566
  provider_key: pk,
@@ -2667,6 +2593,7 @@ export function createKeyHandler(ctx) {
2667
2593
  state.settingsAddKeyMode = false
2668
2594
  state.settingsEditBuffer = ''
2669
2595
  state.settingsSyncStatus = null // 📖 Clear sync status on close
2596
+ clearErrorMsgTimer('settingsErrorMsg') // 📖 Pending auto-clear must not fire into a closed overlay
2670
2597
  // 📖 Rebuild results: add models from newly enabled providers, remove disabled
2671
2598
  const nextResults = MODELS
2672
2599
  .filter(([,,,,,pk]) => isProviderEnabled(state.config, pk))
@@ -2868,8 +2795,6 @@ export function createKeyHandler(ctx) {
2868
2795
 
2869
2796
  // 📖 Profile system removed - API keys now persist permanently across all sessions
2870
2797
 
2871
- if (key.ctrl && key.name === 'c') { exit(0); return }
2872
-
2873
2798
  // 📖 + key: open add-key input (empty buffer) — appends new key on Enter
2874
2799
  if ((str === '+' || key.name === '+') && state.settingsCursor < providerKeys.length) {
2875
2800
  state.settingsEditBuffer = '' // 📖 Start with empty buffer (not existing key)
@@ -3014,7 +2939,7 @@ export function createKeyHandler(ctx) {
3014
2939
  return
3015
2940
  }
3016
2941
 
3017
- // 📖 Sorting keys: R=rank, O=origin, M=model, L=latest ping, A=avg ping, S=SWE-bench, C=context, H=health, V=verdict, B=stability, U=uptime, G=usage
2942
+ // 📖 Sorting keys: R=rank, O=origin, M=model, L=latest ping, A=avg ping, S=SWE-bench, C=context, H=health, V=verdict, B=stability, U=uptime (G cycles theme, not a sort key)
3018
2943
  // 📖 T is reserved for tier filter cycling. Y toggles favorites display mode.
3019
2944
  // 📖 X clears the active custom text filter.
3020
2945
  // 📖 D is now reserved for provider filter cycling
@@ -3278,6 +3203,17 @@ export function createMouseEventHandler(ctx) {
3278
3203
  cycleToolMode,
3279
3204
  } = ctx
3280
3205
 
3206
+ // 📖 Shared helper: recompute the visible+sorted list from current filters/sort.
3207
+ // 📖 Hoisted (P3 dedup): this exact block was copy-pasted in
3208
+ // 📖 setSortColumnFromClick, toggleFavoriteAtRow and the Tier header click.
3209
+ function refreshVisibleSorted() {
3210
+ const visible = state.results.filter(r => !r.hidden)
3211
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection, {
3212
+ pinFavorites: state.favoritesPinnedAndSticky,
3213
+ benchmarkResults: state.benchmarkResults,
3214
+ })
3215
+ }
3216
+
3281
3217
  // 📖 Shared helper: set the sort column, toggling direction if same column clicked twice.
3282
3218
  function setSortColumnFromClick(col) {
3283
3219
  if (state.sortColumn === col) {
@@ -3290,20 +3226,15 @@ export function createMouseEventHandler(ctx) {
3290
3226
  state.headerFlashColumn = col
3291
3227
  state.headerFlashUntilFrame = state.frame + 3
3292
3228
  // 📖 Recompute visible sorted list to reflect new sort order
3293
- const visible = state.results.filter(r => !r.hidden)
3294
- state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection, {
3295
- pinFavorites: state.favoritesPinnedAndSticky,
3296
- benchmarkResults: state.benchmarkResults,
3297
- })
3229
+ refreshVisibleSorted()
3298
3230
  }
3299
3231
 
3300
- // 📖 Shared helper: persist UI settings after mouse-triggered changes
3232
+ // 📖 Shared helper: persist UI settings after mouse-triggered changes.
3233
+ // 📖 Delegates to the module-level helper shared with the keyboard path so a
3234
+ // 📖 mouse sort persists via sortAsc + saveConfig (the old copy wrote a
3235
+ // 📖 sortDirection field nothing reads and never saved).
3301
3236
  function persistUiSettings() {
3302
- if (!state.config) return
3303
- if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
3304
- state.config.settings.sortColumn = state.sortColumn
3305
- state.config.settings.sortDirection = state.sortDirection
3306
- state.config.settings.tierFilter = TIER_CYCLE[state.tierFilterMode] || null
3237
+ persistTableViewSettings(state, { TIER_CYCLE, saveConfig })
3307
3238
  }
3308
3239
 
3309
3240
  // 📖 Shared helper: toggle favorite on a specific model row index.
@@ -3315,11 +3246,7 @@ export function createMouseEventHandler(ctx) {
3315
3246
  toggleFavoriteModel(state.config, selected.providerKey, selected.modelId)
3316
3247
  syncFavoriteFlags(state.results, state.config)
3317
3248
  applyTierFilter()
3318
- const visible = state.results.filter(r => !r.hidden)
3319
- state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection, {
3320
- pinFavorites: state.favoritesPinnedAndSticky,
3321
- benchmarkResults: state.benchmarkResults,
3322
- })
3249
+ refreshVisibleSorted()
3323
3250
  // 📖 If we unfavorited while pinned mode is on, reset cursor to top
3324
3251
  if (wasFavorite && state.favoritesPinnedAndSticky) {
3325
3252
  state.cursor = 0
@@ -3633,11 +3560,7 @@ export function createMouseEventHandler(ctx) {
3633
3560
  state.headerFlashUntilFrame = state.frame + 3
3634
3561
  state.tierFilterMode = (state.tierFilterMode + 1) % TIER_CYCLE.length
3635
3562
  applyTierFilter()
3636
- const visible = state.results.filter(r => !r.hidden)
3637
- state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection, {
3638
- pinFavorites: state.favoritesPinnedAndSticky,
3639
- benchmarkResults: state.benchmarkResults,
3640
- })
3563
+ refreshVisibleSorted()
3641
3564
  state.cursor = 0
3642
3565
  state.scrollOffset = 0
3643
3566
  persistUiSettings()
@@ -3649,7 +3572,20 @@ export function createMouseEventHandler(ctx) {
3649
3572
  // 📖 Check if click is on a model row → move cursor (or select on double-click)
3650
3573
  // 📖 Right-click toggles favorite on that row (same as F key)
3651
3574
  if (y >= layout.firstModelRow && y <= layout.lastModelRow) {
3652
- const rowOffset = y - layout.firstModelRow
3575
+ // 📖 Space-expanded detail card (fix 15): the 2 extra lines under the
3576
+ // 📖 expanded row shift every row below it down, so a raw y - firstModelRow
3577
+ // 📖 offset pointed 2 models too far. Shift offsets below the card back up;
3578
+ // 📖 a click ON the card lines selects the expanded row itself.
3579
+ let rowOffset = y - layout.firstModelRow
3580
+ const expandedDetailRows = layout.expandedDetailRows || 0
3581
+ if (expandedDetailRows > 0) {
3582
+ const expandedRel = (layout.expandedDetailAfterIdx ?? -1) - layout.viewportStartIdx
3583
+ if (rowOffset > expandedRel && rowOffset <= expandedRel + expandedDetailRows) {
3584
+ rowOffset = expandedRel
3585
+ } else if (rowOffset > expandedRel) {
3586
+ rowOffset -= expandedDetailRows
3587
+ }
3588
+ }
3653
3589
  const modelIdx = layout.viewportStartIdx + rowOffset
3654
3590
  if (modelIdx >= layout.viewportStartIdx && modelIdx < layout.viewportEndIdx) {
3655
3591
  state.cursor = modelIdx
@@ -3678,9 +3614,17 @@ export function createMouseEventHandler(ctx) {
3678
3614
  return
3679
3615
  }
3680
3616
  // 📖 Map the footer zone key to a synthetic keypress.
3681
- // 📖 Most are single-character keys; special cases like ctrl+p need special handling.
3617
+ // 📖 Most are single-character keys; ctrl-modified zones emit ctrl keys so
3618
+ // 📖 they hit the real shortcut handlers (plain 'a'/'u' are SORT keys and
3619
+ // 📖 the old zones silently re-sorted the table instead of benchmarking).
3682
3620
  if (zone.key === 'ctrl+p') {
3683
3621
  process.stdin.emit('keypress', '\x10', { name: 'p', ctrl: true, meta: false, shift: false })
3622
+ } else if (zone.key === 'ctrl+a') {
3623
+ // 📖 "AI Speed Test" badge → Ctrl+A (benchmark selected model)
3624
+ process.stdin.emit('keypress', '\x01', { name: 'a', ctrl: true, meta: false, shift: false })
3625
+ } else if (zone.key === 'ctrl+u') {
3626
+ // 📖 "Global AI Speed Test" badge → Ctrl+U (benchmark all models)
3627
+ process.stdin.emit('keypress', '\x15', { name: 'u', ctrl: true, meta: false, shift: false })
3684
3628
  } else if (zone.key === 'shift+r') {
3685
3629
  process.stdin.emit('keypress', 'R', { name: 'r', ctrl: false, meta: false, shift: true })
3686
3630
  } else {