free-coding-models 0.3.26 → 0.3.29

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
@@ -1,6 +1,28 @@
1
1
  # Changelog
2
2
  ---
3
3
 
4
+ ## [0.3.29] - 2026-03-27
5
+
6
+ ### Added
7
+ - **Fluorescent green UPDATE AVAILABLE banner** — impossible-to-miss fluo green (🚀⬆️) banner at the bottom of the TUI when a new version is detected; click it to update instantly
8
+ - **Command Palette update entry** — `⬆️ UPDATE NOW` is always the first result in Ctrl+P when an update is available
9
+ - **Shift+U hotkey** — press Shift+U from the main table to trigger an immediate update
10
+ - **Mouse-clickable update banner** — click the fluo green banner to install the latest version and relaunch
11
+ - **Background version re-check every 5 minutes** — if a new version is published while the TUI is open, the banner appears live without restarting
12
+ - **Aggressive pre-TUI update prompt** — fluorescent green header, and "Continue without update" warns that reminders will follow
13
+ - **Last release date in footer** — light pink `Last release: Mar 27, 2026, 09:42 PM` shows when the package was last published to npm, so users know how fresh the model data is
14
+ - **Demo2 GIF** — added `demo2.gif` to README for visual showcase of the TUI
15
+
16
+ ### Fixed
17
+ - **Auto-update now detects the correct package manager** — bun, pnpm, and yarn users no longer get duplicate npm installs (fixes #46)
18
+ - Update banner in footer shows the correct install command for your package manager
19
+
20
+ ## [0.3.28] - 2026-03-27
21
+
22
+ _(Skipped — published as 0.3.28 without changelog in commit message, all changes are in 0.3.29)_
23
+
24
+ ## [0.3.27] - 2026-03-27
25
+
4
26
  ## [0.3.26] - 2026-03-27
5
27
 
6
28
  ### Added
package/README.md CHANGED
@@ -130,7 +130,9 @@ If the active CLI tool is missing, FCM now catches it before launch, offers a ti
130
130
 
131
131
  > 💡 You can also run `free-coding-models --goose --tier S` to pre-filter to S-tier models for Goose before the TUI even opens.
132
132
 
133
-
133
+ <p align="center">
134
+ <img src="demo2.gif" alt="free-coding-models TUI demo" width="100%">
135
+ </p>
134
136
 
135
137
  ## 🚀 Usage
136
138
 
@@ -245,6 +247,7 @@ When a tool mode is active (via `Z`), models incompatible with that tool are hig
245
247
  | `G` | Cycle global theme (`Auto → Dark → Light`) |
246
248
  | `Ctrl+P` | Open ⚡️ command palette (search + run actions) |
247
249
  | `R/S/C/M/O/L/A/H/V/B/U` | Sort columns |
250
+ | `Shift+U` | Update to latest version (when update available) |
248
251
  | `P` | Settings (API keys, providers, updates, theme) |
249
252
  | `Q` | Smart Recommend overlay |
250
253
  | `N` | Changelog |
@@ -265,6 +268,7 @@ When a tool mode is active (via `Z`), models incompatible with that tool are hig
265
268
  | **Right-click model row** | Toggle favorite |
266
269
  | **Scroll wheel** | Navigate table / overlays / palette |
267
270
  | **Click footer hotkey** | Trigger that action |
271
+ | **Click update banner** | Install latest version and relaunch |
268
272
  | **Click command palette item** | Select item (double-click to confirm) |
269
273
  | **Click recommend option** | Select option (double-click to confirm) |
270
274
  | **Click outside modal** | Close command palette |
@@ -290,8 +294,10 @@ When a tool mode is active (via `Z`), models incompatible with that tool are hig
290
294
  - **OpenCode Zen models** — 8 free models exclusive to OpenCode CLI/Desktop, powered by the Zen AI gateway
291
295
  - **Width guardrail** — shows a warning instead of a broken table in narrow terminals
292
296
  - **Readable everywhere** — semantic theme palette keeps table rows, overlays, badges, and help screens legible in dark and light terminals
293
- - **Global theme switch** — `G` cycles `auto`, `dark`, and `light` live without restarting
297
+ - **Global theme switch** — `G` cycles `auto`, `dark`, + `light` live without restarting
294
298
  - **Auto-retry** — timeout models keep getting retried
299
+ - **Aggressive update nudging** — fluorescent green banner when an update is available, impossible to miss, Shift+U hotkey, command palette entry, background re-check every 5 min, mid-session updates the banner live without restarting
300
+ - **Last release timestamp** — light pink footer shows `Last release: Mar 27, 2026, 09:42 PM` from npm so users know how fresh the data is
295
301
 
296
302
  ---
297
303
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.3.26",
3
+ "version": "0.3.29",
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",
package/src/app.js CHANGED
@@ -112,7 +112,7 @@ import { runFiableMode, filterByTierOrExit, fetchOpenRouterFreeModels } from '..
112
112
  import { PROVIDER_METADATA, ENV_VAR_NAMES, isWindows, isMac } from '../src/provider-metadata.js'
113
113
  import { parseTelemetryEnv, isTelemetryDebugEnabled, telemetryDebug, ensureTelemetryConfig, getTelemetryDistinctId, getTelemetrySystem, getTelemetryTerminal, isTelemetryEnabled, sendUsageTelemetry, sendBugReport } from '../src/telemetry.js'
114
114
  import { ensureFavoritesConfig, toFavoriteKey, syncFavoriteFlags, toggleFavoriteModel } from '../src/favorites.js'
115
- import { checkForUpdateDetailed, checkForUpdate, runUpdate, promptUpdateNotification } from '../src/updater.js'
115
+ import { checkForUpdateDetailed, checkForUpdate, runUpdate, promptUpdateNotification, fetchLastReleaseDate } from './updater.js'
116
116
  import { promptApiKey } from '../src/setup.js'
117
117
  import { stripAnsi, maskApiKey, displayWidth, padEndDisplay, tintOverlayLines, keepOverlayTargetVisible, sliceOverlayLines, calculateViewport, sortResultsWithPinnedFavorites, adjustScrollOffset } from '../src/render-helpers.js'
118
118
  import { renderTable, PROVIDER_COLOR } from '../src/render-table.js'
@@ -295,6 +295,8 @@ export async function runApp(cliArgs, config) {
295
295
  // 📖 Dynamic OpenRouter free model discovery — fetch live free models from API
296
296
  // 📖 Replaces static openrouter entries in MODELS with fresh data.
297
297
  // 📖 Fallback: if fetch fails, the static list from sources.js stays intact + warning shown.
298
+ const lastReleaseDate = await fetchLastReleaseDate()
299
+ state.lastReleaseDate = lastReleaseDate
298
300
  const dynamicModels = await fetchOpenRouterFreeModels()
299
301
  if (dynamicModels) {
300
302
  // 📖 Remove all existing openrouter entries from MODELS
@@ -381,6 +383,7 @@ export async function runApp(cliArgs, config) {
381
383
  lastUserActivityAt: now, // 📖 Any keypress refreshes this timer; inactivity can force slow mode.
382
384
  resumeSpeedOnActivity: false, // 📖 Set after idle slowdown so the next activity restarts a 60s speed burst.
383
385
  startupLatestVersion: latestVersion, // 📖 Startup auto-check result reused by the footer banner after "skip update".
386
+ lastReleaseDate: null, // 📖 Human-readable last npm publish date (fetched asynchronously).
384
387
  versionAlertsEnabled: !isDevMode, // 📖 Dev checkouts should not tell contributors to upgrade the global npm package.
385
388
  mode, // 📖 'opencode' or 'openclaw' — controls Enter action
386
389
  tierFilterMode: 0, // 📖 Index into TIER_CYCLE (0=All, 1=S+, 2=S, ...)
@@ -673,10 +676,10 @@ export async function runApp(cliArgs, config) {
673
676
 
674
677
  // 📖 Ensure we always leave alt screen cleanly (Ctrl+C, crash, normal exit)
675
678
  const exit = (code = 0) => {
676
- // 📖 Save cache before exiting so next run starts faster
677
679
  saveCache(state.results, state.pingMode)
678
680
  clearInterval(ticker)
679
681
  clearTimeout(state.pingIntervalObj)
682
+ clearInterval(state.versionRecheckTimer)
680
683
  process.stdout.write(ALT_LEAVE)
681
684
  if (process.stdout.isTTY) {
682
685
  process.stdout.flush && process.stdout.flush()
@@ -743,6 +746,7 @@ export async function runApp(cliArgs, config) {
743
746
  const stopUi = ({ resetRawMode = false } = {}) => {
744
747
  if (ticker) clearInterval(ticker)
745
748
  clearTimeout(state.pingIntervalObj)
749
+ clearInterval(state.versionRecheckTimer)
746
750
  if (onKeyPress) process.stdin.removeListener('keypress', onKeyPress)
747
751
  if (onMouseData) process.stdin.removeListener('data', onMouseData)
748
752
  if (process.stdin.isTTY && resetRawMode) process.stdin.setRawMode(false)
@@ -996,7 +1000,8 @@ export async function runApp(cliArgs, config) {
996
1000
  state.startupLatestVersion,
997
1001
  state.versionAlertsEnabled,
998
1002
  state.favoritesPinnedAndSticky,
999
- state.customTextFilter
1003
+ state.customTextFilter,
1004
+ state.lastReleaseDate
1000
1005
  )
1001
1006
  }
1002
1007
  tableContent = state.commandPaletteFrozenTable
@@ -1030,7 +1035,8 @@ export async function runApp(cliArgs, config) {
1030
1035
  state.startupLatestVersion,
1031
1036
  state.versionAlertsEnabled,
1032
1037
  state.favoritesPinnedAndSticky,
1033
- state.customTextFilter
1038
+ state.customTextFilter,
1039
+ state.lastReleaseDate
1034
1040
  )
1035
1041
  }
1036
1042
 
@@ -1074,7 +1080,7 @@ export async function runApp(cliArgs, config) {
1074
1080
  pinFavorites: state.favoritesPinnedAndSticky,
1075
1081
  })
1076
1082
 
1077
- 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, null, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels, state.widthWarningStartedAt, state.widthWarningDismissed, state.widthWarningShowCount, state.settingsUpdateState, state.settingsUpdateLatestVersion, false, state.startupLatestVersion, state.versionAlertsEnabled, state.favoritesPinnedAndSticky, state.customTextFilter))
1083
+ 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, null, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels, state.widthWarningStartedAt, state.widthWarningDismissed, state.widthWarningShowCount, state.settingsUpdateState, state.settingsUpdateLatestVersion, false, state.startupLatestVersion, state.versionAlertsEnabled, state.favoritesPinnedAndSticky, state.customTextFilter, state.lastReleaseDate))
1078
1084
  if (process.stdout.isTTY) {
1079
1085
  process.stdout.flush && process.stdout.flush()
1080
1086
  }
@@ -1149,6 +1155,19 @@ export async function runApp(cliArgs, config) {
1149
1155
  // 📖 Save cache after initial pings complete for faster next startup
1150
1156
  saveCache(state.results, state.pingMode)
1151
1157
 
1158
+ // 📖 Background version re-check: poll npm registry every 5 minutes.
1159
+ // 📖 If a new version appears (wasn't there at startup), update the banner live.
1160
+ const VERSION_RECHECK_INTERVAL_MS = 5 * 60 * 1000
1161
+ state.versionRecheckTimer = setInterval(async () => {
1162
+ if (isDevMode || !state.versionAlertsEnabled) return
1163
+ try {
1164
+ const fresh = await checkForUpdate()
1165
+ if (fresh) {
1166
+ state.startupLatestVersion = fresh
1167
+ }
1168
+ } catch {}
1169
+ }, VERSION_RECHECK_INTERVAL_MS)
1170
+
1152
1171
  // 📖 Keep interface running forever - user can select anytime or Ctrl+C to exit
1153
1172
  // 📖 The pings continue running in background with dynamic interval
1154
1173
  // 📖 User can press W to decrease interval (faster pings) or = to increase (slower)
@@ -843,6 +843,21 @@ export function createKeyHandler(ctx) {
843
843
  })
844
844
  }
845
845
 
846
+ // 📖 Inject a high-priority update entry at the top when a newer version is known.
847
+ const updateVersion = state.startupLatestVersion
848
+ if (updateVersion && state.versionAlertsEnabled) {
849
+ state.commandPaletteResults.unshift({
850
+ id: 'action-update-now',
851
+ label: `⬆️ UPDATE NOW — v${updateVersion} available (recommended!)`,
852
+ type: 'command',
853
+ depth: 0,
854
+ hasChildren: false,
855
+ isExpanded: false,
856
+ updateVersion,
857
+ keywords: ['update', 'upgrade', 'version', 'install'],
858
+ })
859
+ }
860
+
846
861
  if (state.commandPaletteCursor >= state.commandPaletteResults.length) {
847
862
  state.commandPaletteCursor = Math.max(0, state.commandPaletteResults.length - 1)
848
863
  }
@@ -869,6 +884,14 @@ export function createKeyHandler(ctx) {
869
884
  function executeCommandPaletteEntry(entry) {
870
885
  if (!entry?.id) return
871
886
 
887
+ // 📖 Update action: stop TUI cleanly and run the npm update + relaunch.
888
+ if (entry.id === 'action-update-now' && entry.updateVersion) {
889
+ closeCommandPalette()
890
+ stopUi({ resetRawMode: true })
891
+ runUpdate(entry.updateVersion)
892
+ return
893
+ }
894
+
872
895
  if (entry.id.startsWith('action-set-ping-') && entry.pingMode) {
873
896
  setPingMode(entry.pingMode, 'manual')
874
897
  return
@@ -982,6 +1005,10 @@ export function createKeyHandler(ctx) {
982
1005
  if (!key) return
983
1006
  noteUserActivity()
984
1007
 
1008
+ // 📖 Ctrl+C: always exit immediately, checked FIRST to prevent any other key binding from swallowing it.
1009
+ // 📖 Also handles the raw \x03 byte as a fallback for terminals where readline doesn't set key.ctrl properly.
1010
+ if ((key.ctrl && key.name === 'c') || str === '\x03') { exit(0); return }
1011
+
985
1012
  // 📖 Ctrl+P toggles the command palette from the main table only.
986
1013
  if (key.ctrl && key.name === 'p') {
987
1014
  if (state.commandPaletteOpen) {
@@ -1555,12 +1582,12 @@ export function createKeyHandler(ctx) {
1555
1582
  // 📖 Help overlay: full keyboard navigation + key swallowing while overlay is open.
1556
1583
  if (state.helpVisible) {
1557
1584
  const pageStep = Math.max(1, (state.terminalRows || 1) - 2)
1558
- if (key.name === 'escape' || key.name === 'k') {
1585
+ if (key.name === 'escape' || (key.ctrl && key.name === 'h')) {
1559
1586
  state.helpVisible = false
1560
1587
  return
1561
1588
  }
1562
- if (key.name === 'up') { state.helpScrollOffset = Math.max(0, state.helpScrollOffset - 1); return }
1563
- if (key.name === 'down') { state.helpScrollOffset += 1; return }
1589
+ if (key.name === 'up' || key.name === 'k') { state.helpScrollOffset = Math.max(0, state.helpScrollOffset - 1); return }
1590
+ if (key.name === 'down' || key.name === 'j') { state.helpScrollOffset += 1; return }
1564
1591
  if (key.name === 'pageup') { state.helpScrollOffset = Math.max(0, state.helpScrollOffset - pageStep); return }
1565
1592
  if (key.name === 'pagedown') { state.helpScrollOffset += pageStep; return }
1566
1593
  if (key.name === 'home') { state.helpScrollOffset = 0; return }
@@ -2100,6 +2127,13 @@ export function createKeyHandler(ctx) {
2100
2127
  return
2101
2128
  }
2102
2129
 
2130
+ // 📖 Shift+U: trigger immediate update when a newer version is known.
2131
+ if (key.name === 'u' && key.shift && state.startupLatestVersion && state.versionAlertsEnabled) {
2132
+ stopUi({ resetRawMode: true })
2133
+ runUpdate(state.startupLatestVersion)
2134
+ return
2135
+ }
2136
+
2103
2137
  // 📖 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
2104
2138
  // 📖 T is reserved for tier filter cycling. Y toggles favorites display mode.
2105
2139
  // 📖 X clears the active custom text filter.
@@ -2169,8 +2203,8 @@ export function createKeyHandler(ctx) {
2169
2203
  return
2170
2204
  }
2171
2205
 
2172
- // 📖 Help overlay key: K = toggle help overlay
2173
- if (key.name === 'k') {
2206
+ // 📖 Help overlay key: Ctrl+H = toggle help overlay
2207
+ if (key.ctrl && key.name === 'h') {
2174
2208
  state.helpVisible = !state.helpVisible
2175
2209
  if (state.helpVisible) state.helpScrollOffset = 0
2176
2210
  return
@@ -2194,8 +2228,8 @@ export function createKeyHandler(ctx) {
2194
2228
  return
2195
2229
  }
2196
2230
 
2197
- if (key.name === 'up') {
2198
- // 📖 Main list wrap navigation: top -> bottom on Up.
2231
+ if (key.name === 'up' || key.name === 'k') {
2232
+ // 📖 Main list wrap navigation: top -> bottom on Up / K (vim-style).
2199
2233
  const count = state.visibleSorted.length
2200
2234
  if (count === 0) return
2201
2235
  state.cursor = state.cursor > 0 ? state.cursor - 1 : count - 1
@@ -2203,8 +2237,8 @@ export function createKeyHandler(ctx) {
2203
2237
  return
2204
2238
  }
2205
2239
 
2206
- if (key.name === 'down') {
2207
- // 📖 Main list wrap navigation: bottom -> top on Down.
2240
+ if (key.name === 'down' || key.name === 'j') {
2241
+ // 📖 Main list wrap navigation: bottom -> top on Down / J (vim-style).
2208
2242
  const count = state.visibleSorted.length
2209
2243
  if (count === 0) return
2210
2244
  state.cursor = state.cursor < count - 1 ? state.cursor + 1 : 0
@@ -2212,11 +2246,6 @@ export function createKeyHandler(ctx) {
2212
2246
  return
2213
2247
  }
2214
2248
 
2215
- if (key.name === 'c' && key.ctrl) { // Ctrl+C
2216
- exit(0)
2217
- return
2218
- }
2219
-
2220
2249
  // 📖 Esc can dismiss the narrow-terminal warning immediately without quitting the app.
2221
2250
  if (key.name === 'escape' && state.terminalCols > 0 && state.terminalCols < WIDTH_WARNING_MIN_COLS) {
2222
2251
  state.widthWarningDismissed = true
@@ -2691,6 +2720,12 @@ export function createMouseEventHandler(ctx) {
2691
2720
  if (layout.footerHotkeys && layout.footerHotkeys.length > 0) {
2692
2721
  const zone = layout.footerHotkeys.find(z => y === z.row && x >= z.xStart && x <= z.xEnd)
2693
2722
  if (zone) {
2723
+ // 📖 Update banner click: stop TUI and run the npm update + relaunch.
2724
+ if (zone.key === 'update-click' && state.startupLatestVersion && state.versionAlertsEnabled) {
2725
+ stopUi({ resetRawMode: true })
2726
+ runUpdate(state.startupLatestVersion)
2727
+ return
2728
+ }
2694
2729
  // 📖 Map the footer zone key to a synthetic keypress.
2695
2730
  // 📖 Most are single-character keys; special cases like ctrl+p need special handling.
2696
2731
  if (zone.key === 'ctrl+p') {
package/src/overlays.js CHANGED
@@ -904,7 +904,7 @@ export function createOverlayRenderers(state, deps) {
904
904
  lines.push('')
905
905
  lines.push(` ${heading('Main TUI')}`)
906
906
  lines.push(` ${heading('Navigation')}`)
907
- lines.push(` ${key('↑↓')} Navigate rows`)
907
+ lines.push(` ${key('↑↓ / J/K')} Navigate rows ${hint('(J/K = vim-style scroll)')}`)
908
908
  lines.push(` ${key('Enter')} Select model and launch`)
909
909
  lines.push(` ${hint('If the active CLI is missing, FCM offers a one-click install prompt first.')}`)
910
910
  lines.push('')
@@ -923,7 +923,7 @@ export function createOverlayRenderers(state, deps) {
923
923
  // 📖 Profile system removed - API keys now persist permanently across all sessions
924
924
  lines.push(` ${key('Shift+R')} Reset view settings ${hint('(tier filter, sort, provider filter → defaults)')}`)
925
925
  lines.push(` ${key('N')} Changelog ${hint('(📋 browse all versions, Enter to view details)')}`)
926
- lines.push(` ${key('K')} / ${key('Esc')} Show/hide this help`)
926
+ lines.push(` ${key('Ctrl+H')} / ${key('Esc')} Show/hide this help`)
927
927
  lines.push(` ${key('Ctrl+C')} Exit`)
928
928
  lines.push('')
929
929
  lines.push(` ${heading('Settings (P)')}`)
@@ -52,6 +52,7 @@ import { usagePlaceholderForProvider } from './ping.js'
52
52
  import { calculateViewport, sortResultsWithPinnedFavorites, padEndDisplay, displayWidth } from './render-helpers.js'
53
53
  import { getToolMeta, TOOL_METADATA, TOOL_MODE_ORDER, isModelCompatibleWithTool } from './tool-metadata.js'
54
54
  import { getColumnSpacing } from './ui-config.js'
55
+ import { detectPackageManager, getManualInstallCmd } from './updater.js'
55
56
 
56
57
  const require = createRequire(import.meta.url)
57
58
  const { version: LOCAL_VERSION } = require('../package.json')
@@ -71,6 +72,7 @@ let _lastLayout = {
71
72
  hasAboveIndicator: false, // 📖 whether "... N more above ..." is shown
72
73
  hasBelowIndicator: false, // 📖 whether "... N more below ..." is shown
73
74
  footerHotkeys: [], // 📖 Array of { key, row, xStart, xEnd } for footer click zones
75
+ updateBannerRow: 0, // 📖 1-based terminal row of the fluorescent update banner (0 = none)
74
76
  }
75
77
  export function getLastLayout() { return _lastLayout }
76
78
 
@@ -102,7 +104,7 @@ export const PROVIDER_COLOR = new Proxy({}, {
102
104
  })
103
105
 
104
106
  // ─── renderTable: mode param controls footer hint text (opencode vs openclaw) ─────────
105
- 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, legacyStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, widthWarningShowCount = 0, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, legacyFlag = false, startupLatestVersion = null, versionAlertsEnabled = true, favoritesPinnedAndSticky = false, customTextFilter = null) {
107
+ 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, legacyStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, widthWarningShowCount = 0, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, legacyFlag = false, startupLatestVersion = null, versionAlertsEnabled = true, favoritesPinnedAndSticky = false, customTextFilter = null, lastReleaseDate = null) {
106
108
  // 📖 Filter out hidden models for display
107
109
  const visibleResults = results.filter(r => !r.hidden)
108
110
 
@@ -741,7 +743,9 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
741
743
  { text: ' • ', key: null },
742
744
  { text: 'P Settings', key: 'p' },
743
745
  { text: ' • ', key: null },
744
- { text: 'K Help', key: 'k' },
746
+ { text: 'J/K Navigate', key: null },
747
+ { text: ' • ', key: null },
748
+ { text: 'Ctrl+H Help', key: 'ctrl+h' },
745
749
  ]
746
750
  const footerRow1 = lines.length + 1 // 📖 1-based terminal row (line hasn't been pushed yet)
747
751
  let xPos = 1
@@ -769,7 +773,9 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
769
773
  themeColors.dim(` • `) +
770
774
  hotkey('P', ' Settings') +
771
775
  themeColors.dim(` • `) +
772
- hotkey('K', ' Help')
776
+ themeColors.dim('J/K Navigate') +
777
+ themeColors.dim(` • `) +
778
+ themeColors.dim('Ctrl+H Help')
773
779
  )
774
780
 
775
781
  // 📖 Line 2: command palette, recommend, feedback, theme
@@ -803,7 +809,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
803
809
  hotkey('G', ' Theme') + themeColors.dim(` • `) +
804
810
  hotkey('I', ' Feedback, bugs & requests')
805
811
  )
806
- // 📖 Proxy status is now shown via the J badge in line 2 above — no need for a dedicated line
812
+ // 📖 Proxy status is now shown via the badge in line 2 above — no need for a dedicated line
807
813
  const footerLine =
808
814
  themeColors.footerLove(' Made with 💖 & ☕ by \x1b]8;;https://github.com/vava-nessa\x1b\\vava-nessa\x1b]8;;\x1b\\') +
809
815
  themeColors.dim(' • ') +
@@ -823,12 +829,17 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
823
829
  lines.push(footerLine)
824
830
 
825
831
  if (versionStatus.isOutdated) {
826
- const outdatedMessage = ` Update available: v${LOCAL_VERSION} -> v${versionStatus.latestVersion}. If auto-update did not complete, run: npm install -g free-coding-models@latest`
832
+ const updateMsg = ` 🚀⬆️ UPDATE AVAILABLE v${LOCAL_VERSION} v${versionStatus.latestVersion} • Click here or press Shift+U to update 🚀⬆️ `
827
833
  const paddedBanner = terminalCols > 0
828
- ? outdatedMessage + ' '.repeat(Math.max(0, terminalCols - displayWidth(outdatedMessage)))
829
- : outdatedMessage
830
- // 📖 Reserve a dedicated full-width red row so the warning cannot blend into the footer links.
831
- lines.push(chalk.bgRed.white.bold(paddedBanner))
834
+ ? updateMsg + ' '.repeat(Math.max(0, terminalCols - displayWidth(updateMsg)))
835
+ : updateMsg
836
+ const fluoGreenBanner = chalk.bgRgb(57, 255, 20).rgb(0, 0, 0).bold(paddedBanner)
837
+ const updateBannerRow = lines.length + 1
838
+ _lastLayout.updateBannerRow = updateBannerRow
839
+ footerHotkeys.push({ key: 'update-click', row: updateBannerRow, xStart: 1, xEnd: Math.max(terminalCols, displayWidth(updateMsg)) })
840
+ lines.push(fluoGreenBanner)
841
+ } else {
842
+ _lastLayout.updateBannerRow = 0
832
843
  }
833
844
 
834
845
  // 📖 Final footer line: changelog + optional active text-filter badge + exit hint.
@@ -872,13 +883,18 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
872
883
 
873
884
  _lastLayout.footerHotkeys = footerHotkeys
874
885
 
886
+ const releaseLabel = lastReleaseDate
887
+ ? chalk.rgb(255, 182, 193)(`Last release: ${lastReleaseDate}`)
888
+ : ''
889
+
875
890
  lines.push(
876
891
  ' ' + themeColors.hotkey('N') + themeColors.dim(' Changelog') +
877
892
  (filterBadge
878
893
  ? themeColors.dim(' • ') + filterBadge
879
894
  : '') +
880
895
  themeColors.dim(' • ') +
881
- themeColors.dim('Ctrl+C Exit')
896
+ themeColors.dim('Ctrl+C Exit') +
897
+ (releaseLabel ? themeColors.dim(' • ') + releaseLabel : '')
882
898
  )
883
899
 
884
900
  // 📖 Append \x1b[K (erase to EOL) to each line so leftover chars from previous
package/src/updater.js CHANGED
@@ -12,11 +12,10 @@
12
12
  * - `checkForUpdate()` — thin backward-compatible wrapper used at startup for the
13
13
  * auto-update guard. Returns `latestVersion` (string) or `null`.
14
14
  *
15
- * - `runUpdate(latestVersion)` — runs `npm i -g free-coding-models@<version> --prefer-online`,
16
- * retrying with `sudo` on EACCES/EPERM. On success, relaunches the process with the
17
- * same argv. On failure, prints manual instructions and exits with code 1.
18
- * Uses `require('child_process').execSync` inline because ESM dynamic import is async
19
- * but `execSync` must block to give `stdio: 'inherit'` feedback in the terminal.
15
+ * - `runUpdate(latestVersion)` — detects the active package manager (npm/bun/pnpm/yarn),
16
+ * runs the correct global install command, retrying with `sudo` on EACCES/EPERM.
17
+ * On success, relaunches the process with the same argv. On failure, prints manual
18
+ * instructions (using the correct PM command) and exits with code 1.
20
19
  *
21
20
  * - `promptUpdateNotification(latestVersion)` — renders a small centered interactive menu
22
21
  * that lets the user choose: Update Now / Read Changelogs / Continue without update.
@@ -29,14 +28,21 @@
29
28
  * can be imported independently from the bin entry point.
30
29
  * - The auto-update flow in `main()` skips update if `isDevMode` is detected (presence of
31
30
  * a `.git` directory next to the package root) to avoid an infinite update loop in dev.
31
+ * - `detectPackageManager()` checks the install path, script path, and runtime binary
32
+ * to determine which package manager (npm/bun/pnpm/yarn) owns the installation.
33
+ * All install commands, permission probes, and error messages use the detected PM.
32
34
  *
33
35
  * @functions
36
+ * → detectPackageManager() — Detect which PM owns the current installation
37
+ * → getInstallArgs(pm, version) — Build correct { bin, args } per package manager
38
+ * → getManualInstallCmd(pm, version) — Human-readable install command string for error messages
34
39
  * → checkForUpdateDetailed() — Fetch npm latest with explicit error info
35
40
  * → checkForUpdate() — Startup wrapper, returns version string or null
36
- * → runUpdate(latestVersion) — Install new version via npm global + relaunch
41
+ * → runUpdate(latestVersion) — Install new version via detected PM + relaunch
37
42
  * → promptUpdateNotification(version) — Interactive pre-TUI update menu
38
43
  *
39
44
  * @exports
45
+ * detectPackageManager, getInstallArgs, getManualInstallCmd,
40
46
  * checkForUpdateDetailed, checkForUpdate, runUpdate, promptUpdateNotification
41
47
  *
42
48
  * @see bin/free-coding-models.js — calls checkForUpdate() at startup and runUpdate() on confirm
@@ -51,6 +57,50 @@ const readline = require('readline')
51
57
  const pkg = require('../package.json')
52
58
  const LOCAL_VERSION = pkg.version
53
59
 
60
+ /**
61
+ * 📖 detectPackageManager: figure out which package manager owns the current installation.
62
+ * 📖 Checks import.meta.url (package install path), process.argv[1] (script entry),
63
+ * 📖 and process.execPath (runtime binary) for signatures of bun, pnpm, or yarn.
64
+ * 📖 Falls back to 'npm' when no other signature is found.
65
+ * @returns {'npm' | 'bun' | 'pnpm' | 'yarn'}
66
+ */
67
+ export function detectPackageManager() {
68
+ const sources = [import.meta.url, process.argv[1] || '', process.execPath || '']
69
+ const combined = sources.join(' ').toLowerCase()
70
+ if (combined.includes('.bun')) return 'bun'
71
+ if (combined.includes('pnpm')) return 'pnpm'
72
+ if (combined.includes('yarn')) return 'yarn'
73
+ return 'npm'
74
+ }
75
+
76
+ /**
77
+ * 📖 getInstallArgs: return the correct binary and argument list for a given PM.
78
+ * 📖 Each PM has different syntax for global install — this normalises them.
79
+ * @param {'npm' | 'bun' | 'pnpm' | 'yarn'} pm
80
+ * @param {string} version
81
+ * @returns {{ bin: string, args: string[] }}
82
+ */
83
+ export function getInstallArgs(pm, version) {
84
+ const pkg = `free-coding-models@${version}`
85
+ switch (pm) {
86
+ case 'bun': return { bin: 'bun', args: ['add', '-g', pkg] }
87
+ case 'pnpm': return { bin: 'pnpm', args: ['add', '-g', pkg] }
88
+ case 'yarn': return { bin: 'yarn', args: ['global', 'add', pkg] }
89
+ default: return { bin: 'npm', args: ['i', '-g', pkg, '--prefer-online'] }
90
+ }
91
+ }
92
+
93
+ /**
94
+ * 📖 getManualInstallCmd: human-readable command string for error / fallback messages.
95
+ * @param {'npm' | 'bun' | 'pnpm' | 'yarn'} pm
96
+ * @param {string} version
97
+ * @returns {string}
98
+ */
99
+ export function getManualInstallCmd(pm, version) {
100
+ const { bin, args } = getInstallArgs(pm, version)
101
+ return `${bin} ${args.join(' ')}`
102
+ }
103
+
54
104
  /**
55
105
  * 📖 checkForUpdateDetailed: Fetch npm latest version with explicit error details.
56
106
  * 📖 Used by settings manual-check flow to display meaningful status in the UI.
@@ -79,26 +129,67 @@ export async function checkForUpdate() {
79
129
  }
80
130
 
81
131
  /**
82
- * 📖 detectGlobalInstallPermission: check whether npm global install paths are writable.
83
- * 📖 On sudo-based systems (Arch, many Linux/macOS setups), `npm i -g` will fail with EACCES
84
- * 📖 if the current user cannot write to the resolved global root/prefix.
85
- * 📖 We probe those paths ahead of time so the updater can go straight to an interactive
86
- * 📖 `sudo npm i -g ...` instead of printing a wall of permission errors first.
132
+ * 📖 fetchLastReleaseDate: Get the human-readable publish date of the latest npm release.
133
+ * 📖 Used in the TUI footer to show users how fresh the package is.
134
+ * @returns {Promise<string|null>} e.g. "Mar 27, 2026, 09:42 PM" or null on failure
135
+ */
136
+ export async function fetchLastReleaseDate() {
137
+ try {
138
+ const res = await fetch('https://registry.npmjs.org/free-coding-models', { signal: AbortSignal.timeout(5000) })
139
+ if (!res.ok) return null
140
+ const data = await res.json()
141
+ const timeMap = data?.time
142
+ if (!timeMap) return null
143
+ const latestKey = data?.['dist-tags']?.latest
144
+ if (!latestKey || !timeMap[latestKey]) return null
145
+ const d = new Date(timeMap[latestKey])
146
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
147
+ const hh = d.getHours()
148
+ const mm = String(d.getMinutes()).padStart(2, '0')
149
+ const ampm = hh >= 12 ? 'PM' : 'AM'
150
+ const h12 = hh % 12 || 12
151
+ return `${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}, ${h12}:${mm} ${ampm}`
152
+ } catch {
153
+ return null
154
+ }
155
+ }
156
+
157
+ /**
158
+ * 📖 detectGlobalInstallPermission: check whether the detected PM's global install paths are writable.
159
+ * 📖 Bun installs to ~/.bun/install/global/ (always user-writable) so sudo is never needed.
160
+ * 📖 For npm/pnpm/yarn we probe their global root/prefix paths and check writability.
161
+ * @param {'npm' | 'bun' | 'pnpm' | 'yarn'} pm
87
162
  * @returns {{ needsSudo: boolean, checkedPath: string|null }}
88
163
  */
89
- function detectGlobalInstallPermission() {
164
+ function detectGlobalInstallPermission(pm) {
165
+ if (pm === 'bun') {
166
+ return { needsSudo: false, checkedPath: null }
167
+ }
168
+
90
169
  const { execFileSync } = require('child_process')
91
170
  const candidates = []
92
171
 
93
- try {
94
- const npmRoot = execFileSync('npm', ['root', '-g'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
95
- if (npmRoot) candidates.push(npmRoot)
96
- } catch {}
172
+ if (pm === 'pnpm') {
173
+ try {
174
+ const root = execFileSync('pnpm', ['root', '-g'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
175
+ if (root) candidates.push(root)
176
+ } catch {}
177
+ } else if (pm === 'yarn') {
178
+ try {
179
+ const dir = execFileSync('yarn', ['global', 'dir'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
180
+ if (dir) candidates.push(dir)
181
+ } catch {}
182
+ } else {
183
+ try {
184
+ const npmRoot = execFileSync('npm', ['root', '-g'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
185
+ if (npmRoot) candidates.push(npmRoot)
186
+ } catch {}
97
187
 
98
- try {
99
- const npmPrefix = execFileSync('npm', ['prefix', '-g'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
100
- if (npmPrefix) candidates.push(npmPrefix)
101
- } catch {}
188
+ try {
189
+ const npmPrefix = execFileSync('npm', ['prefix', '-g'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
190
+ if (npmPrefix) candidates.push(npmPrefix)
191
+ } catch {}
192
+ }
102
193
 
103
194
  for (const candidate of candidates) {
104
195
  try {
@@ -162,20 +253,21 @@ function relaunchCurrentProcess() {
162
253
  }
163
254
 
164
255
  /**
165
- * 📖 installUpdateCommand: run npm global install, optionally prefixed with sudo.
256
+ * 📖 installUpdateCommand: run global install using the detected package manager, optionally prefixed with sudo.
166
257
  * @param {string} latestVersion
167
258
  * @param {boolean} useSudo
168
259
  */
169
260
  function installUpdateCommand(latestVersion, useSudo) {
170
261
  const { execFileSync } = require('child_process')
171
- const npmArgs = ['i', '-g', `free-coding-models@${latestVersion}`, '--prefer-online']
262
+ const pm = detectPackageManager()
263
+ const { bin, args } = getInstallArgs(pm, latestVersion)
172
264
 
173
265
  if (useSudo) {
174
- execFileSync('sudo', ['npm', ...npmArgs], { stdio: 'inherit', shell: false })
266
+ execFileSync('sudo', [bin, ...args], { stdio: 'inherit', shell: false })
175
267
  return
176
268
  }
177
269
 
178
- execFileSync('npm', npmArgs, { stdio: 'inherit', shell: false })
270
+ execFileSync(bin, args, { stdio: 'inherit', shell: false })
179
271
  }
180
272
 
181
273
  /**
@@ -189,19 +281,17 @@ export function runUpdate(latestVersion) {
189
281
  console.log(chalk.bold.cyan(' ⬆ Updating free-coding-models to v' + latestVersion + '...'))
190
282
  console.log()
191
283
 
192
- const { needsSudo, checkedPath } = detectGlobalInstallPermission()
284
+ const pm = detectPackageManager()
285
+ const { needsSudo, checkedPath } = detectGlobalInstallPermission(pm)
193
286
  const sudoAvailable = process.platform !== 'win32' && hasSudoCommand()
194
287
 
195
288
  if (needsSudo && checkedPath && sudoAvailable) {
196
- console.log(chalk.yellow(` ⚠ Global npm path is not writable: ${checkedPath}`))
289
+ console.log(chalk.yellow(` ⚠ Global ${pm} path is not writable: ${checkedPath}`))
197
290
  console.log(chalk.dim(' Re-running update with sudo so you can enter your password once.'))
198
291
  console.log()
199
292
  }
200
293
 
201
294
  try {
202
- // 📖 Force install from npm registry (ignore local cache).
203
- // 📖 If the global install path is not writable, go straight to sudo instead of
204
- // 📖 letting npm print a long EACCES stack first.
205
295
  installUpdateCommand(latestVersion, needsSudo && sudoAvailable)
206
296
  console.log()
207
297
  console.log(chalk.green(` ✅ Update complete! Version ${latestVersion} installed.`))
@@ -209,9 +299,10 @@ export function runUpdate(latestVersion) {
209
299
  relaunchCurrentProcess()
210
300
  return
211
301
  } catch (err) {
302
+ const manualCmd = getManualInstallCmd(pm, latestVersion)
212
303
  console.log()
213
304
  if (isPermissionError(err) && !needsSudo && sudoAvailable) {
214
- console.log(chalk.yellow(' ⚠ Permission denied during npm global install. Retrying with sudo...'))
305
+ console.log(chalk.yellow(` ⚠ Permission denied during ${pm} global install. Retrying with sudo...`))
215
306
  console.log()
216
307
  try {
217
308
  installUpdateCommand(latestVersion, true)
@@ -223,15 +314,15 @@ export function runUpdate(latestVersion) {
223
314
  } catch {
224
315
  console.log()
225
316
  console.log(chalk.red(' ✖ Update failed even with sudo. Try manually:'))
226
- console.log(chalk.dim(' sudo npm i -g free-coding-models@' + latestVersion))
317
+ console.log(chalk.dim(` sudo ${manualCmd}`))
227
318
  console.log()
228
319
  }
229
320
  } else if (isPermissionError(err) && !sudoAvailable && process.platform !== 'win32') {
230
321
  console.log(chalk.red(' ✖ Update failed due to permissions and `sudo` is not available in PATH.'))
231
- console.log(chalk.dim(` Try manually with your system's privilege escalation tool for free-coding-models@${latestVersion}.`))
322
+ console.log(chalk.dim(` Try manually: ${manualCmd}`))
232
323
  console.log()
233
324
  } else {
234
- console.log(chalk.red(' ✖ Update failed. Try manually: npm i -g free-coding-models@' + latestVersion))
325
+ console.log(chalk.red(` ✖ Update failed. Try manually: ${manualCmd}`))
235
326
  console.log()
236
327
  }
237
328
  }
@@ -264,7 +355,7 @@ export async function promptUpdateNotification(latestVersion) {
264
355
  {
265
356
  label: 'Continue without update',
266
357
  icon: '▶',
267
- description: 'Use current version',
358
+ description: ' You will be reminded again in the TUI',
268
359
  },
269
360
  ]
270
361
 
@@ -278,8 +369,8 @@ export async function promptUpdateNotification(latestVersion) {
278
369
  const centerPad = ' '.repeat(Math.max(0, Math.floor((terminalWidth - maxWidth) / 2)))
279
370
 
280
371
  console.log()
281
- console.log(centerPad + chalk.bold.red(' UPDATE AVAILABLE'))
282
- console.log(centerPad + chalk.red(` Version ${latestVersion} is ready to install`))
372
+ console.log(centerPad + chalk.bold.rgb(57, 255, 20)(' 🚀⬆️ UPDATE AVAILABLE'))
373
+ console.log(centerPad + chalk.rgb(57, 255, 20)(` Version ${latestVersion} is ready to install`))
283
374
  console.log()
284
375
  console.log(centerPad + chalk.bold(' ⚡ Free Coding Models') + chalk.dim(` v${LOCAL_VERSION}`))
285
376
  console.log()