free-coding-models 0.2.11 → 0.2.13

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,10 +2,44 @@
2
2
 
3
3
  ---
4
4
 
5
+ ## 0.2.13
6
+
7
+ ### Added
8
+ - **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.
9
+ - When T cycles tier: S+ tier is now remembered for next session
10
+ - When D cycles provider: Filtered provider is now remembered
11
+ - When sort keys (R/O/M/L/A/S/C/H/V/B/U) change order: Sort column and direction are now remembered
12
+ - Profile loading has priority over global `config.settings` so saved profiles override global defaults
13
+ - **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.
14
+ - Useful when you've customized your view but want a fresh start
15
+ - Does not affect favorites, API keys, or other settings — only view state
16
+
17
+ ### Changed
18
+ - **Help overlay (K key)**: Updated to document new Shift+R keybinding for resetting view settings
19
+
20
+ ---
21
+
22
+ ## 0.2.12
23
+
24
+ ### Added
25
+ - **Auto-select models for all external tools**: All 10 supported tools (Aider, Crush, Goose, Claude Code, Codex, Gemini, Qwen, OpenHands, Amp, Pi) now automatically configure and pre-select the chosen model on launch — no manual model selection needed after pressing Enter.
26
+ - **Changelog loader utility**: New `src/changelog-loader.js` module parses CHANGELOG.md for future TUI integration to display changes directly in the app instead of opening a browser.
27
+
28
+ ### Fixed
29
+ - **Infinite update loop on startup**: Disabled forced auto-update that caused the app to detect the same update repeatedly after restarting. The app now checks for updates in the background without forcing installation.
30
+ - **Removed disruptive browser window**: The auto-update process no longer opens a browser window to show the changelog — it now shows update information in the terminal only.
31
+ - **Update failure tracking**: If update checks fail 3+ times, the app displays a prominent red footer warning: `⚠ OUTDATED version, please update` with manual update instructions instead of crashing.
32
+
33
+ ### Changed
34
+ - **OpenHands integration improved**: Now sets `LLM_MODEL` and `LLM_API_KEY` environment variables for proper model pre-selection on launch.
35
+ - **Amp integration improved**: Now writes `amp.model` to config file with the selected model ID.
36
+
37
+ ---
38
+
5
39
  ## 0.2.11
6
40
 
7
41
  ### Added
8
- - **Pi Coding Agent support**: Enabled Pi (pi.dev) as a launchable mode in the Z key cycle. Select a model and press Enter to configure Pi's config file and spawn the PI coding agent CLI with the chosen model and API endpoint.
42
+ - **Pi Coding Agent support**: Enabled Pi (pi.dev) as a launchable mode in the Z key cycle. Select a model and press Enter to auto-configure Pi's model config and settings, then spawn the PI coding agent CLI with the chosen model pre-selected as the default.
9
43
 
10
44
  ---
11
45
 
package/README.md CHANGED
@@ -80,13 +80,14 @@ 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
86
87
  - **💻 OpenCode integration** — Auto-detects NIM setup, sets model as default, launches OpenCode
87
88
  - **🦞 OpenClaw integration** — Sets selected model as default provider in `~/.openclaw/openclaw.json`
88
- - **🧰 Public tool launchers** — `Enter` can auto-configure and launch `OpenCode CLI`, `OpenCode Desktop`, `OpenClaw`, `Crush`, and `Goose`
89
- - **🔌 Install Endpoints flow** — Press `Y` to install one configured provider directly into `OpenCode CLI`, `OpenCode Desktop`, `OpenClaw`, `Crush`, or `Goose`, either with the full provider catalog or a curated subset of models
89
+ - **🧰 Public tool launchers** — `Enter` auto-configures and launches 10+ tools: `OpenCode CLI`, `OpenCode Desktop`, `OpenClaw`, `Crush`, `Goose`, `Aider`, `Claude Code`, `Codex`, `Gemini`, `Qwen`, `OpenHands`, `Amp`, and `Pi`. All tools auto-select the chosen model on launch.
90
+ - **🔌 Install Endpoints flow** — Press `Y` to install one configured provider directly into `OpenCode CLI`, `OpenCode Desktop`, `OpenClaw`, `Crush`, `Goose`, `Aider`, or `Gemini`, either with the full provider catalog or a curated subset of models
90
91
  - **📝 Feature Request (J key)** — Send anonymous feedback directly to the project team
91
92
  - **🐛 Bug Report (I key)** — Send anonymous bug reports directly to the project team
92
93
  - **🎨 Clean output** — Zero scrollback pollution, interface stays open until Ctrl+C
@@ -594,6 +595,30 @@ Use **↑↓** to scroll and **Esc** or **X** to return to the main table.
594
595
 
595
596
  ---
596
597
 
598
+ ## 🧰 Supported Tool Launchers
599
+
600
+ You can use `free-coding-models` with 12+ AI coding tools. When you select a model and press Enter, the tool automatically configures and pre-selects your chosen model:
601
+
602
+ | Tool | Flag | Auto-Config |
603
+ |------|------|------------|
604
+ | OpenCode CLI | `--opencode` | ~/.config/opencode/opencode.json |
605
+ | OpenCode Desktop | `--opencode-desktop` | Opens Desktop app |
606
+ | OpenClaw | `--openclaw` | ~/.openclaw/openclaw.json |
607
+ | Crush | `--crush` | ~/.config/crush/crush.json |
608
+ | Goose | `--goose` | Environment variables |
609
+ | **Aider** | `--aider` | ~/.aider.conf.yml |
610
+ | **Claude Code** | `--claude-code` | CLI flag |
611
+ | **Codex** | `--codex` | CLI flag |
612
+ | **Gemini** | `--gemini` | ~/.gemini/settings.json |
613
+ | **Qwen** | `--qwen` | ~/.qwen/settings.json |
614
+ | **OpenHands** | `--openhands` | LLM_MODEL env var |
615
+ | **Amp** | `--amp` | ~/.config/amp/settings.json |
616
+ | **Pi** | `--pi` | ~/.pi/agent/settings.json |
617
+
618
+ Press **Z** to cycle through different tool modes in the TUI, or use flags to start in your preferred mode.
619
+
620
+ ---
621
+
597
622
  ## 🔌 OpenCode Integration
598
623
 
599
624
  **The easiest way** — let `free-coding-models` do everything:
@@ -604,7 +629,7 @@ Use **↑↓** to scroll and **Esc** or **X** to return to the main table.
604
629
  4. **Press Enter** — tool automatically:
605
630
  - Detects if NVIDIA NIM is configured in OpenCode
606
631
  - Sets your selected model as default in `~/.config/opencode/opencode.json`
607
- - Launches OpenCode with the model ready to use
632
+ - Launches OpenCode with the model pre-selected and ready to use
608
633
 
609
634
  ### tmux sub-agent panes
610
635
 
@@ -907,6 +932,7 @@ This script:
907
932
  - **Shift+P** — Cycle through saved profiles (switches live TUI settings)
908
933
  - **Shift+S** — Save current TUI settings as a named profile (inline prompt)
909
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)
910
936
  - **W** — Cycle ping mode (`FAST` 2s → `NORMAL` 10s → `SLOW` 30s → `FORCED` 4s)
911
937
  - **J / I** — Request feature / Report bug
912
938
  - **K / Esc** — Show help overlay / Close overlay
@@ -261,47 +261,26 @@ async function main() {
261
261
  ts: new Date().toISOString(),
262
262
  })
263
263
 
264
- // 📖 Check for updates in the background
264
+ // 📖 Check for updates in the background (non-blocking, non-forced)
265
+ // 📖 The old auto-update on startup caused infinite loops, so we've moved to:
266
+ // 📖 1. Optional prompt when a new version is available (user chooses to update or not)
267
+ // 📖 2. Display "OUTDATED" in TUI footer if update check fails repeatedly
265
268
  let latestVersion = null
269
+ let isOutdated = false
266
270
  try {
267
271
  latestVersion = await checkForUpdate()
268
- } catch {
269
- // Silently fail - don't block the app if npm registry is unreachable
270
- }
271
-
272
- // 📖 Auto-update system: force updates and handle changelog automatically
273
- // 📖 Skip when running from source (dev mode) — .git means we're in a repo checkout,
274
- // 📖 not a global npm install. Auto-update would overwrite the global copy but restart
275
- // 📖 the local one, causing an infinite update loop since LOCAL_VERSION never changes.
276
- const isDevMode = existsSync(join(dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1')), '..', '.git'))
277
- if (latestVersion && !isDevMode) {
278
- console.log()
279
- console.log(chalk.bold.red(' ⚠ AUTO-UPDATE AVAILABLE'))
280
- console.log(chalk.red(` Version ${latestVersion} will be installed automatically`))
281
- console.log(chalk.dim(' Opening changelog in browser...'))
282
- console.log()
283
-
284
- // 📖 Open changelog automatically
285
- const { execSync } = require('child_process')
286
- const changelogUrl = 'https://github.com/vava-nessa/free-coding-models/releases'
287
- try {
288
- if (isMac) {
289
- execSync(`open "${changelogUrl}"`, { stdio: 'ignore' })
290
- } else if (isWindows) {
291
- execSync(`start "" "${changelogUrl}"`, { stdio: 'ignore' })
292
- } else {
293
- execSync(`xdg-open "${changelogUrl}"`, { stdio: 'ignore' })
294
- }
295
- console.log(chalk.green(' ✅ Changelog opened in browser'))
296
- } catch {
297
- console.log(chalk.yellow(' ⚠ Could not open browser automatically'))
298
- console.log(chalk.dim(` Visit manually: ${changelogUrl}`))
272
+ // 📖 Track update check failures - if it fails 3+ times, mark as outdated
273
+ if (!latestVersion && config.settings?.updateCheckFailures >= 3) {
274
+ isOutdated = true
299
275
  }
300
-
301
- // 📖 Force update immediately
302
- console.log(chalk.cyan(' 🚀 Starting auto-update...'))
303
- runUpdate(latestVersion)
304
- return // runUpdate will restart the process
276
+ } catch (err) {
277
+ // 📖 Silently fail - don't block the app if npm registry is unreachable
278
+ // 📖 But track the failure for outdated detection
279
+ const failures = (config.settings?.updateCheckFailures || 0) + 1
280
+ if (!config.settings) config.settings = {}
281
+ config.settings.updateCheckFailures = Math.min(failures, 3)
282
+ if (failures >= 3) isOutdated = true
283
+ saveConfig(config)
305
284
  }
306
285
 
307
286
  // 📖 Dynamic OpenRouter free model discovery — fetch live free models from API
@@ -383,8 +362,8 @@ async function main() {
383
362
  frame: 0,
384
363
  cursor: 0,
385
364
  selectedModel: null,
386
- sortColumn: startupProfileSettings?.sortColumn || 'avg',
387
- sortDirection: startupProfileSettings?.sortAsc === false ? 'desc' : 'asc',
365
+ sortColumn: startupProfileSettings?.sortColumn ?? config.settings?.sortColumn ?? 'avg',
366
+ sortDirection: (startupProfileSettings?.sortAsc ?? config.settings?.sortAsc ?? true) ? 'asc' : 'desc',
388
367
  pingInterval: PING_MODE_INTERVALS.speed, // 📖 Effective live interval derived from the active ping mode.
389
368
  pingMode: 'speed', // 📖 Current ping mode: speed | normal | slow | forced.
390
369
  pingModeSource: 'startup', // 📖 Why this mode is active: startup | manual | auto | idle | activity.
@@ -392,6 +371,8 @@ async function main() {
392
371
  lastPingTime: now, // 📖 Track when last ping cycle started
393
372
  lastUserActivityAt: now, // 📖 Any keypress refreshes this timer; inactivity can force slow mode.
394
373
  resumeSpeedOnActivity: false, // 📖 Set after idle slowdown so the next activity restarts a 60s speed burst.
374
+ latestVersion, // 📖 Latest npm version available (null if none or check failed)
375
+ isOutdated, // 📖 Set to true if update check failed 3+ times (show red "OUTDATED" footer)
395
376
  mode, // 📖 'opencode' or 'openclaw' — controls Enter action
396
377
  tierFilterMode: 0, // 📖 Index into TIER_CYCLE (0=All, 1=S+, 2=S, ...)
397
378
  originFilterMode: 0, // 📖 Index into ORIGIN_CYCLE (0=All, then providers)
@@ -463,6 +444,12 @@ async function main() {
463
444
  logVisible: false, // 📖 Whether the log page overlay is active
464
445
  logScrollOffset: 0, // 📖 Vertical scroll offset for log overlay viewport
465
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
466
453
  // 📖 Proxy startup status — set by autoStartProxyIfSynced, consumed by Task 3 indicator
467
454
  // 📖 null = not configured/not attempted
468
455
  // 📖 { phase: 'starting' } — proxy start in progress
@@ -625,8 +612,10 @@ async function main() {
625
612
 
626
613
  // 📖 originFilterMode: index into ORIGIN_CYCLE, 0=All, then each provider key in order
627
614
  const ORIGIN_CYCLE = [null, ...Object.keys(sources)]
628
- state.tierFilterMode = startupProfileSettings?.tierFilter ? Math.max(0, TIER_CYCLE.indexOf(startupProfileSettings.tierFilter)) : 0
629
- 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
630
619
 
631
620
  function applyTierFilter() {
632
621
  const activeTier = TIER_CYCLE[state.tierFilterMode]
@@ -790,12 +779,12 @@ async function main() {
790
779
  process.stdin.on('keypress', onKeyPress)
791
780
  process.on('SIGCONT', noteUserActivity)
792
781
 
793
- // 📖 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
794
783
  ticker = setInterval(() => {
795
784
  refreshAutoPingMode()
796
785
  state.frame++
797
786
  // 📖 Cache visible+sorted models each frame so Enter handler always matches the display
798
- 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) {
799
788
  const visible = state.results.filter(r => !r.hidden)
800
789
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
801
790
  }
@@ -813,7 +802,9 @@ async function main() {
813
802
  ? overlays.renderHelp()
814
803
  : state.logVisible
815
804
  ? overlays.renderLog()
816
- : 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)
805
+ : state.changelogOpen
806
+ ? overlays.renderChangelog()
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)
817
808
  process.stdout.write(ALT_HOME + content)
818
809
  }, Math.round(1000 / FPS))
819
810
 
@@ -821,7 +812,7 @@ async function main() {
821
812
  const initialVisible = state.results.filter(r => !r.hidden)
822
813
  state.visibleSorted = sortResultsWithPinnedFavorites(initialVisible, state.sortColumn, state.sortDirection)
823
814
 
824
- process.stdout.write(ALT_HOME + 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))
815
+ process.stdout.write(ALT_HOME + 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))
825
816
 
826
817
  // 📖 If --recommend was passed, auto-open the Smart Recommend overlay on start
827
818
  if (cliArgs.recommendMode) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
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",
@@ -0,0 +1,118 @@
1
+ /**
2
+ * @file changelog-loader.js
3
+ * @description Load and parse CHANGELOG.md for display in the TUI
4
+ *
5
+ * @functions
6
+ * → loadChangelog() — Read and parse CHANGELOG.md into structured format
7
+ * → getLatestChanges(version) — Return changelog for a specific version
8
+ * → formatChangelogForDisplay(version) — Format for TUI rendering
9
+ *
10
+ * @exports loadChangelog, getLatestChanges, formatChangelogForDisplay
11
+ */
12
+
13
+ import { readFileSync, existsSync } from 'fs'
14
+ import { dirname, join } from 'path'
15
+ import { fileURLToPath } from 'url'
16
+
17
+ const __filename = fileURLToPath(import.meta.url)
18
+ const __dirname = dirname(__filename)
19
+ const CHANGELOG_PATH = join(__dirname, '..', 'CHANGELOG.md')
20
+
21
+ /**
22
+ * 📖 loadChangelog: Read and parse CHANGELOG.md
23
+ * @returns {Object} { versions: { '0.2.11': { added: [], fixed: [], changed: [] }, ... } }
24
+ */
25
+ export function loadChangelog() {
26
+ if (!existsSync(CHANGELOG_PATH)) return { versions: {} }
27
+
28
+ const content = readFileSync(CHANGELOG_PATH, 'utf8')
29
+ const versions = {}
30
+ const lines = content.split('\n')
31
+ let currentVersion = null
32
+ let currentSection = null
33
+ let currentItems = []
34
+
35
+ for (const line of lines) {
36
+ // 📖 Match version headers: ## 0.2.11
37
+ const versionMatch = line.match(/^## ([\d.]+)/)
38
+ if (versionMatch) {
39
+ if (currentVersion && currentSection && currentItems.length > 0) {
40
+ if (!versions[currentVersion]) versions[currentVersion] = {}
41
+ versions[currentVersion][currentSection] = currentItems
42
+ }
43
+ currentVersion = versionMatch[1]
44
+ currentSection = null
45
+ currentItems = []
46
+ continue
47
+ }
48
+
49
+ // 📖 Match section headers: ### Added, ### Fixed, ### Changed
50
+ const sectionMatch = line.match(/^### (Added|Fixed|Changed|Updated)/)
51
+ if (sectionMatch) {
52
+ if (currentVersion && currentSection && currentItems.length > 0) {
53
+ if (!versions[currentVersion]) versions[currentVersion] = {}
54
+ versions[currentVersion][currentSection.toLowerCase()] = currentItems
55
+ }
56
+ currentSection = sectionMatch[1].toLowerCase()
57
+ currentItems = []
58
+ continue
59
+ }
60
+
61
+ // 📖 Match bullet points: - **text**: description
62
+ if (line.match(/^- /) && currentVersion && currentSection) {
63
+ currentItems.push(line.replace(/^- /, ''))
64
+ }
65
+ }
66
+
67
+ // 📖 Save the last section
68
+ if (currentVersion && currentSection && currentItems.length > 0) {
69
+ if (!versions[currentVersion]) versions[currentVersion] = {}
70
+ versions[currentVersion][currentSection] = currentItems
71
+ }
72
+
73
+ return { versions }
74
+ }
75
+
76
+ /**
77
+ * 📖 getLatestChanges: Return changelog for a specific version
78
+ * @param {string} version (e.g. '0.2.11')
79
+ * @returns {Object|null}
80
+ */
81
+ export function getLatestChanges(version) {
82
+ const { versions } = loadChangelog()
83
+ return versions[version] || null
84
+ }
85
+
86
+ /**
87
+ * 📖 formatChangelogForDisplay: Format changelog section as array of strings for TUI
88
+ * @param {string} version
89
+ * @returns {string[]} formatted lines
90
+ */
91
+ export function formatChangelogForDisplay(version) {
92
+ const changes = getLatestChanges(version)
93
+ if (!changes) return []
94
+
95
+ const lines = [
96
+ `📋 Changelog for v${version}`,
97
+ '',
98
+ ]
99
+
100
+ const sections = { added: 'Added', fixed: 'Fixed', changed: 'Changed', updated: 'Updated' }
101
+ for (const [key, label] of Object.entries(sections)) {
102
+ if (changes[key] && changes[key].length > 0) {
103
+ lines.push(`✨ ${label}:`)
104
+ for (const item of changes[key]) {
105
+ // 📖 Wrap long lines for display
106
+ const maxWidth = 70
107
+ let item_text = item.replace(/\*\*([^*]+)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1')
108
+ if (item_text.length > maxWidth) {
109
+ item_text = item_text.substring(0, maxWidth - 3) + '...'
110
+ }
111
+ lines.push(` • ${item_text}`)
112
+ }
113
+ lines.push('')
114
+ }
115
+ }
116
+
117
+ return lines
118
+ }
@@ -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,70 @@ 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
+ if (key.name === 'up') { state.changelogScrollOffset = Math.max(0, state.changelogScrollOffset - 1); return }
824
+ if (key.name === 'down') { state.changelogScrollOffset += 1; return }
825
+ if (key.name === 'pageup') { state.changelogScrollOffset = Math.max(0, state.changelogScrollOffset - pageStep); return }
826
+ if (key.name === 'pagedown') { state.changelogScrollOffset += pageStep; return }
827
+ if (key.name === 'home') { state.changelogScrollOffset = 0; return }
828
+ if (key.name === 'end') { state.changelogScrollOffset = Number.MAX_SAFE_INTEGER; return }
829
+ }
830
+
831
+ if (key.ctrl && key.name === 'c') { exit(0); return }
832
+ return
833
+ }
834
+
769
835
  // 📖 Smart Recommend overlay: full keyboard handling while overlay is open.
770
836
  if (state.recommendOpen) {
771
837
  if (key.ctrl && key.name === 'c') { exit(0); return }
@@ -882,10 +948,11 @@ export function createKeyHandler(ctx) {
882
948
  const proxySyncRowIdx = updateRowIdx + 2
883
949
  const proxyPortRowIdx = updateRowIdx + 3
884
950
  const proxyCleanupRowIdx = updateRowIdx + 4
885
- // 📖 Profile rows start after maintenance + proxy rows — one row per saved profile
951
+ const changelogViewRowIdx = updateRowIdx + 5
952
+ // 📖 Profile rows start after maintenance + proxy rows + changelog row — one row per saved profile
886
953
  const savedProfiles = listProfiles(state.config)
887
- const profileStartIdx = updateRowIdx + 5
888
- const maxRowIdx = savedProfiles.length > 0 ? profileStartIdx + savedProfiles.length - 1 : proxyCleanupRowIdx
954
+ const profileStartIdx = updateRowIdx + 6
955
+ const maxRowIdx = savedProfiles.length > 0 ? profileStartIdx + savedProfiles.length - 1 : changelogViewRowIdx
889
956
 
890
957
  // 📖 Edit/Add-key mode: capture typed characters for the API key
891
958
  if (state.settingsEditMode || state.settingsAddKeyMode) {
@@ -1059,6 +1126,17 @@ export function createKeyHandler(ctx) {
1059
1126
  return
1060
1127
  }
1061
1128
 
1129
+ // 📖 Changelog row: Enter → open changelog overlay
1130
+ if (state.settingsCursor === changelogViewRowIdx) {
1131
+ state.settingsOpen = false
1132
+ state.changelogOpen = true
1133
+ state.changelogPhase = 'index'
1134
+ state.changelogCursor = 0
1135
+ state.changelogSelectedVersion = null
1136
+ state.changelogScrollOffset = 0
1137
+ return
1138
+ }
1139
+
1062
1140
  // 📖 Profile row: Enter → load the selected profile (apply its settings live)
1063
1141
  if (state.settingsCursor >= profileStartIdx && savedProfiles.length > 0) {
1064
1142
  const profileIdx = state.settingsCursor - profileStartIdx
@@ -1094,7 +1172,7 @@ export function createKeyHandler(ctx) {
1094
1172
  }
1095
1173
 
1096
1174
  if (key.name === 'space') {
1097
- if (state.settingsCursor === updateRowIdx || state.settingsCursor === proxyPortRowIdx || state.settingsCursor === proxyCleanupRowIdx) return
1175
+ if (state.settingsCursor === updateRowIdx || state.settingsCursor === proxyPortRowIdx || state.settingsCursor === proxyCleanupRowIdx || state.settingsCursor === changelogViewRowIdx) return
1098
1176
  // 📖 Profile rows don't respond to Space
1099
1177
  if (state.settingsCursor >= profileStartIdx) return
1100
1178
 
@@ -1125,7 +1203,7 @@ export function createKeyHandler(ctx) {
1125
1203
  }
1126
1204
 
1127
1205
  if (key.name === 't') {
1128
- if (state.settingsCursor === updateRowIdx) return
1206
+ if (state.settingsCursor === updateRowIdx || state.settingsCursor === proxyPortRowIdx || state.settingsCursor === proxyCleanupRowIdx || state.settingsCursor === changelogViewRowIdx) return
1129
1207
  // 📖 Profile rows don't respond to T (test key)
1130
1208
  if (state.settingsCursor >= profileStartIdx) return
1131
1209
 
@@ -1333,9 +1411,60 @@ export function createKeyHandler(ctx) {
1333
1411
  return
1334
1412
  }
1335
1413
 
1414
+ // 📖 Helper: persist current UI view settings (tier, provider, sort) to config.settings
1415
+ // 📖 Called after every T / D / sort key so preferences survive session restarts.
1416
+ function persistUiSettings() {
1417
+ if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
1418
+ state.config.settings.tierFilter = TIER_CYCLE[state.tierFilterMode]
1419
+ state.config.settings.originFilter = ORIGIN_CYCLE[state.originFilterMode] ?? null
1420
+ state.config.settings.sortColumn = state.sortColumn
1421
+ state.config.settings.sortAsc = state.sortDirection === 'asc'
1422
+ // 📖 Mirror into active profile too so profile captures live preferences
1423
+ if (state.activeProfile && state.config.profiles?.[state.activeProfile]) {
1424
+ const profile = state.config.profiles[state.activeProfile]
1425
+ if (!profile.settings || typeof profile.settings !== 'object') profile.settings = {}
1426
+ profile.settings.tierFilter = state.config.settings.tierFilter
1427
+ profile.settings.originFilter = state.config.settings.originFilter
1428
+ profile.settings.sortColumn = state.config.settings.sortColumn
1429
+ profile.settings.sortAsc = state.config.settings.sortAsc
1430
+ }
1431
+ saveConfig(state.config)
1432
+ }
1433
+
1434
+ // 📖 Shift+R: reset all UI view settings to defaults (tier, sort, provider) and clear persisted config
1435
+ if (key.name === 'r' && key.shift) {
1436
+ state.tierFilterMode = 0
1437
+ state.originFilterMode = 0
1438
+ state.sortColumn = 'avg'
1439
+ state.sortDirection = 'asc'
1440
+ if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
1441
+ delete state.config.settings.tierFilter
1442
+ delete state.config.settings.originFilter
1443
+ delete state.config.settings.sortColumn
1444
+ delete state.config.settings.sortAsc
1445
+ // 📖 Also clear in active profile if loaded
1446
+ if (state.activeProfile && state.config.profiles?.[state.activeProfile]) {
1447
+ const profile = state.config.profiles[state.activeProfile]
1448
+ if (profile.settings) {
1449
+ delete profile.settings.tierFilter
1450
+ delete profile.settings.originFilter
1451
+ delete profile.settings.sortColumn
1452
+ delete profile.settings.sortAsc
1453
+ }
1454
+ }
1455
+ saveConfig(state.config)
1456
+ applyTierFilter()
1457
+ const visible = state.results.filter(r => !r.hidden)
1458
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1459
+ state.cursor = 0
1460
+ state.scrollOffset = 0
1461
+ return
1462
+ }
1463
+
1336
1464
  // 📖 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
1465
  // 📖 T is reserved for tier filter cycling. Y now opens the install-endpoints flow.
1338
1466
  // 📖 D is now reserved for provider filter cycling
1467
+ // 📖 Shift+R is reserved for reset view settings
1339
1468
  const sortKeys = {
1340
1469
  'r': 'rank', 'o': 'origin', 'm': 'model',
1341
1470
  'l': 'ping', 'a': 'avg', 's': 'swe', 'c': 'ctx', 'h': 'condition', 'v': 'verdict', 'b': 'stability', 'u': 'uptime', 'g': 'usage'
@@ -1355,6 +1484,7 @@ export function createKeyHandler(ctx) {
1355
1484
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1356
1485
  state.cursor = 0
1357
1486
  state.scrollOffset = 0
1487
+ persistUiSettings()
1358
1488
  return
1359
1489
  }
1360
1490
 
@@ -1440,6 +1570,7 @@ export function createKeyHandler(ctx) {
1440
1570
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1441
1571
  state.cursor = 0
1442
1572
  state.scrollOffset = 0
1573
+ persistUiSettings()
1443
1574
  return
1444
1575
  }
1445
1576
 
@@ -1452,6 +1583,7 @@ export function createKeyHandler(ctx) {
1452
1583
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1453
1584
  state.cursor = 0
1454
1585
  state.scrollOffset = 0
1586
+ persistUiSettings()
1455
1587
  return
1456
1588
  }
1457
1589
 
@@ -1462,6 +1594,18 @@ export function createKeyHandler(ctx) {
1462
1594
  return
1463
1595
  }
1464
1596
 
1597
+ // 📖 Changelog overlay key: N = toggle changelog overlay
1598
+ if (key.name === 'n') {
1599
+ state.changelogOpen = !state.changelogOpen
1600
+ if (state.changelogOpen) {
1601
+ state.changelogScrollOffset = 0
1602
+ state.changelogPhase = 'index'
1603
+ state.changelogCursor = 0
1604
+ state.changelogSelectedVersion = null
1605
+ }
1606
+ return
1607
+ }
1608
+
1465
1609
  // 📖 Mode toggle key: Z cycles through the supported tool targets.
1466
1610
  if (key.name === 'z') {
1467
1611
  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
  }
@@ -92,7 +92,7 @@ export function setActiveProxy(proxyInstance) {
92
92
  }
93
93
 
94
94
  // ─── renderTable: mode param controls footer hint text (opencode vs openclaw) ─────────
95
- export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, proxyEnabled = false) {
95
+ export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, proxyEnabled = false, isOutdated = false, latestVersion = null) {
96
96
  // 📖 Filter out hidden models for display
97
97
  const visibleResults = results.filter(r => !r.hidden)
98
98
 
@@ -648,25 +648,35 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
648
648
  const latestLabel = chalk.redBright(` local v${LOCAL_VERSION} · latest v${versionStatus.latestVersion}`)
649
649
  lines.push(` ${outdatedBadge}${latestLabel}`)
650
650
  }
651
- lines.push(
652
- chalk.rgb(255, 150, 200)(' Made with 💖 & by \x1b]8;;https://github.com/vava-nessa\x1b\\vava-nessa\x1b]8;;\x1b\\') +
653
- chalk.dim(' ') +
654
- '⭐ ' +
655
- chalk.yellow('\x1b]8;;https://github.com/vava-nessa/free-coding-models\x1b\\Star on GitHub\x1b]8;;\x1b\\') +
656
- chalk.dim(' ') +
657
- '🤝 ' +
658
- chalk.rgb(255, 165, 0)('\x1b]8;;https://github.com/vava-nessa/free-coding-models/graphs/contributors\x1b\\Contributors\x1b]8;;\x1b\\') +
659
- chalk.dim(' ') +
660
- '' +
661
- chalk.rgb(255, 200, 100)('\x1b]8;;https://buymeacoffee.com/vavanessadev\x1b\\Buy me a coffee\x1b]8;;\x1b\\') +
662
- chalk.dim('') +
663
- '💬 ' +
664
- chalk.rgb(200, 150, 255)('\x1b]8;;https://discord.gg/ZTNFHvvCkU\x1b\\Discord\x1b]8;;\x1b\\') +
665
- chalk.dim(' ') +
666
- chalk.rgb(200, 150, 255)('https://discord.gg/ZTNFHvvCkU') +
667
- chalk.dim('') +
668
- chalk.dim('Ctrl+C Exit')
669
- )
651
+
652
+ // 📖 Build footer line, with OUTDATED warning if isOutdated is true
653
+ let footerLine = ''
654
+ if (isOutdated) {
655
+ // 📖 Show OUTDATED in red background, high contrast warning
656
+ footerLine = chalk.bgRed.bold.white(' ⚠ OUTDATED version, please update with "npm i -g free-coding-models@latest" ')
657
+ } else {
658
+ footerLine =
659
+ chalk.rgb(255, 150, 200)(' Made with 💖 & ☕ by \x1b]8;;https://github.com/vava-nessa\x1b\\vava-nessa\x1b]8;;\x1b\\') +
660
+ chalk.dim('') +
661
+ ' ' +
662
+ chalk.yellow('\x1b]8;;https://github.com/vava-nessa/free-coding-models\x1b\\Star on GitHub\x1b]8;;\x1b\\') +
663
+ chalk.dim('') +
664
+ '🤝 ' +
665
+ chalk.rgb(255, 165, 0)('\x1b]8;;https://github.com/vava-nessa/free-coding-models/graphs/contributors\x1b\\Contributors\x1b]8;;\x1b\\') +
666
+ chalk.dim('') +
667
+ '' +
668
+ chalk.rgb(255, 200, 100)('\x1b]8;;https://buymeacoffee.com/vavanessadev\x1b\\Buy me a coffee\x1b]8;;\x1b\\') +
669
+ chalk.dim(' • ') +
670
+ '💬 ' +
671
+ chalk.rgb(200, 150, 255)('\x1b]8;;https://discord.gg/ZTNFHvvCkU\x1b\\Discord\x1b]8;;\x1b\\') +
672
+ chalk.dim(' → ') +
673
+ chalk.rgb(200, 150, 255)('https://discord.gg/ZTNFHvvCkU') +
674
+ chalk.dim(' • ') +
675
+ chalk.yellow('N') + chalk.dim(' Changelog') +
676
+ chalk.dim(' • ') +
677
+ chalk.dim('Ctrl+C Exit')
678
+ }
679
+ lines.push(footerLine)
670
680
 
671
681
  // 📖 Append \x1b[K (erase to EOL) to each line so leftover chars from previous
672
682
  // 📖 frames are cleared. Then pad with blank cleared lines to fill the terminal,
@@ -228,25 +228,36 @@ function writeQwenConfig(model, providerKey, apiKey, baseUrl) {
228
228
  }
229
229
 
230
230
  function writePiConfig(model, apiKey, baseUrl) {
231
- const filePath = join(homedir(), '.pi', 'agent', 'models.json')
232
- const backupPath = backupIfExists(filePath)
233
- const config = readJson(filePath, { providers: {} })
234
- if (!config.providers || typeof config.providers !== 'object') config.providers = {}
235
- config.providers.freeCodingModels = {
231
+ // 📖 Write models.json with the selected provider config
232
+ const modelsFilePath = join(homedir(), '.pi', 'agent', 'models.json')
233
+ const modelsBackupPath = backupIfExists(modelsFilePath)
234
+ const modelsConfig = readJson(modelsFilePath, { providers: {} })
235
+ if (!modelsConfig.providers || typeof modelsConfig.providers !== 'object') modelsConfig.providers = {}
236
+ modelsConfig.providers.freeCodingModels = {
236
237
  baseUrl,
237
238
  api: 'openai-completions',
238
239
  apiKey,
239
240
  models: [{ id: model.modelId, name: model.label }],
240
241
  }
241
- writeJson(filePath, config)
242
- return { filePath, backupPath }
242
+ writeJson(modelsFilePath, modelsConfig)
243
+
244
+ // 📖 Write settings.json to set the model as default on next launch
245
+ const settingsFilePath = join(homedir(), '.pi', 'agent', 'settings.json')
246
+ const settingsBackupPath = backupIfExists(settingsFilePath)
247
+ const settingsConfig = readJson(settingsFilePath, {})
248
+ settingsConfig.defaultProvider = 'freeCodingModels'
249
+ settingsConfig.defaultModel = model.modelId
250
+ writeJson(settingsFilePath, settingsConfig)
251
+
252
+ return { filePath: modelsFilePath, backupPath: modelsBackupPath, settingsFilePath, settingsBackupPath }
243
253
  }
244
254
 
245
- function writeAmpConfig(baseUrl) {
255
+ function writeAmpConfig(model, baseUrl) {
246
256
  const filePath = join(homedir(), '.config', 'amp', 'settings.json')
247
257
  const backupPath = backupIfExists(filePath)
248
258
  const config = readJson(filePath, {})
249
259
  config['amp.url'] = baseUrl
260
+ config['amp.model'] = model.modelId
250
261
  writeJson(filePath, config)
251
262
  return { filePath, backupPath }
252
263
  }
@@ -346,19 +357,24 @@ export async function startExternalTool(mode, model, config) {
346
357
  }
347
358
 
348
359
  if (mode === 'openhands') {
349
- console.log(chalk.dim(' 📖 OpenHands is launched with --override-with-envs so the selected model applies immediately.'))
360
+ // 📖 OpenHands supports LLM_MODEL env var to set the default model
361
+ env.LLM_MODEL = model.modelId
362
+ env.LLM_API_KEY = apiKey || env.LLM_API_KEY
363
+ if (baseUrl) env.LLM_BASE_URL = baseUrl
364
+ console.log(chalk.dim(` 📖 OpenHands launched with model: ${model.modelId}`))
350
365
  return spawnCommand('openhands', ['--override-with-envs'], env)
351
366
  }
352
367
 
353
368
  if (mode === 'amp') {
354
- printConfigResult(meta.label, writeAmpConfig(baseUrl))
355
- console.log(chalk.yellow(' Amp does not officially expose arbitrary model switching like the other CLIs.'))
356
- console.log(chalk.dim(' The proxy URL is written, then Amp is launched so you can reuse the current endpoint.'))
369
+ printConfigResult(meta.label, writeAmpConfig(model, baseUrl))
370
+ console.log(chalk.dim(` 📖 Amp config updated with model: ${model.modelId}`))
357
371
  return spawnCommand('amp', [], env)
358
372
  }
359
373
 
360
374
  if (mode === 'pi') {
361
- printConfigResult(meta.label, writePiConfig(model, apiKey, baseUrl))
375
+ const piResult = writePiConfig(model, apiKey, baseUrl)
376
+ printConfigResult(meta.label, { filePath: piResult.filePath, backupPath: piResult.backupPath })
377
+ printConfigResult(meta.label, { filePath: piResult.settingsFilePath, backupPath: piResult.settingsBackupPath })
362
378
  return spawnCommand('pi', [], env)
363
379
  }
364
380