free-coding-models 0.3.56 → 0.3.57

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.
@@ -36,33 +36,27 @@ import chalk from 'chalk'
36
36
  import { createRequire } from 'module'
37
37
  import { sources } from '../sources.js'
38
38
  import {
39
- TABLE_FIXED_LINES,
40
39
  COL_MODEL,
41
40
  TIER_CYCLE,
42
41
  msCell,
43
42
  spinCell,
44
43
  PING_INTERVAL,
45
44
  WIDTH_WARNING_MIN_COLS,
45
+ TABLE_FOOTER_LINES,
46
46
  FRAMES
47
47
  } from './constants.js'
48
48
  import { themeColors, getProviderRgb, getTierRgb, getReadableTextRgb, getTheme } from './theme.js'
49
49
  import { TIER_COLOR } from './tier-colors.js'
50
50
  import { getAvg, getVerdict, getUptime, getStabilityScore, getVersionStatusInfo } from './utils.js'
51
- import { VERDICT_CYCLE } from './constants.js'
52
51
  import { usagePlaceholderForProvider } from './ping.js'
53
52
  import { calculateViewport, sortResultsWithPinnedFavorites, padEndDisplay, displayWidth } from './render-helpers.js'
54
53
  import { getToolMeta, TOOL_METADATA, TOOL_MODE_ORDER, isModelCompatibleWithTool } from './tool-metadata.js'
55
54
  import { getColumnSpacing } from './ui-config.js'
56
55
  import { detectPackageManager, getManualInstallCmd } from './updater.js'
57
- import { formatTokenTotalCompact } from './token-usage-reader.js'
58
56
 
59
57
  const require = createRequire(import.meta.url)
60
58
  const { version: LOCAL_VERSION } = require('../package.json')
61
59
 
62
- // 📖 HEALTH_CYCLE: cycles through health/status states (local constant for render-table.js)
63
- // VERDICT_CYCLE is now imported from constants.js
64
- const HEALTH_CYCLE = [null, 'up', 'timeout', 'down', 'auth_error', 'noauth', 'pending']
65
-
66
60
  // 📖 Mouse support: column boundary map updated every frame by renderTable().
67
61
  // 📖 Each entry maps a column name to its display X-start and X-end (1-based, inclusive).
68
62
  // 📖 headerRow is the 1-based terminal row of the column header line.
@@ -110,9 +104,10 @@ export const PROVIDER_COLOR = new Proxy({}, {
110
104
  })
111
105
 
112
106
  // ─── renderTable: mode param controls footer hint text (opencode vs openclaw) ─────────
113
- 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, footerHidden = false, verdictFilterMode = 0, healthFilterMode = 0, routerFooterRunning = false, routerFooterActiveSet = null, routerFooterTodayTokens = 0, routerFooterAllTimeTokens = 0, routerFooterRequests = 0) {
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, legacyFooterHidden = false, verdictFilterMode = 0, healthFilterMode = 0, routerFooterRunning = false, routerFooterActiveSet = null, routerFooterTodayTokens = 0, routerFooterAllTimeTokens = 0, routerFooterRequests = 0) {
114
108
  // 📖 Filter out hidden models for display
115
109
  const visibleResults = results.filter(r => !r.hidden)
110
+ void legacyFooterHidden
116
111
 
117
112
  const up = visibleResults.filter(r => r.status === 'up').length
118
113
  const down = visibleResults.filter(r => r.status === 'down').length
@@ -324,80 +319,8 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
324
319
  themeColors.warning(`⏳ ${timeout}`) + themeColors.dim(' timeout ') +
325
320
  themeColors.error(`❌ ${down}`) + themeColors.dim(' down ') +
326
321
  '',
327
- '',
328
322
  ]
329
323
 
330
- // 📖 Filter bar — llmfit-style horizontal filter pills (1 dedicated row above table)
331
- // 📖 Each block: title with hotkey hint + active value colored by filter state
332
- {
333
- const filterParts = []
334
- const filterSep = themeColors.dim(' │ ')
335
- const blockSep = ' │ '
336
-
337
- // 📖 Search filter block — shows active text filter or prompt
338
- if (customTextFilter && customTextFilter.trim()) {
339
- const badgeText = ` Search "/" ${blockSep} ${customTextFilter.trim().slice(0, 20)} `
340
- filterParts.push(themeColors.badge(badgeText, [52, 120, 88], [255, 255, 255]))
341
- } else {
342
- filterParts.push(themeColors.dim(' Search "/" '))
343
- }
344
-
345
- // 📖 Tier filter block — T key cycles through TIER_CYCLE
346
- if (tierFilterMode > 0) {
347
- const tierLabel = TIER_CYCLE_NAMES[tierFilterMode]
348
- const tierBg = getTierRgb(tierLabel)
349
- filterParts.push(themeColors.badge(` Tier (${tierLabel}) `, tierBg, [255, 255, 255]))
350
- } else {
351
- filterParts.push(themeColors.dim(' Tier (T) '))
352
- }
353
-
354
- // 📖 Provider filter block — D key cycles through providers
355
- if (originFilterMode > 0) {
356
- const originKeys = [null, ...Object.keys(sources)]
357
- const activeOriginKey = originKeys[originFilterMode]
358
- const activeOriginName = activeOriginKey ? sources[activeOriginKey]?.name ?? activeOriginKey : null
359
- if (activeOriginName) {
360
- const normName = normalizeOriginLabel(activeOriginName, activeOriginKey)
361
- const providerRgb = PROVIDER_COLOR[activeOriginKey] || [255, 255, 255]
362
- filterParts.push(themeColors.badge(` Provider (${normName}) `, providerRgb, [255, 255, 255]))
363
- }
364
- } else {
365
- filterParts.push(themeColors.dim(' Provider (D) '))
366
- }
367
-
368
- // 📖 Verdict filter block — V key cycles through verdicts
369
- if (verdictFilterMode > 0) {
370
- const verdictLabel = VERDICT_CYCLE[verdictFilterMode]
371
- const verdictColors = {
372
- 'Perfect': themeColors.success,
373
- 'Normal': themeColors.metricGood,
374
- 'Slow': (t) => chalk.bold.rgb(...getTierRgb('A-'))(t),
375
- 'Spiky': (t) => chalk.bold.rgb(...getTierRgb('A+'))(t),
376
- 'Very Slow': (t) => chalk.bold.rgb(...getTierRgb('B+'))(t),
377
- 'Overloaded': (t) => chalk.bold.rgb(...getTierRgb('B'))(t),
378
- 'Unstable': themeColors.errorBold,
379
- 'Not Active': themeColors.dim,
380
- 'Pending': themeColors.dim,
381
- }
382
- const vc = verdictColors[verdictLabel] || themeColors.accent
383
- filterParts.push(themeColors.badge(` Verdict (${verdictLabel}) `, [20, 20, 20], vc === themeColors.dim ? [130, 130, 130] : [255, 255, 255]))
384
- } else {
385
- filterParts.push(themeColors.dim(' Verdict (V) '))
386
- }
387
-
388
- // 📖 Health filter block — H key cycles through health states
389
- if (healthFilterMode > 0) {
390
- const healthLabel = HEALTH_CYCLE[healthFilterMode]
391
- const healthDisplay = healthLabel === 'auth_error' ? 'Auth Err' : healthLabel === 'noauth' ? 'No Key' : healthLabel.charAt(0).toUpperCase() + healthLabel.slice(1)
392
- const healthBg = healthLabel === 'up' ? [52, 120, 88] : healthLabel === 'timeout' ? [180, 130, 0] : healthLabel === 'down' ? [120, 40, 40] : [60, 60, 60]
393
- filterParts.push(themeColors.badge(` Health (${healthDisplay}) `, healthBg, [255, 255, 255]))
394
- } else {
395
- filterParts.push(themeColors.dim(' Health (H) '))
396
- }
397
-
398
- lines.push(filterParts.join(blockSep))
399
- }
400
-
401
324
  // 📖 Header row with sorting indicators
402
325
  // 📖 NOTE: padEnd on chalk strings counts ANSI codes, breaking alignment
403
326
  // 📖 Solution: build plain text first, then colorize
@@ -496,8 +419,11 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
496
419
 
497
420
  // 📖 Viewport clipping: only render models that fit on screen
498
421
  const hasCustomFilter = typeof customTextFilter === 'string' && customTextFilter.trim().length > 0
499
- const extraFooterLines = versionStatus.isOutdated ? 1 : 0
500
- const vp = calculateViewport(terminalRows, scrollOffset, sorted.length, extraFooterLines)
422
+ const hasReleaseFooter = typeof lastReleaseDate === 'string' && lastReleaseDate.trim().length > 0
423
+ const extraFooterLines = (versionStatus.isOutdated ? 1 : 0) + (hasCustomFilter ? 1 : 0) + (hasReleaseFooter ? 1 : 0)
424
+ const vp = calculateViewport(terminalRows, scrollOffset, sorted.length, {
425
+ extraFixedLines: extraFooterLines,
426
+ })
501
427
  const paintSweScore = (score, paddedText) => {
502
428
  if (score >= 70) return chalk.bold.rgb(...getTierRgb('S+'))(paddedText)
503
429
  if (score >= 60) return chalk.bold.rgb(...getTierRgb('S'))(paddedText)
@@ -538,16 +464,14 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
538
464
  ? providerName.slice(0, 4) + '…'
539
465
  : providerName
540
466
  const source = themeColors.provider(r.providerKey, providerDisplay.padEnd(wSource))
541
- const CIRCLED = ['①','②','③','④','⑤','⑥','⑦','⑧','⑨','⑩','⑪','⑫','⑬','⑭','⑮','⑯','⑰','⑱','⑲','⑳']
542
- let favoritePrefix = ' '
467
+ // 📖 Favorites marked with a single ⭐ — no ranking numbers
468
+ let favoritePrefix = ''
543
469
  if (r.isRecommended) {
544
- favoritePrefix = '🎯'
545
- } else if (r.isFavorite && r.favoriteRank < CIRCLED.length) {
546
- favoritePrefix = CIRCLED[r.favoriteRank]
470
+ favoritePrefix = '🎯 '
547
471
  } else if (r.isFavorite) {
548
- favoritePrefix = '⭐'
472
+ favoritePrefix = '⭐ '
549
473
  }
550
- const prefixDisplayWidth = 2
474
+ const prefixDisplayWidth = displayWidth(favoritePrefix)
551
475
  const nameWidth = Math.max(0, W_MODEL - prefixDisplayWidth)
552
476
  const name = favoritePrefix + r.label.slice(0, nameWidth).padEnd(nameWidth)
553
477
  const sweScore = r.sweScore ?? '—'
@@ -793,7 +717,13 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
793
717
  lines.push(themeColors.dim(` ... ${sorted.length - vp.endIdx} more below ...`))
794
718
  }
795
719
 
796
- lines.push('')
720
+ // 📖 Blank lines keep the footer glued to the bottom without touching the sticky header.
721
+ if (terminalRows > 0) {
722
+ const footerLineCount = TABLE_FOOTER_LINES + extraFooterLines
723
+ const blankCount = Math.max(0, terminalRows - lines.length - footerLineCount)
724
+ for (let i = 0; i < blankCount; i++) lines.push('')
725
+ }
726
+
797
727
  // 📖 Footer hints keep only navigation and secondary actions now that the
798
728
  // 📖 active tool target is already visible in the header badge.
799
729
  const hotkey = (keyLabel, text) => themeColors.hotkey(keyLabel) + themeColors.dim(text)
@@ -801,8 +731,6 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
801
731
  // 📖 states are obvious even when the user misses the smaller header badges.
802
732
  const configuredBadgeBg = getTheme() === 'dark' ? [52, 120, 88] : [195, 234, 206]
803
733
  const activeHotkey = (keyLabel, text, bg) => themeColors.badge(`${keyLabel}${text}`, bg, getReadableTextRgb(bg))
804
- const favoritesModeBg = favoritesPinnedAndSticky ? [157, 122, 48] : [95, 95, 95]
805
- const favoritesModeLabel = favoritesPinnedAndSticky ? ' Favorites Pinned' : ' Favorites Normal'
806
734
 
807
735
  // 📖 Mouse support: build footer hotkey zones alongside the footer lines.
808
736
  // 📖 Each zone records { key, row (1-based terminal row), xStart, xEnd (1-based display cols) }.
@@ -814,21 +742,19 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
814
742
  {
815
743
  const parts = [
816
744
  { text: ' ', key: null },
817
- { text: 'F Toggle Favorite', key: 'f' },
745
+ { text: 'F Favorite', key: 'f' },
818
746
  { text: ' • ', key: null },
819
- { text: 'Y' + favoritesModeLabel, key: 'y' },
747
+ { text: 'Y Fav Mode', key: 'y' },
820
748
  { text: ' • ', key: null },
821
749
  { text: tierFilterMode > 0 ? `T Tier (${activeTierLabel})` : 'T Tier', key: 't' },
822
750
  { text: ' • ', key: null },
823
751
  { text: originFilterMode > 0 ? `D Provider (${activeOriginLabel})` : 'D Provider', key: 'd' },
824
752
  { text: ' • ', key: null },
825
- { text: 'E Show only configured models', key: 'e' },
753
+ { text: 'E Active only', key: 'e' },
826
754
  { text: ' • ', key: null },
827
755
  { text: 'P Settings', key: 'p' },
828
756
  { text: ' • ', key: null },
829
- { text: 'J/K Navigate', key: null },
830
- { text: ' • ', key: null },
831
- { text: 'Ctrl+H Help', key: 'ctrl+h' },
757
+ { text: 'I Help', key: 'i' },
832
758
  ]
833
759
  const footerRow1 = lines.length + 1 // 📖 1-based terminal row (line hasn't been pushed yet)
834
760
  let xPos = 1
@@ -839,196 +765,108 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
839
765
  }
840
766
  }
841
767
 
842
- if (!footerHidden) {
843
- // 📖 Full footer all hint lines hidden when footerHidden=true to maximize table space
844
- lines.push(
845
- ' ' + hotkey('F', ' Toggle Favorite') +
846
- themeColors.dim(` • `) +
847
- activeHotkey('Y', favoritesModeLabel, favoritesModeBg) +
848
- themeColors.dim(` • `) +
849
- (tierFilterMode > 0
850
- ? activeHotkey('T', ` Tier (${activeTierLabel})`, getTierRgb(activeTierLabel))
851
- : hotkey('T', ' Tier')) +
852
- themeColors.dim(` • `) +
853
- (originFilterMode > 0
854
- ? activeHotkey('D', ` Provider (${activeOriginLabel})`, PROVIDER_COLOR[[null, ...Object.keys(sources)][originFilterMode]] || [255, 255, 255])
855
- : hotkey('D', ' Provider')) +
856
- themeColors.dim(` • `) +
857
- (hideUnconfiguredModels ? activeHotkey('E', ' Show only configured models', configuredBadgeBg) : hotkey('E', ' Show only configured models')) +
858
- themeColors.dim(` • `) +
859
- hotkey('P', ' Settings') +
860
- themeColors.dim(` • `) +
861
- themeColors.dim('J/K Navigate') +
862
- themeColors.dim(` • `) +
863
- themeColors.dim('Ctrl+H Help')
864
- )
865
-
866
- // 📖 Line 2: command palette, recommend, feedback, theme
867
- {
868
- const cpText = ' CTRL+P ⚡️ Command Palette '
869
- const parts = [
870
- { text: ' ', key: null },
871
- { text: cpText, key: 'ctrl+p' },
872
- { text: ' • ', key: null },
873
- { text: 'Q Smart Recommend', key: 'q' },
874
- { text: ' • ', key: null },
875
- { text: 'Shift+R Router', key: 'shift+r' },
876
- { text: ' • ', key: null },
877
- { text: 'G Theme', key: 'g' },
878
- { text: ' • ', key: null },
879
- { text: 'I Feedback, bugs & requests', key: 'i' },
880
- ]
881
- const footerRow2 = lines.length + 1
882
- let xPos = 1
883
- for (const part of parts) {
884
- const w = displayWidth(part.text)
885
- if (part.key) footerHotkeys.push({ key: part.key, row: footerRow2, xStart: xPos, xEnd: xPos + w - 1 })
886
- xPos += w
887
- }
888
- }
889
-
890
- // 📖 Line 2: command palette (highlighted as new), recommend, feedback, and extended hints.
891
- // 📖 CTRL+P ⚡️ Command Palette uses neon-green-on-dark-green background to highlight the feature.
892
- const paletteLabel = chalk.bgRgb(0, 60, 0).rgb(57, 255, 20).bold(' CTRL+P ⚡️ Command Palette ')
893
- lines.push(
894
- ' ' + paletteLabel + themeColors.dim(` • `) +
895
- hotkey('Q', ' Smart Recommend') + themeColors.dim(` • `) +
896
- hotkey('Shift+R', ' Router') + themeColors.dim(` • `) +
897
- hotkey('G', ' Theme') + themeColors.dim(` • `) +
898
- hotkey('I', ' Feedback, bugs & requests')
899
- )
900
- // 📖 Proxy status is now shown via the badge in line 2 above — no need for a dedicated line
901
- const footerLine =
902
- themeColors.footerLove(' Made with 💖 & ☕ by \x1b]8;;https://github.com/vava-nessa\x1b\\vava-nessa\x1b]8;;\x1b\\') +
903
- themeColors.dim(' • ') +
904
- '⭐ ' +
905
- themeColors.link('\x1b]8;;https://github.com/vava-nessa/free-coding-models\x1b\\Star on GitHub\x1b]8;;\x1b\\') +
906
- themeColors.dim(' • ') +
907
- '🤝 ' +
908
- themeColors.warning('\x1b]8;;https://github.com/vava-nessa/free-coding-models/graphs/contributors\x1b\\Contributors\x1b]8;;\x1b\\') +
909
- themeColors.dim(' • ') +
910
- '☕ ' +
911
- themeColors.footerCoffee('\x1b]8;;https://buymeacoffee.com/vavanessadev\x1b\\Buy me a coffee\x1b]8;;\x1b\\')
912
- lines.push(footerLine)
913
-
914
- if (versionStatus.isOutdated) {
915
- const updateMsg = ` 🚀⬆️ UPDATE AVAILABLE — v${LOCAL_VERSION} → v${versionStatus.latestVersion} • Click here or press Shift+U to update 🚀⬆️ `
916
- const paddedBanner = terminalCols > 0
917
- ? updateMsg + ' '.repeat(Math.max(0, terminalCols - displayWidth(updateMsg)))
918
- : updateMsg
919
- const fluoGreenBanner = chalk.bgRgb(57, 255, 20).rgb(0, 0, 0).bold(paddedBanner)
920
- const updateBannerRow = lines.length + 1
921
- _lastLayout.updateBannerRow = updateBannerRow
922
- footerHotkeys.push({ key: 'update-click', row: updateBannerRow, xStart: 1, xEnd: Math.max(terminalCols, displayWidth(updateMsg)) })
923
- lines.push(fluoGreenBanner)
924
- } else {
925
- _lastLayout.updateBannerRow = 0
926
- }
927
-
928
- // 📖 Final footer line: changelog + optional active text-filter badge + exit hint.
929
- let filterBadge = ''
930
- if (hasCustomFilter) {
931
- const normalizedFilter = customTextFilter.trim().replace(/\s+/g, ' ')
932
- const filterPrefix = 'X Disable filter: "'
933
- const filterSuffix = '"'
934
- const separatorPlain = ' • '
935
- const baseFooterPlain = ' N Changelog' + separatorPlain + 'Ctrl+C Exit'
936
- const baseBadgeWidth = displayWidth(` ${filterPrefix}${filterSuffix} `)
937
- const availableFilterWidth = terminalCols > 0
938
- ? Math.max(8, terminalCols - displayWidth(baseFooterPlain) - displayWidth(separatorPlain) - baseBadgeWidth)
939
- : normalizedFilter.length
940
- const visibleFilter = normalizedFilter.length > availableFilterWidth
941
- ? `${normalizedFilter.slice(0, Math.max(3, availableFilterWidth - 3))}...`
942
- : normalizedFilter
943
- filterBadge = chalk.bgYellow.black.bold(` ${filterPrefix}${visibleFilter}${filterSuffix} `)
768
+ lines.push(
769
+ ' ' + hotkey('F', ' Favorite') +
770
+ themeColors.dim(` • `) +
771
+ hotkey('Y', ' Fav Mode') +
772
+ themeColors.dim(` • `) +
773
+ (tierFilterMode > 0
774
+ ? activeHotkey('T', ` Tier (${activeTierLabel})`, getTierRgb(activeTierLabel))
775
+ : hotkey('T', ' Tier')) +
776
+ themeColors.dim(` • `) +
777
+ (originFilterMode > 0
778
+ ? activeHotkey('D', ` Provider (${activeOriginLabel})`, PROVIDER_COLOR[[null, ...Object.keys(sources)][originFilterMode]] || [255, 255, 255])
779
+ : hotkey('D', ' Provider')) +
780
+ themeColors.dim(` • `) +
781
+ (hideUnconfiguredModels ? activeHotkey('E', ' Active only', configuredBadgeBg) : hotkey('E', ' Active only')) +
782
+ themeColors.dim(` • `) +
783
+ hotkey('P', ' Settings') +
784
+ themeColors.dim(` • `) +
785
+ hotkey('I', ' Help')
786
+ )
787
+
788
+ // 📖 Line 2: command palette + GitHub
789
+ {
790
+ const cpText = ' Ctrl+P Cmd Palette '
791
+ const parts = [
792
+ { text: ' ', key: null },
793
+ { text: cpText, key: 'ctrl+p' },
794
+ { text: ' ', key: null },
795
+ ]
796
+ const footerRow2 = lines.length + 1
797
+ let xPos = 1
798
+ for (const part of parts) {
799
+ const w = displayWidth(part.text)
800
+ if (part.key) footerHotkeys.push({ key: part.key, row: footerRow2, xStart: xPos, xEnd: xPos + w - 1 })
801
+ xPos += w
944
802
  }
803
+ }
945
804
 
946
- // 📖 Mouse support: track last footer line hotkey zones
947
- {
948
- const lastFooterRow = lines.length + 1 // 📖 1-based terminal row (line about to be pushed)
949
- const parts = [
950
- { text: ' ', key: null },
951
- { text: 'N Changelog', key: 'n' },
952
- ]
953
- if (hasCustomFilter) {
954
- parts.push({ text: ' • ', key: null })
955
- // 📖 X key clears filter — compute width from rendered badge text
956
- const badgePlain = `X Disable filter: "${customTextFilter.trim().replace(/\s+/g, ' ')}"`
957
- parts.push({ text: ` ${badgePlain} `, key: 'x' })
958
- }
959
- let xPos = 1
960
- for (const part of parts) {
961
- const w = displayWidth(part.text)
962
- if (part.key) footerHotkeys.push({ key: part.key, row: lastFooterRow, xStart: xPos, xEnd: xPos + w - 1 })
963
- xPos += w
964
- }
965
- }
805
+ // 📖 Line 2: command palette (highlighted as new) + GitHub link.
806
+ // 📖 Ctrl+P Cmd Palette uses neon-green-on-dark-green background to highlight the feature.
807
+ const paletteLabel = chalk.bgRgb(0, 60, 0).rgb(57, 255, 20).bold(' Ctrl+P Cmd Palette ')
808
+ const starLink = '⭐ ' + themeColors.link('\x1b]8;;https://github.com/vava-nessa/free-coding-models\x1b\\GitHub\x1b]8;;\x1b\\')
809
+ lines.push(
810
+ ' ' + paletteLabel + themeColors.dim(` • `) + starLink + themeColors.dim(` • `) +
811
+ chalk.rgb(255, 168, 209).bold('\x1b]8;;https://x.com/vavanessadev\x1b\\Support me by following me on X ! @vavanessadev\x1b]8;;\x1b\\')
812
+ )
813
+
814
+ if (versionStatus.isOutdated) {
815
+ const updateMsg = ` 🚀⬆️ UPDATE AVAILABLE — v${LOCAL_VERSION} → v${versionStatus.latestVersion} • Click here or press Shift+U to update 🚀⬆️ `
816
+ const paddedBanner = terminalCols > 0
817
+ ? updateMsg + ' '.repeat(Math.max(0, terminalCols - displayWidth(updateMsg)))
818
+ : updateMsg
819
+ const fluoGreenBanner = chalk.bgRgb(57, 255, 20).rgb(0, 0, 0).bold(paddedBanner)
820
+ const updateBannerRow = lines.length + 1
821
+ _lastLayout.updateBannerRow = updateBannerRow
822
+ footerHotkeys.push({ key: 'update-click', row: updateBannerRow, xStart: 1, xEnd: Math.max(terminalCols, displayWidth(updateMsg)) })
823
+ lines.push(fluoGreenBanner)
824
+ } else {
825
+ _lastLayout.updateBannerRow = 0
826
+ }
966
827
 
967
- const releaseLabel = lastReleaseDate
968
- ? chalk.rgb(255, 182, 193)(`Last release: ${lastReleaseDate}`)
969
- : ''
970
-
971
- lines.push(
972
- ' ' + themeColors.hotkey('N') + themeColors.dim(' Changelog') +
973
- (filterBadge
974
- ? themeColors.dim('') + filterBadge
975
- : '') +
976
- themeColors.dim(' • ') +
977
- themeColors.dim('Ctrl+C Exit') +
978
- (releaseLabel ? themeColors.dim(' • ') + releaseLabel : '')
979
- )
980
-
981
- // 📖 Router token stats + daemon status in the footer (shown when router is enabled)
982
- if (routerFooterRunning) {
983
- const todayStr = formatTokenTotalCompact(routerFooterTodayTokens)
984
- const allTimeStr = formatTokenTotalCompact(routerFooterAllTimeTokens)
985
- const reqStr = String(routerFooterRequests)
986
- const setLabel = routerFooterActiveSet ? themeColors.info(routerFooterActiveSet) : themeColors.dim('?')
987
- lines.push(
988
- ' ' + themeColors.success('●') + ' ' +
989
- themeColors.dim('Router:') + ' ' + setLabel +
990
- themeColors.dim(' • Today:') + ' ' + themeColors.textBold(todayStr + ' tok') +
991
- themeColors.dim(' • All-time:') + ' ' + themeColors.textBold(allTimeStr + ' tok') +
992
- themeColors.dim(' • ' + reqStr + ' req')
993
- )
994
- } else {
995
- lines.push(
996
- ' ' + themeColors.error('○') + ' ' +
997
- themeColors.dim('Router:') + ' ' + themeColors.dim('daemon not running') +
998
- themeColors.dim(' • Shift+R Dashboard')
999
- )
1000
- }
828
+ // 📖 Optional active text-filter badge — surfaced inline if a custom filter is active.
829
+ // 📖 Changelog moved to Settings (P), Ctrl+C Exit moved to Help (Ctrl+H), Discord
830
+ // 📖 moved to onboarding + Settings — no more orphan hint lines down here.
831
+ let filterBadge = ''
832
+ if (hasCustomFilter) {
833
+ const normalizedFilter = customTextFilter.trim().replace(/\s+/g, ' ')
834
+ const filterPrefix = 'X Disable filter: "'
835
+ const filterSuffix = '"'
836
+ const baseBadgeWidth = displayWidth(` ${filterPrefix}${filterSuffix} `)
837
+ const availableFilterWidth = terminalCols > 0
838
+ ? Math.max(8, terminalCols - 4 - baseBadgeWidth)
839
+ : normalizedFilter.length
840
+ const visibleFilter = normalizedFilter.length > availableFilterWidth
841
+ ? `${normalizedFilter.slice(0, Math.max(3, availableFilterWidth - 3))}...`
842
+ : normalizedFilter
843
+ filterBadge = chalk.bgYellow.black.bold(` ${filterPrefix}${visibleFilter}${filterSuffix} `)
844
+ }
1001
845
 
1002
- // 📖 Discord link at the very bottom of the TUI
1003
- lines.push(
1004
- ' 💬 ' +
1005
- themeColors.footerDiscord('\x1b]8;;https://discord.gg/ZTNFHvvCkU\x1b\\Join the Discord\x1b]8;;\x1b\\') +
1006
- themeColors.dim(' ') +
1007
- themeColors.footerDiscord('https://discord.gg/ZTNFHvvCkU')
1008
- )
1009
- } else {
1010
- // 📖 Collapsed footer: single line with toggle hint
1011
- lines.push(
1012
- ' ' + themeColors.hotkey('Ctrl+O') + themeColors.dim(' Toggle Footer') +
1013
- themeColors.dim(' • ') +
1014
- themeColors.hotkey('Shift+R') + themeColors.dim(' Router') +
1015
- themeColors.dim(' • Ctrl+C Exit')
1016
- )
846
+ if (hasCustomFilter) {
847
+ // 📖 Mouse support: register click zone for the X-clear filter badge
848
+ const lastFooterRow = lines.length + 1
849
+ const badgePlain = `X Disable filter: "${customTextFilter.trim().replace(/\s+/g, ' ')}"`
850
+ const fullText = ' ' + ` ${badgePlain} `
851
+ const xStart = 3 // 📖 after the leading 2 spaces
852
+ const xEnd = xStart + displayWidth(` ${badgePlain} `) - 1
853
+ footerHotkeys.push({ key: 'x', row: lastFooterRow, xStart, xEnd })
854
+ void fullText
855
+ lines.push(' ' + filterBadge)
1017
856
  }
1018
857
 
858
+ const releaseLabel = lastReleaseDate
859
+ ? chalk.rgb(255, 182, 193)(`Last release: ${lastReleaseDate}`)
860
+ : ''
861
+
862
+ if (releaseLabel) lines.push(' ' + releaseLabel)
1019
863
  _lastLayout.footerHotkeys = footerHotkeys
1020
864
 
1021
865
  // 📖 Append \x1b[K (erase to EOL) to each line so leftover chars from previous
1022
- // 📖 frames are cleared. \x1b[J (erase from cursor to end of screen) clears any
1023
- // 📖 stale content below when footer is hidden.
866
+ // 📖 frames are cleared. \x1b[J clears stale content below without adding a
867
+ // 📖 newline that could scroll the alternate screen.
1024
868
  const EL = '\x1b[K'
1025
869
  const cleared = lines.map(l => l + EL)
1026
- if (footerHidden) {
1027
- // 📖 When footer is hidden, \x1b[J erases stale footer content below the cursor
1028
- cleared.push('\x1b[J')
1029
- } else {
1030
- const remaining = terminalRows > 0 ? Math.max(0, terminalRows - cleared.length) : 0
1031
- for (let i = 0; i < remaining; i++) cleared.push(EL)
1032
- }
870
+ if (cleared.length > 0) cleared[cleared.length - 1] += '\x1b[J'
1033
871
  return cleared.join('\n')
1034
872
  }
@@ -826,6 +826,15 @@ export function renderRouterDashboard(state, deps = {}) {
826
826
  if (snapshot.models.length === 0) {
827
827
  lines.push(` ${themeColors.dim('No model health rows available yet. Start the daemon or wait for /stats to answer.')}`)
828
828
  } else {
829
+ // 📖 Keycap emoji digits 1️⃣…🔟 — large, colorful, instantly readable in the
830
+ // 📖 router order column. Capped at 10 because the active set should never
831
+ // 📖 contain more than 10 priority slots in practice.
832
+ const KEYCAPS = ['1️⃣','2️⃣','3️⃣','4️⃣','5️⃣','6️⃣','7️⃣','8️⃣','9️⃣','🔟']
833
+ const priorityGlyph = (priority) => {
834
+ const n = Number(priority)
835
+ if (Number.isFinite(n) && n >= 1 && n <= KEYCAPS.length) return KEYCAPS[n - 1]
836
+ return '—'
837
+ }
829
838
  const header = ` ${padEndDisplay('#', 4)} ${padEndDisplay('Provider', 12)} ${padEndDisplay('Model', 30)} ${padEndDisplay('State', 12)} ${padEndDisplay('P95', 8)} ${padEndDisplay('Up', 5)} Score`
830
839
  lines.push(themeColors.dim(header))
831
840
  for (const model of snapshot.models.slice(0, 12)) {
@@ -833,7 +842,7 @@ export function renderRouterDashboard(state, deps = {}) {
833
842
  const score = Number.isFinite(model.score) ? model.score.toFixed(2) : '—'
834
843
  const errorSuffix = model.last_error ? themeColors.dim(` ${compactText(model.last_error, 22).trimEnd()}`) : ''
835
844
  lines.push(
836
- ` ${padEndDisplay(String(model.priority), 4)} ` +
845
+ ` ${padEndDisplay(priorityGlyph(model.priority), 4)} ` +
837
846
  `${compactText(model.provider, 12)} ` +
838
847
  `${compactText(model.model, 30)} ` +
839
848
  `${padEndDisplay(modelStateBadge(model.state), 12)} ` +