free-coding-models 0.2.12 → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ---
4
4
 
5
+ ## 0.2.14
6
+
7
+ ### Fixed
8
+ - **Changelog scrolling viewport**: Scrolling in changelog details (N key) now respects viewport boundaries — content no longer scrolls beyond visible area. Down/PageDown/End keys now properly clamp to max scroll offset so you can view all content without it disappearing off screen.
9
+
10
+ ---
11
+
12
+ ## 0.2.13
13
+
14
+ ### Added
15
+ - **Persist UI view settings**: Tier filter (T key), provider filter (D key), and sort order now persist across session restarts — settings are saved to `~/.free-coding-models.json` under `config.settings` and automatically restored on next startup. Settings also mirror into active profiles so profile switching captures live view preferences.
16
+ - When T cycles tier: S+ tier is now remembered for next session
17
+ - When D cycles provider: Filtered provider is now remembered
18
+ - When sort keys (R/O/M/L/A/S/C/H/V/B/U) change order: Sort column and direction are now remembered
19
+ - Profile loading has priority over global `config.settings` so saved profiles override global defaults
20
+ - **Reset view settings (Shift+R)**: New keyboard shortcut to instantly reset tier filter, provider filter, and sort order to defaults (All tier, no provider filter, avg sort ascending). Also clears persisted settings from `config.settings` so next restart returns to factory defaults.
21
+ - Useful when you've customized your view but want a fresh start
22
+ - Does not affect favorites, API keys, or other settings — only view state
23
+
24
+ ### Changed
25
+ - **Help overlay (K key)**: Updated to document new Shift+R keybinding for resetting view settings
26
+
27
+ ---
28
+
5
29
  ## 0.2.12
6
30
 
7
31
  ### Added
package/README.md CHANGED
@@ -80,6 +80,7 @@ By Vanessa Depraute
80
80
  - **📐 Stability score** — Composite 0–100 score measuring consistency (p95, jitter, spikes, uptime)
81
81
  - **📊 Usage tracking** — Monitor remaining quota for each exact provider/model pair when the provider exposes it; otherwise the TUI shows a green dot instead of a misleading percentage.
82
82
  - **📜 Request Log Overlay** — Press `X` to inspect recent proxied requests and token usage for exact provider/model pairs.
83
+ - **📋 Changelog Overlay** — Press `N` to browse all versions in an index, then `Enter` to view details for any version with full scroll support
83
84
  - **🛠 MODEL_NOT_FOUND Rotation** — If a specific provider returns a 404 for a model, the TUI intelligently rotates through other available providers for the same model.
84
85
  - **🔄 Auto-retry** — Timeout models keep getting retried, nothing is ever "given up on"
85
86
  - **🎮 Interactive selection** — Navigate with arrow keys directly in the table, press Enter to act
@@ -931,6 +932,7 @@ This script:
931
932
  - **Shift+P** — Cycle through saved profiles (switches live TUI settings)
932
933
  - **Shift+S** — Save current TUI settings as a named profile (inline prompt)
933
934
  - **Q** — Open Smart Recommend overlay (find the best model for your task)
935
+ - **N** — Open Changelog overlay (browse index of all versions, `Enter` to view details, `B` to go back)
934
936
  - **W** — Cycle ping mode (`FAST` 2s → `NORMAL` 10s → `SLOW` 30s → `FORCED` 4s)
935
937
  - **J / I** — Request feature / Report bug
936
938
  - **K / Esc** — Show help overlay / Close overlay
@@ -362,8 +362,8 @@ async function main() {
362
362
  frame: 0,
363
363
  cursor: 0,
364
364
  selectedModel: null,
365
- sortColumn: startupProfileSettings?.sortColumn || 'avg',
366
- sortDirection: startupProfileSettings?.sortAsc === false ? 'desc' : 'asc',
365
+ sortColumn: startupProfileSettings?.sortColumn ?? config.settings?.sortColumn ?? 'avg',
366
+ sortDirection: (startupProfileSettings?.sortAsc ?? config.settings?.sortAsc ?? true) ? 'asc' : 'desc',
367
367
  pingInterval: PING_MODE_INTERVALS.speed, // 📖 Effective live interval derived from the active ping mode.
368
368
  pingMode: 'speed', // 📖 Current ping mode: speed | normal | slow | forced.
369
369
  pingModeSource: 'startup', // 📖 Why this mode is active: startup | manual | auto | idle | activity.
@@ -444,6 +444,12 @@ async function main() {
444
444
  logVisible: false, // 📖 Whether the log page overlay is active
445
445
  logScrollOffset: 0, // 📖 Vertical scroll offset for log overlay viewport
446
446
  logShowAll: false, // 📖 Show all logs (true) or limited to 500 (false)
447
+ // 📖 Changelog overlay state (N key opens it)
448
+ changelogOpen: false, // 📖 Whether the changelog overlay is active
449
+ changelogScrollOffset: 0, // 📖 Vertical scroll offset for changelog overlay viewport
450
+ changelogPhase: 'index', // 📖 'index' (all versions) | 'details' (specific version)
451
+ changelogCursor: 0, // 📖 Selected row in index phase
452
+ changelogSelectedVersion: null, // 📖 Which version to show details for
447
453
  // 📖 Proxy startup status — set by autoStartProxyIfSynced, consumed by Task 3 indicator
448
454
  // 📖 null = not configured/not attempted
449
455
  // 📖 { phase: 'starting' } — proxy start in progress
@@ -606,8 +612,10 @@ async function main() {
606
612
 
607
613
  // 📖 originFilterMode: index into ORIGIN_CYCLE, 0=All, then each provider key in order
608
614
  const ORIGIN_CYCLE = [null, ...Object.keys(sources)]
609
- state.tierFilterMode = startupProfileSettings?.tierFilter ? Math.max(0, TIER_CYCLE.indexOf(startupProfileSettings.tierFilter)) : 0
610
- state.originFilterMode = 0
615
+ const resolvedTierFilter = startupProfileSettings?.tierFilter ?? config.settings?.tierFilter
616
+ state.tierFilterMode = resolvedTierFilter ? Math.max(0, TIER_CYCLE.indexOf(resolvedTierFilter)) : 0
617
+ const resolvedOriginFilter = config.settings?.originFilter
618
+ state.originFilterMode = resolvedOriginFilter ? Math.max(0, ORIGIN_CYCLE.indexOf(resolvedOriginFilter)) : 0
611
619
 
612
620
  function applyTierFilter() {
613
621
  const activeTier = TIER_CYCLE[state.tierFilterMode]
@@ -771,12 +779,12 @@ async function main() {
771
779
  process.stdin.on('keypress', onKeyPress)
772
780
  process.on('SIGCONT', noteUserActivity)
773
781
 
774
- // 📖 Animation loop: render settings overlay, recommend overlay, help overlay, feature request overlay, bug report overlay, OR main table
782
+ // 📖 Animation loop: render settings overlay, recommend overlay, help overlay, feature request overlay, bug report overlay, changelog overlay, OR main table
775
783
  ticker = setInterval(() => {
776
784
  refreshAutoPingMode()
777
785
  state.frame++
778
786
  // 📖 Cache visible+sorted models each frame so Enter handler always matches the display
779
- if (!state.settingsOpen && !state.installEndpointsOpen && !state.recommendOpen && !state.featureRequestOpen && !state.bugReportOpen) {
787
+ if (!state.settingsOpen && !state.installEndpointsOpen && !state.recommendOpen && !state.featureRequestOpen && !state.bugReportOpen && !state.changelogOpen) {
780
788
  const visible = state.results.filter(r => !r.hidden)
781
789
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
782
790
  }
@@ -794,6 +802,8 @@ async function main() {
794
802
  ? overlays.renderHelp()
795
803
  : state.logVisible
796
804
  ? overlays.renderLog()
805
+ : state.changelogOpen
806
+ ? overlays.renderChangelog()
797
807
  : renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.terminalCols, state.originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels, state.widthWarningStartedAt, state.widthWarningDismissed, state.settingsUpdateState, state.settingsUpdateLatestVersion, getProxySettings(state.config).enabled === true, state.isOutdated, state.latestVersion)
798
808
  process.stdout.write(ALT_HOME + content)
799
809
  }, Math.round(1000 / FPS))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "description": "Find the fastest coding LLM models in seconds — ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
5
5
  "keywords": [
6
6
  "nvidia",
@@ -25,6 +25,8 @@
25
25
  * @exports { buildProviderModelsUrl, parseProviderModelIds, listProviderTestModels, classifyProviderTestOutcome, buildProviderTestDetail, createKeyHandler }
26
26
  */
27
27
 
28
+ import { loadChangelog } from './changelog-loader.js'
29
+
28
30
  // 📖 Some providers need an explicit probe model because the first catalog entry
29
31
  // 📖 is not guaranteed to be accepted by their chat endpoint.
30
32
  const PROVIDER_TEST_MODEL_OVERRIDES = {
@@ -766,6 +768,102 @@ export function createKeyHandler(ctx) {
766
768
  return
767
769
  }
768
770
 
771
+ // 📖 Changelog overlay: two-phase (index + details) with keyboard navigation
772
+ if (state.changelogOpen) {
773
+ const pageStep = Math.max(1, (state.terminalRows || 1) - 2)
774
+ const changelogData = loadChangelog()
775
+ const { versions } = changelogData
776
+ const versionList = Object.keys(versions).sort((a, b) => {
777
+ const aParts = a.split('.').map(Number)
778
+ const bParts = b.split('.').map(Number)
779
+ for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
780
+ const aVal = aParts[i] || 0
781
+ const bVal = bParts[i] || 0
782
+ if (bVal !== aVal) return bVal - aVal
783
+ }
784
+ return 0
785
+ })
786
+
787
+ // 📖 Close changelog overlay
788
+ if (key.name === 'escape' || key.name === 'n') {
789
+ state.changelogOpen = false
790
+ state.changelogPhase = 'index'
791
+ state.changelogCursor = 0
792
+ state.changelogSelectedVersion = null
793
+ return
794
+ }
795
+
796
+ if (state.changelogPhase === 'index') {
797
+ // 📖 INDEX PHASE: Navigate through versions
798
+ if (key.name === 'up') {
799
+ state.changelogCursor = Math.max(0, state.changelogCursor - 1)
800
+ return
801
+ }
802
+ if (key.name === 'down') {
803
+ state.changelogCursor = Math.min(versionList.length - 1, state.changelogCursor + 1)
804
+ return
805
+ }
806
+ if (key.name === 'home') { state.changelogCursor = 0; return }
807
+ if (key.name === 'end') { state.changelogCursor = versionList.length - 1; return }
808
+ if (key.name === 'return') {
809
+ // 📖 Enter details phase for selected version
810
+ state.changelogPhase = 'details'
811
+ state.changelogSelectedVersion = versionList[state.changelogCursor]
812
+ state.changelogScrollOffset = 0
813
+ return
814
+ }
815
+ } else if (state.changelogPhase === 'details') {
816
+ // 📖 DETAILS PHASE: Scroll through selected version details
817
+ if (key.name === 'b') {
818
+ // 📖 B = back to index
819
+ state.changelogPhase = 'index'
820
+ state.changelogScrollOffset = 0
821
+ return
822
+ }
823
+
824
+ // 📖 Calculate total content lines for proper scroll boundary clamping
825
+ const calcChangelogLines = () => {
826
+ const lines = []
827
+ lines.push(` 🚀 free-coding-models`)
828
+ lines.push(` 📋 v${state.changelogSelectedVersion}`)
829
+ lines.push(` — ↑↓ / PgUp / PgDn scroll • B back • Esc close`)
830
+ lines.push('')
831
+ const changes = versions[state.changelogSelectedVersion]
832
+ if (changes) {
833
+ const sections = { added: '✨ Added', fixed: '🐛 Fixed', changed: '🔄 Changed', updated: '📝 Updated' }
834
+ for (const [key, label] of Object.entries(sections)) {
835
+ if (changes[key] && changes[key].length > 0) {
836
+ lines.push(` ${label}`)
837
+ for (const item of changes[key]) {
838
+ let displayText = item.replace(/\*\*([^*]+)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1')
839
+ const maxWidth = state.terminalCols - 16
840
+ if (displayText.length > maxWidth) {
841
+ displayText = displayText.substring(0, maxWidth - 3) + '…'
842
+ }
843
+ lines.push(` • ${displayText}`)
844
+ }
845
+ lines.push('')
846
+ }
847
+ }
848
+ }
849
+ return lines.length
850
+ }
851
+ const totalChangelogLines = calcChangelogLines()
852
+ const viewportRows = Math.max(1, state.terminalRows || 1)
853
+ const maxScrollOffset = Math.max(0, totalChangelogLines - viewportRows)
854
+
855
+ if (key.name === 'up') { state.changelogScrollOffset = Math.max(0, state.changelogScrollOffset - 1); return }
856
+ if (key.name === 'down') { state.changelogScrollOffset = Math.min(maxScrollOffset, state.changelogScrollOffset + 1); return }
857
+ if (key.name === 'pageup') { state.changelogScrollOffset = Math.max(0, state.changelogScrollOffset - pageStep); return }
858
+ if (key.name === 'pagedown') { state.changelogScrollOffset = Math.min(maxScrollOffset, state.changelogScrollOffset + pageStep); return }
859
+ if (key.name === 'home') { state.changelogScrollOffset = 0; return }
860
+ if (key.name === 'end') { state.changelogScrollOffset = maxScrollOffset; return }
861
+ }
862
+
863
+ if (key.ctrl && key.name === 'c') { exit(0); return }
864
+ return
865
+ }
866
+
769
867
  // 📖 Smart Recommend overlay: full keyboard handling while overlay is open.
770
868
  if (state.recommendOpen) {
771
869
  if (key.ctrl && key.name === 'c') { exit(0); return }
@@ -882,10 +980,11 @@ export function createKeyHandler(ctx) {
882
980
  const proxySyncRowIdx = updateRowIdx + 2
883
981
  const proxyPortRowIdx = updateRowIdx + 3
884
982
  const proxyCleanupRowIdx = updateRowIdx + 4
885
- // 📖 Profile rows start after maintenance + proxy rows — one row per saved profile
983
+ const changelogViewRowIdx = updateRowIdx + 5
984
+ // 📖 Profile rows start after maintenance + proxy rows + changelog row — one row per saved profile
886
985
  const savedProfiles = listProfiles(state.config)
887
- const profileStartIdx = updateRowIdx + 5
888
- const maxRowIdx = savedProfiles.length > 0 ? profileStartIdx + savedProfiles.length - 1 : proxyCleanupRowIdx
986
+ const profileStartIdx = updateRowIdx + 6
987
+ const maxRowIdx = savedProfiles.length > 0 ? profileStartIdx + savedProfiles.length - 1 : changelogViewRowIdx
889
988
 
890
989
  // 📖 Edit/Add-key mode: capture typed characters for the API key
891
990
  if (state.settingsEditMode || state.settingsAddKeyMode) {
@@ -1059,6 +1158,17 @@ export function createKeyHandler(ctx) {
1059
1158
  return
1060
1159
  }
1061
1160
 
1161
+ // 📖 Changelog row: Enter → open changelog overlay
1162
+ if (state.settingsCursor === changelogViewRowIdx) {
1163
+ state.settingsOpen = false
1164
+ state.changelogOpen = true
1165
+ state.changelogPhase = 'index'
1166
+ state.changelogCursor = 0
1167
+ state.changelogSelectedVersion = null
1168
+ state.changelogScrollOffset = 0
1169
+ return
1170
+ }
1171
+
1062
1172
  // 📖 Profile row: Enter → load the selected profile (apply its settings live)
1063
1173
  if (state.settingsCursor >= profileStartIdx && savedProfiles.length > 0) {
1064
1174
  const profileIdx = state.settingsCursor - profileStartIdx
@@ -1094,7 +1204,7 @@ export function createKeyHandler(ctx) {
1094
1204
  }
1095
1205
 
1096
1206
  if (key.name === 'space') {
1097
- if (state.settingsCursor === updateRowIdx || state.settingsCursor === proxyPortRowIdx || state.settingsCursor === proxyCleanupRowIdx) return
1207
+ if (state.settingsCursor === updateRowIdx || state.settingsCursor === proxyPortRowIdx || state.settingsCursor === proxyCleanupRowIdx || state.settingsCursor === changelogViewRowIdx) return
1098
1208
  // 📖 Profile rows don't respond to Space
1099
1209
  if (state.settingsCursor >= profileStartIdx) return
1100
1210
 
@@ -1125,7 +1235,7 @@ export function createKeyHandler(ctx) {
1125
1235
  }
1126
1236
 
1127
1237
  if (key.name === 't') {
1128
- if (state.settingsCursor === updateRowIdx) return
1238
+ if (state.settingsCursor === updateRowIdx || state.settingsCursor === proxyPortRowIdx || state.settingsCursor === proxyCleanupRowIdx || state.settingsCursor === changelogViewRowIdx) return
1129
1239
  // 📖 Profile rows don't respond to T (test key)
1130
1240
  if (state.settingsCursor >= profileStartIdx) return
1131
1241
 
@@ -1333,9 +1443,60 @@ export function createKeyHandler(ctx) {
1333
1443
  return
1334
1444
  }
1335
1445
 
1446
+ // 📖 Helper: persist current UI view settings (tier, provider, sort) to config.settings
1447
+ // 📖 Called after every T / D / sort key so preferences survive session restarts.
1448
+ function persistUiSettings() {
1449
+ if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
1450
+ state.config.settings.tierFilter = TIER_CYCLE[state.tierFilterMode]
1451
+ state.config.settings.originFilter = ORIGIN_CYCLE[state.originFilterMode] ?? null
1452
+ state.config.settings.sortColumn = state.sortColumn
1453
+ state.config.settings.sortAsc = state.sortDirection === 'asc'
1454
+ // 📖 Mirror into active profile too so profile captures live preferences
1455
+ if (state.activeProfile && state.config.profiles?.[state.activeProfile]) {
1456
+ const profile = state.config.profiles[state.activeProfile]
1457
+ if (!profile.settings || typeof profile.settings !== 'object') profile.settings = {}
1458
+ profile.settings.tierFilter = state.config.settings.tierFilter
1459
+ profile.settings.originFilter = state.config.settings.originFilter
1460
+ profile.settings.sortColumn = state.config.settings.sortColumn
1461
+ profile.settings.sortAsc = state.config.settings.sortAsc
1462
+ }
1463
+ saveConfig(state.config)
1464
+ }
1465
+
1466
+ // 📖 Shift+R: reset all UI view settings to defaults (tier, sort, provider) and clear persisted config
1467
+ if (key.name === 'r' && key.shift) {
1468
+ state.tierFilterMode = 0
1469
+ state.originFilterMode = 0
1470
+ state.sortColumn = 'avg'
1471
+ state.sortDirection = 'asc'
1472
+ if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
1473
+ delete state.config.settings.tierFilter
1474
+ delete state.config.settings.originFilter
1475
+ delete state.config.settings.sortColumn
1476
+ delete state.config.settings.sortAsc
1477
+ // 📖 Also clear in active profile if loaded
1478
+ if (state.activeProfile && state.config.profiles?.[state.activeProfile]) {
1479
+ const profile = state.config.profiles[state.activeProfile]
1480
+ if (profile.settings) {
1481
+ delete profile.settings.tierFilter
1482
+ delete profile.settings.originFilter
1483
+ delete profile.settings.sortColumn
1484
+ delete profile.settings.sortAsc
1485
+ }
1486
+ }
1487
+ saveConfig(state.config)
1488
+ applyTierFilter()
1489
+ const visible = state.results.filter(r => !r.hidden)
1490
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1491
+ state.cursor = 0
1492
+ state.scrollOffset = 0
1493
+ return
1494
+ }
1495
+
1336
1496
  // 📖 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
1337
1497
  // 📖 T is reserved for tier filter cycling. Y now opens the install-endpoints flow.
1338
1498
  // 📖 D is now reserved for provider filter cycling
1499
+ // 📖 Shift+R is reserved for reset view settings
1339
1500
  const sortKeys = {
1340
1501
  'r': 'rank', 'o': 'origin', 'm': 'model',
1341
1502
  'l': 'ping', 'a': 'avg', 's': 'swe', 'c': 'ctx', 'h': 'condition', 'v': 'verdict', 'b': 'stability', 'u': 'uptime', 'g': 'usage'
@@ -1355,6 +1516,7 @@ export function createKeyHandler(ctx) {
1355
1516
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1356
1517
  state.cursor = 0
1357
1518
  state.scrollOffset = 0
1519
+ persistUiSettings()
1358
1520
  return
1359
1521
  }
1360
1522
 
@@ -1440,6 +1602,7 @@ export function createKeyHandler(ctx) {
1440
1602
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1441
1603
  state.cursor = 0
1442
1604
  state.scrollOffset = 0
1605
+ persistUiSettings()
1443
1606
  return
1444
1607
  }
1445
1608
 
@@ -1452,6 +1615,7 @@ export function createKeyHandler(ctx) {
1452
1615
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1453
1616
  state.cursor = 0
1454
1617
  state.scrollOffset = 0
1618
+ persistUiSettings()
1455
1619
  return
1456
1620
  }
1457
1621
 
@@ -1462,6 +1626,18 @@ export function createKeyHandler(ctx) {
1462
1626
  return
1463
1627
  }
1464
1628
 
1629
+ // 📖 Changelog overlay key: N = toggle changelog overlay
1630
+ if (key.name === 'n') {
1631
+ state.changelogOpen = !state.changelogOpen
1632
+ if (state.changelogOpen) {
1633
+ state.changelogScrollOffset = 0
1634
+ state.changelogPhase = 'index'
1635
+ state.changelogCursor = 0
1636
+ state.changelogSelectedVersion = null
1637
+ }
1638
+ return
1639
+ }
1640
+
1465
1641
  // 📖 Mode toggle key: Z cycles through the supported tool targets.
1466
1642
  if (key.name === 'z') {
1467
1643
  const modeOrder = getToolModeOrder()
package/src/overlays.js CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * @details
6
6
  * This module centralizes all overlay rendering in one place:
7
- * - Settings, Install Endpoints, Help, Log, Smart Recommend, Feature Request, Bug Report
7
+ * - Settings, Install Endpoints, Help, Log, Smart Recommend, Feature Request, Bug Report, Changelog
8
8
  * - Settings diagnostics for provider key tests, including wrapped retry/error details
9
9
  * - Recommend analysis timer orchestration and progress updates
10
10
  *
@@ -17,6 +17,8 @@
17
17
  * @exports { createOverlayRenderers }
18
18
  */
19
19
 
20
+ import { loadChangelog } from './changelog-loader.js'
21
+
20
22
  export function createOverlayRenderers(state, deps) {
21
23
  const {
22
24
  chalk,
@@ -138,6 +140,7 @@ export function createOverlayRenderers(state, deps) {
138
140
  const proxySyncRowIdx = updateRowIdx + 2
139
141
  const proxyPortRowIdx = updateRowIdx + 3
140
142
  const proxyCleanupRowIdx = updateRowIdx + 4
143
+ const changelogViewRowIdx = updateRowIdx + 5
141
144
  const proxySettings = getProxySettings(state.config)
142
145
  const EL = '\x1b[K'
143
146
  const lines = []
@@ -296,9 +299,15 @@ export function createOverlayRenderers(state, deps) {
296
299
  cursorLineByRow[proxyCleanupRowIdx] = lines.length
297
300
  lines.push(state.settingsCursor === proxyCleanupRowIdx ? chalk.bgRgb(45, 30, 30)(proxyCleanupRow) : proxyCleanupRow)
298
301
 
302
+ // 📖 Changelog viewer row
303
+ const changelogViewBullet = state.settingsCursor === changelogViewRowIdx ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
304
+ const changelogViewRow = `${changelogViewBullet}${chalk.bold('View Changelog').padEnd(44)} ${chalk.dim('Enter browse version history')}`
305
+ cursorLineByRow[changelogViewRowIdx] = lines.length
306
+ lines.push(state.settingsCursor === changelogViewRowIdx ? chalk.bgRgb(30, 45, 30)(changelogViewRow) : changelogViewRow)
307
+
299
308
  // 📖 Profiles section — list saved profiles with active indicator + delete support
300
309
  const savedProfiles = listProfiles(state.config)
301
- const profileStartIdx = updateRowIdx + 5
310
+ const profileStartIdx = updateRowIdx + 6
302
311
  const maxRowIdx = savedProfiles.length > 0 ? profileStartIdx + savedProfiles.length - 1 : updateRowIdx
303
312
 
304
313
  lines.push('')
@@ -610,6 +619,8 @@ export function createOverlayRenderers(state, deps) {
610
619
  lines.push(` ${chalk.yellow('Shift+S')} Save current config as a named profile ${chalk.dim('(inline prompt — type name + Enter)')}`)
611
620
  lines.push(` ${chalk.dim('Profiles store: favorites, sort, tier filter, ping interval, configured-only filter, API keys.')}`)
612
621
  lines.push(` ${chalk.dim('Use --profile <name> to load a profile on startup.')}`)
622
+ lines.push(` ${chalk.yellow('Shift+R')} Reset view settings ${chalk.dim('(tier filter, sort, provider filter → defaults)')}`)
623
+ lines.push(` ${chalk.yellow('N')} Changelog ${chalk.dim('(📋 browse all versions, Enter to view details)')}`)
613
624
  lines.push(` ${chalk.yellow('K')} / ${chalk.yellow('Esc')} Show/hide this help`)
614
625
  lines.push(` ${chalk.yellow('Ctrl+C')} Exit`)
615
626
  lines.push('')
@@ -1239,6 +1250,97 @@ export function createOverlayRenderers(state, deps) {
1239
1250
  return cleared.join('\n')
1240
1251
  }
1241
1252
 
1253
+ // ─── Changelog overlay renderer ───────────────────────────────────────────
1254
+ // 📖 renderChangelog: Two-phase overlay — index of all versions or details of one version
1255
+ function renderChangelog() {
1256
+ const EL = '\x1b[K'
1257
+ const lines = []
1258
+ const changelogData = loadChangelog()
1259
+ const { versions } = changelogData
1260
+ const versionList = Object.keys(versions).sort((a, b) => {
1261
+ const aParts = a.split('.').map(Number)
1262
+ const bParts = b.split('.').map(Number)
1263
+ for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
1264
+ const aVal = aParts[i] || 0
1265
+ const bVal = bParts[i] || 0
1266
+ if (bVal !== aVal) return bVal - aVal
1267
+ }
1268
+ return 0
1269
+ })
1270
+
1271
+ // 📖 Branding header
1272
+ lines.push(` ${chalk.cyanBright('🚀')} ${chalk.bold.cyanBright('free-coding-models')} ${chalk.dim(`v${LOCAL_VERSION}`)}`)
1273
+
1274
+ if (state.changelogPhase === 'index') {
1275
+ // ═══════════════════════════════════════════════════════════════════════
1276
+ // 📖 INDEX PHASE: Show all versions with selection
1277
+ // ═══════════════════════════════════════════════════════════════════════
1278
+ lines.push(` ${chalk.bold('📋 Changelog - All Versions')}`)
1279
+ lines.push(` ${chalk.dim('— ↑↓ navigate • Enter select • Esc close')}`)
1280
+ lines.push('')
1281
+
1282
+ for (let i = 0; i < versionList.length; i++) {
1283
+ const version = versionList[i]
1284
+ const changes = versions[version]
1285
+ const isSelected = i === state.changelogCursor
1286
+
1287
+ // 📖 Count items in this version
1288
+ let itemCount = 0
1289
+ for (const key of ['added', 'fixed', 'changed', 'updated']) {
1290
+ if (changes[key]) itemCount += changes[key].length
1291
+ }
1292
+
1293
+ // 📖 Format version line with selection highlight
1294
+ const versionStr = ` v${version.padEnd(8)} — ${itemCount} ${itemCount === 1 ? 'change' : 'changes'}`
1295
+ if (isSelected) {
1296
+ lines.push(chalk.inverse(versionStr))
1297
+ } else {
1298
+ lines.push(versionStr)
1299
+ }
1300
+ }
1301
+
1302
+ lines.push('')
1303
+ lines.push(` ${chalk.dim(`Total: ${versionList.length} versions`)}`)
1304
+
1305
+ } else if (state.changelogPhase === 'details') {
1306
+ // ═══════════════════════════════════════════════════════════════════════
1307
+ // 📖 DETAILS PHASE: Show detailed changes for selected version
1308
+ // ═══════════════════════════════════════════════════════════════════════
1309
+ lines.push(` ${chalk.bold(`📋 v${state.changelogSelectedVersion}`)}`)
1310
+ lines.push(` ${chalk.dim('— ↑↓ / PgUp / PgDn scroll • B back • Esc close')}`)
1311
+ lines.push('')
1312
+
1313
+ const changes = versions[state.changelogSelectedVersion]
1314
+ if (changes) {
1315
+ const sections = { added: '✨ Added', fixed: '🐛 Fixed', changed: '🔄 Changed', updated: '📝 Updated' }
1316
+ for (const [key, label] of Object.entries(sections)) {
1317
+ if (changes[key] && changes[key].length > 0) {
1318
+ lines.push(` ${chalk.yellow(label)}`)
1319
+ for (const item of changes[key]) {
1320
+ // 📖 Unwrap markdown bold/code markers for display
1321
+ let displayText = item.replace(/\*\*([^*]+)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1')
1322
+ // 📖 Wrap long lines
1323
+ const maxWidth = state.terminalCols - 16
1324
+ if (displayText.length > maxWidth) {
1325
+ displayText = displayText.substring(0, maxWidth - 3) + '…'
1326
+ }
1327
+ lines.push(` • ${displayText}`)
1328
+ }
1329
+ lines.push('')
1330
+ }
1331
+ }
1332
+ }
1333
+ }
1334
+
1335
+ // 📖 Use scrolling with overlay handler
1336
+ const CHANGELOG_OVERLAY_BG = chalk.bgRgb(10, 40, 80) // Dark blue background
1337
+ const { visible, offset } = sliceOverlayLines(lines, state.changelogScrollOffset, state.terminalRows)
1338
+ state.changelogScrollOffset = offset
1339
+ const tintedLines = tintOverlayLines(visible, CHANGELOG_OVERLAY_BG, state.terminalCols)
1340
+ const cleared = tintedLines.map(l => l + EL)
1341
+ return cleared.join('\n')
1342
+ }
1343
+
1242
1344
  // 📖 stopRecommendAnalysis: cleanup timers if user cancels during analysis
1243
1345
  function stopRecommendAnalysis() {
1244
1346
  if (state.recommendAnalysisTimer) { clearInterval(state.recommendAnalysisTimer); state.recommendAnalysisTimer = null }
@@ -1253,6 +1355,7 @@ export function createOverlayRenderers(state, deps) {
1253
1355
  renderRecommend,
1254
1356
  renderFeatureRequest,
1255
1357
  renderBugReport,
1358
+ renderChangelog,
1256
1359
  startRecommendAnalysis,
1257
1360
  stopRecommendAnalysis,
1258
1361
  }
@@ -672,6 +672,8 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
672
672
  chalk.dim(' → ') +
673
673
  chalk.rgb(200, 150, 255)('https://discord.gg/ZTNFHvvCkU') +
674
674
  chalk.dim(' • ') +
675
+ chalk.yellow('N') + chalk.dim(' Changelog') +
676
+ chalk.dim(' • ') +
675
677
  chalk.dim('Ctrl+C Exit')
676
678
  }
677
679
  lines.push(footerLine)