free-coding-models 0.3.25 → 0.3.28

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.
@@ -35,6 +35,8 @@ import { getLastLayout, COLUMN_SORT_MAP } from './render-table.js'
35
35
  import { cycleThemeSetting, detectActiveTheme } from './theme.js'
36
36
  import { buildCommandPaletteTree, flattenCommandTree, filterCommandPaletteEntries } from './command-palette.js'
37
37
  import { WIDTH_WARNING_MIN_COLS } from './constants.js'
38
+ import { scanAllToolConfigs, softDeleteModel } from './installed-models-manager.js'
39
+ import { startExternalTool } from './tool-launchers.js'
38
40
 
39
41
  // 📖 Some providers need an explicit probe model because the first catalog entry
40
42
  // 📖 is not guaranteed to be accepted by their chat endpoint.
@@ -687,6 +689,21 @@ export function createKeyHandler(ctx) {
687
689
  state.changelogSelectedVersion = null
688
690
  }
689
691
 
692
+ function openInstalledModelsOverlay() {
693
+ state.installedModelsOpen = true
694
+ state.installedModelsCursor = 0
695
+ state.installedModelsScrollOffset = 0
696
+ state.installedModelsErrorMsg = 'Scanning...'
697
+
698
+ try {
699
+ const results = scanAllToolConfigs()
700
+ state.installedModelsData = results
701
+ state.installedModelsErrorMsg = null
702
+ } catch (err) {
703
+ state.installedModelsErrorMsg = err.message || 'Failed to scan tool configs'
704
+ }
705
+ }
706
+
690
707
  function cycleToolMode() {
691
708
  const modeOrder = getToolModeOrder()
692
709
  const currentIndex = modeOrder.indexOf(state.mode)
@@ -774,6 +791,7 @@ export function createKeyHandler(ctx) {
774
791
  return state.settingsOpen
775
792
  || state.installEndpointsOpen
776
793
  || state.toolInstallPromptOpen
794
+ || state.installedModelsOpen
777
795
  || state.recommendOpen
778
796
  || state.feedbackOpen
779
797
  || state.helpVisible
@@ -825,6 +843,21 @@ export function createKeyHandler(ctx) {
825
843
  })
826
844
  }
827
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
+
828
861
  if (state.commandPaletteCursor >= state.commandPaletteResults.length) {
829
862
  state.commandPaletteCursor = Math.max(0, state.commandPaletteResults.length - 1)
830
863
  }
@@ -851,6 +884,14 @@ export function createKeyHandler(ctx) {
851
884
  function executeCommandPaletteEntry(entry) {
852
885
  if (!entry?.id) return
853
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
+
854
895
  if (entry.id.startsWith('action-set-ping-') && entry.pingMode) {
855
896
  setPingMode(entry.pingMode, 'manual')
856
897
  return
@@ -943,6 +984,7 @@ export function createKeyHandler(ctx) {
943
984
  case 'open-feedback': return openFeedbackOverlay()
944
985
  case 'open-recommend': return openRecommendOverlay()
945
986
  case 'open-install-endpoints': return openInstallEndpointsOverlay()
987
+ case 'open-installed-models': return openInstalledModelsOverlay()
946
988
  case 'action-cycle-theme': return cycleGlobalTheme()
947
989
  case 'action-cycle-tool-mode': return cycleToolMode()
948
990
  case 'action-cycle-ping-mode': {
@@ -963,6 +1005,10 @@ export function createKeyHandler(ctx) {
963
1005
  if (!key) return
964
1006
  noteUserActivity()
965
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
+
966
1012
  // 📖 Ctrl+P toggles the command palette from the main table only.
967
1013
  if (key.ctrl && key.name === 'p') {
968
1014
  if (state.commandPaletteOpen) {
@@ -1289,6 +1335,104 @@ export function createKeyHandler(ctx) {
1289
1335
  return
1290
1336
  }
1291
1337
 
1338
+ // ─── Installed Models overlay keyboard handling ───────────────────────────
1339
+ if (state.installedModelsOpen) {
1340
+ if (key.ctrl && key.name === 'c') { exit(0); return }
1341
+
1342
+ const scanResults = state.installedModelsData || []
1343
+ let maxIndex = 0
1344
+ for (const toolResult of scanResults) {
1345
+ maxIndex += 1
1346
+ maxIndex += toolResult.models.length
1347
+ }
1348
+ if (maxIndex > 0) maxIndex--
1349
+
1350
+ const pageStep = Math.max(1, (state.terminalRows || 1) - 4)
1351
+
1352
+ if (key.name === 'up' || (key.shift && key.name === 'tab')) {
1353
+ state.installedModelsCursor = Math.max(0, state.installedModelsCursor - 1)
1354
+ return
1355
+ }
1356
+ if (key.name === 'down' || key.name === 'tab') {
1357
+ state.installedModelsCursor = Math.min(maxIndex, state.installedModelsCursor + 1)
1358
+ return
1359
+ }
1360
+ if (key.name === 'pageup') {
1361
+ state.installedModelsCursor = Math.max(0, state.installedModelsCursor - pageStep)
1362
+ return
1363
+ }
1364
+ if (key.name === 'pagedown') {
1365
+ state.installedModelsCursor = Math.min(maxIndex, state.installedModelsCursor + pageStep)
1366
+ return
1367
+ }
1368
+ if (key.name === 'home') {
1369
+ state.installedModelsCursor = 0
1370
+ return
1371
+ }
1372
+ if (key.name === 'end') {
1373
+ state.installedModelsCursor = maxIndex
1374
+ return
1375
+ }
1376
+
1377
+ if (key.name === 'escape') {
1378
+ state.installedModelsOpen = false
1379
+ state.installedModelsCursor = 0
1380
+ return
1381
+ }
1382
+
1383
+ if (key.name === 'return') {
1384
+ let currentIdx = 0
1385
+ for (const toolResult of scanResults) {
1386
+ if (currentIdx === state.installedModelsCursor) {
1387
+ return
1388
+ }
1389
+ currentIdx++
1390
+ for (const model of toolResult.models) {
1391
+ if (currentIdx === state.installedModelsCursor) {
1392
+ const selectedModel = {
1393
+ modelId: model.modelId,
1394
+ providerKey: model.providerKey,
1395
+ label: model.label,
1396
+ }
1397
+
1398
+ state.installedModelsOpen = false
1399
+ await startExternalTool(toolResult.toolMode, selectedModel, state.config)
1400
+ return
1401
+ }
1402
+ currentIdx++
1403
+ }
1404
+ }
1405
+ }
1406
+
1407
+ if (key.name === 'd') {
1408
+ let currentIdx = 0
1409
+ for (const toolResult of scanResults) {
1410
+ currentIdx++
1411
+ for (const model of toolResult.models) {
1412
+ if (currentIdx === state.installedModelsCursor) {
1413
+ softDeleteModel(toolResult.toolMode, model.modelId)
1414
+ .then((result) => {
1415
+ if (result.success) {
1416
+ openInstalledModelsOverlay()
1417
+ } else {
1418
+ state.installedModelsErrorMsg = `Failed to disable: ${result.error}`
1419
+ setTimeout(() => { state.installedModelsErrorMsg = null }, 3000)
1420
+ }
1421
+ })
1422
+ .catch((err) => {
1423
+ state.installedModelsErrorMsg = `Failed to disable: ${err.message}`
1424
+ setTimeout(() => { state.installedModelsErrorMsg = null }, 3000)
1425
+ })
1426
+ return
1427
+ }
1428
+ currentIdx++
1429
+ }
1430
+ }
1431
+ }
1432
+
1433
+ return
1434
+ }
1435
+
1292
1436
  // 📖 Incompatible fallback overlay: ↑↓ navigate across tool + model sections, Enter confirms, Esc cancels.
1293
1437
  // 📖 Cursor is a flat index: 0..N-1 = compatible tools, N..N+M-1 = similar models.
1294
1438
  if (state.incompatibleFallbackOpen) {
@@ -1438,12 +1582,12 @@ export function createKeyHandler(ctx) {
1438
1582
  // 📖 Help overlay: full keyboard navigation + key swallowing while overlay is open.
1439
1583
  if (state.helpVisible) {
1440
1584
  const pageStep = Math.max(1, (state.terminalRows || 1) - 2)
1441
- if (key.name === 'escape' || key.name === 'k') {
1585
+ if (key.name === 'escape' || (key.ctrl && key.name === 'h')) {
1442
1586
  state.helpVisible = false
1443
1587
  return
1444
1588
  }
1445
- if (key.name === 'up') { state.helpScrollOffset = Math.max(0, state.helpScrollOffset - 1); return }
1446
- 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 }
1447
1591
  if (key.name === 'pageup') { state.helpScrollOffset = Math.max(0, state.helpScrollOffset - pageStep); return }
1448
1592
  if (key.name === 'pagedown') { state.helpScrollOffset += pageStep; return }
1449
1593
  if (key.name === 'home') { state.helpScrollOffset = 0; return }
@@ -1983,6 +2127,13 @@ export function createKeyHandler(ctx) {
1983
2127
  return
1984
2128
  }
1985
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
+
1986
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
1987
2138
  // 📖 T is reserved for tier filter cycling. Y toggles favorites display mode.
1988
2139
  // 📖 X clears the active custom text filter.
@@ -2052,8 +2203,8 @@ export function createKeyHandler(ctx) {
2052
2203
  return
2053
2204
  }
2054
2205
 
2055
- // 📖 Help overlay key: K = toggle help overlay
2056
- if (key.name === 'k') {
2206
+ // 📖 Help overlay key: Ctrl+H = toggle help overlay
2207
+ if (key.ctrl && key.name === 'h') {
2057
2208
  state.helpVisible = !state.helpVisible
2058
2209
  if (state.helpVisible) state.helpScrollOffset = 0
2059
2210
  return
@@ -2077,8 +2228,8 @@ export function createKeyHandler(ctx) {
2077
2228
  return
2078
2229
  }
2079
2230
 
2080
- if (key.name === 'up') {
2081
- // 📖 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).
2082
2233
  const count = state.visibleSorted.length
2083
2234
  if (count === 0) return
2084
2235
  state.cursor = state.cursor > 0 ? state.cursor - 1 : count - 1
@@ -2086,8 +2237,8 @@ export function createKeyHandler(ctx) {
2086
2237
  return
2087
2238
  }
2088
2239
 
2089
- if (key.name === 'down') {
2090
- // 📖 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).
2091
2242
  const count = state.visibleSorted.length
2092
2243
  if (count === 0) return
2093
2244
  state.cursor = state.cursor < count - 1 ? state.cursor + 1 : 0
@@ -2095,11 +2246,6 @@ export function createKeyHandler(ctx) {
2095
2246
  return
2096
2247
  }
2097
2248
 
2098
- if (key.name === 'c' && key.ctrl) { // Ctrl+C
2099
- exit(0)
2100
- return
2101
- }
2102
-
2103
2249
  // 📖 Esc can dismiss the narrow-terminal warning immediately without quitting the app.
2104
2250
  if (key.name === 'escape' && state.terminalCols > 0 && state.terminalCols < WIDTH_WARNING_MIN_COLS) {
2105
2251
  state.widthWarningDismissed = true
@@ -2327,6 +2473,22 @@ export function createMouseEventHandler(ctx) {
2327
2473
  }
2328
2474
  return
2329
2475
  }
2476
+ if (state.installedModelsOpen) {
2477
+ const scanResults = state.installedModelsData || []
2478
+ let maxIndex = 0
2479
+ for (const toolResult of scanResults) {
2480
+ maxIndex += 1
2481
+ maxIndex += toolResult.models.length
2482
+ }
2483
+ if (maxIndex > 0) maxIndex--
2484
+
2485
+ if (evt.type === 'scroll-up') {
2486
+ state.installedModelsCursor = Math.max(0, (state.installedModelsCursor || 0) - 1)
2487
+ } else {
2488
+ state.installedModelsCursor = Math.min(maxIndex, (state.installedModelsCursor || 0) + 1)
2489
+ }
2490
+ return
2491
+ }
2330
2492
 
2331
2493
  // 📖 Main table scroll: move cursor up/down with wrap-around
2332
2494
  const count = state.visibleSorted.length
@@ -2402,6 +2564,11 @@ export function createMouseEventHandler(ctx) {
2402
2564
  return
2403
2565
  }
2404
2566
 
2567
+ if (state.installedModelsOpen) {
2568
+ state.installedModelsOpen = false
2569
+ return
2570
+ }
2571
+
2405
2572
  if (state.incompatibleFallbackOpen) {
2406
2573
  // 📖 Incompatible fallback: click closes
2407
2574
  state.incompatibleFallbackOpen = false
@@ -2553,6 +2720,12 @@ export function createMouseEventHandler(ctx) {
2553
2720
  if (layout.footerHotkeys && layout.footerHotkeys.length > 0) {
2554
2721
  const zone = layout.footerHotkeys.find(z => y === z.row && x >= z.xStart && x <= z.xEnd)
2555
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
+ }
2556
2729
  // 📖 Map the footer zone key to a synthetic keypress.
2557
2730
  // 📖 Most are single-character keys; special cases like ctrl+p need special handling.
2558
2731
  if (zone.key === 'ctrl+p') {
package/src/overlays.js CHANGED
@@ -479,6 +479,114 @@ export function createOverlayRenderers(state, deps) {
479
479
  return cleared.join('\n')
480
480
  }
481
481
 
482
+ // ─── Installed Models Manager overlay renderer ─────────────────────────────
483
+ // 📖 renderInstalledModels displays all models configured in external tools
484
+ // 📖 Shows tool configs, model lists, and provides actions (Launch, Disable, Reinstall)
485
+ function renderInstalledModels() {
486
+ const EL = '\x1b[K'
487
+ const lines = []
488
+ const cursorLineByRow = {}
489
+
490
+ lines.push('')
491
+ lines.push(` ${themeColors.accent('🚀')} ${themeColors.accentBold('free-coding-models')} ${themeColors.dim(`v${LOCAL_VERSION}`)}`)
492
+ lines.push(` ${themeColors.textBold('🗂️ Installed Models Manager')}`)
493
+ lines.push('')
494
+ lines.push(themeColors.dim(' — models configured in your tools'))
495
+
496
+ if (state.installedModelsErrorMsg) {
497
+ lines.push(` ${themeColors.warning(state.installedModelsErrorMsg)}`)
498
+ }
499
+
500
+ if (state.installedModelsErrorMsg === 'Scanning...') {
501
+ lines.push(themeColors.dim(' Scanning tool configs, please wait...'))
502
+ const targetLine = 5
503
+ state.installedModelsScrollOffset = keepOverlayTargetVisible(
504
+ state.installedModelsScrollOffset,
505
+ targetLine,
506
+ lines.length,
507
+ state.terminalRows
508
+ )
509
+ const { visible, offset } = sliceOverlayLines(lines, state.installedModelsScrollOffset, state.terminalRows)
510
+ state.installedModelsScrollOffset = offset
511
+
512
+ overlayLayout.installedModelsCursorToLine = cursorLineByRow
513
+ overlayLayout.installedModelsScrollOffset = offset
514
+
515
+ const tintedLines = tintOverlayLines(visible, themeColors.overlayBgSettings, state.terminalCols)
516
+ const cleared = tintedLines.map((l) => l + EL)
517
+ return cleared.join('\n')
518
+ }
519
+
520
+ lines.push('')
521
+
522
+ const scanResults = state.installedModelsData || []
523
+
524
+ if (scanResults.length === 0) {
525
+ lines.push(themeColors.dim(' No tool configs found.'))
526
+ lines.push(themeColors.dim(' Install a tool (Goose, Crush, Aider, etc.) to get started.'))
527
+ } else {
528
+ let globalIdx = 0
529
+
530
+ for (const toolResult of scanResults) {
531
+ const { toolMode, toolLabel, toolEmoji, configPath, isValid, hasManagedMarker, models } = toolResult
532
+
533
+ lines.push('')
534
+ const isCursor = globalIdx === state.installedModelsCursor
535
+
536
+ const statusIcon = isValid ? themeColors.successBold('✅') : themeColors.errorBold('⚠️')
537
+ const toolHeader = `${bullet(isCursor)}${toolEmoji} ${themeColors.textBold(toolLabel)} ${statusIcon}`
538
+ cursorLineByRow[globalIdx++] = lines.length
539
+ lines.push(isCursor ? themeColors.bgCursor(toolHeader) : toolHeader)
540
+
541
+ const configShortPath = configPath.replace(process.env.HOME || homedir(), '~')
542
+ lines.push(` ${themeColors.dim(configShortPath)}`)
543
+
544
+ if (!isValid) {
545
+ lines.push(themeColors.dim(' ⚠️ Config invalid or missing'))
546
+ } else if (models.length === 0) {
547
+ lines.push(themeColors.dim(' No models configured'))
548
+ } else {
549
+ const managedBadge = hasManagedMarker ? themeColors.info('• Managed by FCM') : themeColors.dim('• External config')
550
+ lines.push(` ${themeColors.success(`${models.length} model${models.length > 1 ? 's' : ''} configured`)} ${managedBadge}`)
551
+
552
+ for (const model of models) {
553
+ const isModelCursor = globalIdx === state.installedModelsCursor
554
+ const tierBadge = model.tier !== '-' ? themeColors.info(model.tier.padEnd(2)) : themeColors.dim(' ')
555
+ const externalBadge = model.isExternal ? themeColors.dim('[external]') : ''
556
+
557
+ const modelRow = ` • ${model.label} ${tierBadge} ${externalBadge}`
558
+ cursorLineByRow[globalIdx++] = lines.length
559
+ lines.push(isModelCursor ? themeColors.bgCursor(modelRow) : modelRow)
560
+
561
+ if (isModelCursor) {
562
+ lines.push(` ${themeColors.dim('[Enter] Launch [D] Disable')}`)
563
+ }
564
+ }
565
+ }
566
+ }
567
+ }
568
+
569
+ lines.push('')
570
+ lines.push(themeColors.dim(' ↑↓ Navigate Enter=Launch D=Disable Esc=Close'))
571
+
572
+ const targetLine = cursorLineByRow[state.installedModelsCursor] ?? 0
573
+ state.installedModelsScrollOffset = keepOverlayTargetVisible(
574
+ state.installedModelsScrollOffset,
575
+ targetLine,
576
+ lines.length,
577
+ state.terminalRows
578
+ )
579
+ const { visible, offset } = sliceOverlayLines(lines, state.installedModelsScrollOffset, state.terminalRows)
580
+ state.installedModelsScrollOffset = offset
581
+
582
+ overlayLayout.installedModelsCursorToLine = cursorLineByRow
583
+ overlayLayout.installedModelsScrollOffset = offset
584
+
585
+ const tintedLines = tintOverlayLines(visible, themeColors.overlayBgSettings, state.terminalCols)
586
+ const cleared = tintedLines.map((l) => l + EL)
587
+ return cleared.join('\n')
588
+ }
589
+
482
590
  // ─── Missing-tool install confirmation overlay ────────────────────────────
483
591
  // 📖 renderToolInstallPrompt keeps the user inside the TUI long enough to
484
592
  // 📖 confirm the auto-install, then the key handler exits the alt screen and
@@ -796,7 +904,7 @@ export function createOverlayRenderers(state, deps) {
796
904
  lines.push('')
797
905
  lines.push(` ${heading('Main TUI')}`)
798
906
  lines.push(` ${heading('Navigation')}`)
799
- lines.push(` ${key('↑↓')} Navigate rows`)
907
+ lines.push(` ${key('↑↓ / J/K')} Navigate rows ${hint('(J/K = vim-style scroll)')}`)
800
908
  lines.push(` ${key('Enter')} Select model and launch`)
801
909
  lines.push(` ${hint('If the active CLI is missing, FCM offers a one-click install prompt first.')}`)
802
910
  lines.push('')
@@ -815,7 +923,7 @@ export function createOverlayRenderers(state, deps) {
815
923
  // 📖 Profile system removed - API keys now persist permanently across all sessions
816
924
  lines.push(` ${key('Shift+R')} Reset view settings ${hint('(tier filter, sort, provider filter → defaults)')}`)
817
925
  lines.push(` ${key('N')} Changelog ${hint('(📋 browse all versions, Enter to view details)')}`)
818
- lines.push(` ${key('K')} / ${key('Esc')} Show/hide this help`)
926
+ lines.push(` ${key('Ctrl+H')} / ${key('Esc')} Show/hide this help`)
819
927
  lines.push(` ${key('Ctrl+C')} Exit`)
820
928
  lines.push('')
821
929
  lines.push(` ${heading('Settings (P)')}`)
@@ -1381,6 +1489,7 @@ export function createOverlayRenderers(state, deps) {
1381
1489
  renderRecommend,
1382
1490
  renderFeedback,
1383
1491
  renderChangelog,
1492
+ renderInstalledModels,
1384
1493
  renderIncompatibleFallback,
1385
1494
  startRecommendAnalysis,
1386
1495
  stopRecommendAnalysis,
@@ -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