free-coding-models 0.3.55 → 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.
Files changed (75) hide show
  1. package/CHANGELOG.md +55 -56
  2. package/README.md +214 -160
  3. package/bin/free-coding-models.js +46 -0
  4. package/package.json +2 -2
  5. package/sources.js +134 -310
  6. package/src/analysis.js +23 -10
  7. package/src/app.js +66 -27
  8. package/src/cache.js +1 -1
  9. package/src/cli-help.js +9 -0
  10. package/src/command-palette.js +15 -13
  11. package/src/config.js +201 -35
  12. package/src/constants.js +4 -4
  13. package/src/endpoint-installer.js +45 -1
  14. package/src/favorites.js +22 -0
  15. package/src/graphify-out/cache/089db1c1def873cf6d112f1590da4490e61e691aff0db41e006aa2fb15ba0656.json +1 -0
  16. package/src/graphify-out/cache/0b510b53cf1a1393fb52b1fc3bbbf88b63938e961ec5b82119a2e9715fee8bd7.json +1 -0
  17. package/src/graphify-out/cache/0ec9a95a326bde58e0316889018b278062d06d494d0f31ba177c9de71e5fed2d.json +1 -0
  18. package/src/graphify-out/cache/1548663a24a68dce740ebab1bd1d3091048c9604e9d067a1650a42a6d82541d4.json +1 -0
  19. package/src/graphify-out/cache/1783af63cb6d0dfb4d469009f71ac83a74ba0b33d48186ff2c6e63f9429e900a.json +1 -0
  20. package/src/graphify-out/cache/1e109f5eb5dc4fd285871c3613e32b6b14a8c225f4080ee34b51c7e1a1764571.json +1 -0
  21. package/src/graphify-out/cache/1eb24dbeb69b46c8bc1caf925df2f2a964af0f33aea143adf8ddf88e017db6ca.json +1 -0
  22. package/src/graphify-out/cache/21e1bcfed11685e8347243f9d8516072dda183266a4bfe22c52fb31753a446c8.json +1 -0
  23. package/src/graphify-out/cache/2327473478b9c4b1940bf7ef66c9ee960b3cba8d5302e56b625df8274246e0b4.json +1 -0
  24. package/src/graphify-out/cache/25955b81fd25454c8fa90fb71a47db8d1215cf621beb8ff3cbd580aaf011b4f3.json +1 -0
  25. package/src/graphify-out/cache/2739677f19c702f88f3de0a0bac475066adbda98709907ad3de967aef689f86d.json +1 -0
  26. package/src/graphify-out/cache/2bba03422f6b3ee7f5b5d29cc90314a064d259e5822a176657bda3e04505cf00.json +1 -0
  27. package/src/graphify-out/cache/2ddf1d2c6d10147b0402446bc71a7988187b79b6210dd7e7250be8c555b9ff35.json +1 -0
  28. package/src/graphify-out/cache/2ee07457a5767c95a57f8e9eb95b28f800044f35666e0715e9d88ad1103a092e.json +1 -0
  29. package/src/graphify-out/cache/2fe9f75dc2951c417f2c8dd22749092cf550dc67599f1c8d1866900dc6e9154e.json +1 -0
  30. package/src/graphify-out/cache/41c4b7c27e7fc3e2948d3a4bf95a72de2ed9a6f0463994babdce8ed2cc84598c.json +1 -0
  31. package/src/graphify-out/cache/5028defd54b7fbd3c7e444973e493de036e097e9b1d2a7cae7f19b88d68aacde.json +1 -0
  32. package/src/graphify-out/cache/5b133aba3fb16410c5b1fdbd1730039fc7fa1ac93abd99d7be08f60da70fc8d4.json +1 -0
  33. package/src/graphify-out/cache/74252e5b0978d85ab3421a3de1a9384aa282ffd2be2cfe7db2530139089f4275.json +1 -0
  34. package/src/graphify-out/cache/7695ebeea056095edd14332963cc43354ef3a097caf46f1e28d0f01369642901.json +1 -0
  35. package/src/graphify-out/cache/777aa7085c395a935c6556bbde182cd871edb61f3a685ed8068ec0c8f6fb0075.json +1 -0
  36. package/src/graphify-out/cache/82a723881980e82273c113def8315533d7da28827e300413d9ad30f27b7407df.json +1 -0
  37. package/src/graphify-out/cache/86b87c9603e6cd188f42c7eed3b86c291d48a781c223a707e74f3e7ed0c02a21.json +1 -0
  38. package/src/graphify-out/cache/890fead9a78cadaed560a2d2453916121fa605c3e43a334910ac4bc951a9ef6d.json +1 -0
  39. package/src/graphify-out/cache/89d3ea66f52783caa775ef9a30923d7d6225e1d8ae9e962f4741b8c7785dab1e.json +1 -0
  40. package/src/graphify-out/cache/8cc82cd9edce41f0e1c092f14a94fd52bf847addf3237b616dc5a9e505bd05bd.json +1 -0
  41. package/src/graphify-out/cache/93ba2e25e3ff7ad525f397902345fbd375df7315de7b402e20cc803c14eccde8.json +1 -0
  42. package/src/graphify-out/cache/99beed29580b9c7bfecfee794cb3d8e535fcf0eb3b92113108f88bdd0a8e79b3.json +1 -0
  43. package/src/graphify-out/cache/aeeb931fa477c65ce2e51d8149957350fa54225c613222bbbe8448998d1afd3d.json +1 -0
  44. package/src/graphify-out/cache/baf91bef5b5ecb2a476433b6cc0c48c563c54ee2d07fc3c192e543685e3e7222.json +1 -0
  45. package/src/graphify-out/cache/bd98b94ac4e9b92b6336d47b26e0366b51a4eaf0711d722f05f98dfae23ab42b.json +1 -0
  46. package/src/graphify-out/cache/bfcb51e9328e9cbfbee4f6fee0f56635d7b03488addc9f6c4e4b190b70a73362.json +1 -0
  47. package/src/graphify-out/cache/c0d3dabeb093aa758c49eadf41b87ecc96a16c1449c2670aaf48cbfc891d8da6.json +1 -0
  48. package/src/graphify-out/cache/c20d6630236f473c1406068c3ae205853e649b216495c93dfec055dd222c55cf.json +1 -0
  49. package/src/graphify-out/cache/c22b9122816bebce0a2f79af41a986559d01e00163dbcd579c5755621b4cb483.json +1 -0
  50. package/src/graphify-out/cache/ca556ec14453ddb8f9e0c5a832dac90d77111b9bad5f8c2d80d272e2e7a06371.json +1 -0
  51. package/src/graphify-out/cache/d6dbc9135dfa35a756b3b09b06700e4bc229fdccba11bb963f2ba44028e0bbae.json +1 -0
  52. package/src/graphify-out/cache/e1cf71276f1779d0fa075f79bd7c8a9fd0b8eef6932ac043137451b7c7fa7cbe.json +1 -0
  53. package/src/graphify-out/cache/e4b3be14494467df2d2ed389bc4f18f099021cb5bc355b901fa88387b2d8b8a2.json +1 -0
  54. package/src/graphify-out/cache/eaea0dded097f6f9553b654220046c6ec0c9be592a5973d906564ee60af34e0d.json +1 -0
  55. package/src/graphify-out/cache/ef07d0cd2675d1f79d2a2fdbf3bc3319687638751e9ce89b0d0d97ed1cd9f7e1.json +1 -0
  56. package/src/graphify-out/cache/f81272d6eb8aaff9e96d5a1d9f06777db70ac3652a646b951ded51f79871d733.json +1 -0
  57. package/src/graphify-out/cache/f9619dd92186f75a6dbda937e0c606647153918524cdb5763f956e6ec2a9e386.json +1 -0
  58. package/src/graphify-out/cache/fd88b1b2ff4bfcae08559d9c2aaeeb9a3f1e2f5cd8928762c311196956c170a5.json +1 -0
  59. package/src/key-handler.js +322 -114
  60. package/src/kilo.js +20 -1
  61. package/src/opencode.js +23 -2
  62. package/src/overlays.js +199 -98
  63. package/src/provider-metadata.js +26 -17
  64. package/src/quota-capabilities.js +6 -10
  65. package/src/render-helpers.js +38 -8
  66. package/src/render-table.js +119 -248
  67. package/src/router-daemon.js +1986 -0
  68. package/src/router-dashboard.js +902 -0
  69. package/src/sync-set.js +479 -0
  70. package/src/theme.js +4 -0
  71. package/src/tool-launchers.js +1 -0
  72. package/src/tool-metadata.js +6 -2
  73. package/src/utils.js +30 -6
  74. package/web/dist/assets/{index-C03JjCgA.js → index-DKHCzbK1.js} +2 -2
  75. package/web/dist/index.html +1 -1
@@ -47,7 +47,7 @@
47
47
  */
48
48
 
49
49
  import chalk from 'chalk'
50
- import { OVERLAY_PANEL_WIDTH, TABLE_FIXED_LINES } from './constants.js'
50
+ import { OVERLAY_PANEL_WIDTH, TABLE_FIXED_LINES, TABLE_HEADER_LINES, TABLE_FOOTER_LINES } from './constants.js'
51
51
  import { sortResults } from './utils.js'
52
52
 
53
53
  // 📖 stripAnsi: Remove ANSI color/control sequences to estimate visible text width before padding.
@@ -65,13 +65,32 @@ export function maskApiKey(key) {
65
65
 
66
66
  // 📖 displayWidth: Calculate display width of a string in terminal columns.
67
67
  // 📖 Emojis and other wide characters occupy 2 columns, variation selectors (U+FE0F) are zero-width.
68
+ // 📖 Keycap sequences (digit/# + FE0F + 20E3, e.g. 1️⃣) render as a single 2-cell glyph.
68
69
  // 📖 This avoids pulling in a full `string-width` dependency for a lightweight CLI tool.
69
70
  export function displayWidth(str) {
70
71
  const plain = stripAnsi(String(str))
72
+ const codepoints = [...plain]
71
73
  let w = 0
72
- for (const ch of plain) {
74
+ for (let i = 0; i < codepoints.length; i++) {
75
+ const ch = codepoints[i]
73
76
  const cp = ch.codePointAt(0)
74
- // Zero-width: variation selectors (FE00-FE0F), zero-width joiner/non-joiner, combining marks
77
+
78
+ // Keycap sequence detection: ASCII digit / # / * followed by optional FE0F then 20E3 → +2 (single emoji glyph)
79
+ const isKeycapBase = (cp >= 0x30 && cp <= 0x39) || cp === 0x23 || cp === 0x2A
80
+ if (isKeycapBase) {
81
+ let j = i + 1
82
+ let sawFe0f = false
83
+ if (j < codepoints.length && codepoints[j].codePointAt(0) === 0xFE0F) { sawFe0f = true; j++ }
84
+ if (j < codepoints.length && codepoints[j].codePointAt(0) === 0x20E3) {
85
+ w += 2
86
+ i = j // 📖 skip the consumed FE0F (if any) and the 20E3
87
+ continue
88
+ }
89
+ // 📖 Not a keycap, fall through to normal handling
90
+ void sawFe0f
91
+ }
92
+
93
+ // Zero-width: variation selectors (FE00-FE0F), zero-width joiner/non-joiner, lone combining keycap
75
94
  if ((cp >= 0xFE00 && cp <= 0xFE0F) || cp === 0x200D || cp === 0x200C || cp === 0x20E3) continue
76
95
  // Wide: CJK, emoji (most above U+1F000), fullwidth forms
77
96
  if (
@@ -146,14 +165,24 @@ export function sliceOverlayLines(lines, offset, terminalRows) {
146
165
 
147
166
  // ─── Table viewport calculation ────────────────────────────────────────────────
148
167
 
168
+ // 📖 getTableFixedLines: Resolve the non-model line budget for the main table.
169
+ // 📖 Header and full footer are always visible in the main table, with optional
170
+ // 📖 extra fixed rows for temporary banners.
171
+ export function getTableFixedLines({ extraFixedLines = 0 } = {}) {
172
+ return TABLE_HEADER_LINES + TABLE_FOOTER_LINES + Math.max(0, extraFixedLines)
173
+ }
174
+
149
175
  // 📖 calculateViewport: Computes the visible slice of model rows that fits in the terminal.
150
176
  // 📖 When scroll indicators are needed, they each consume 1 line from the model budget.
151
- // 📖 `extraFixedLines` lets callers reserve temporary footer rows without shrinking the
152
- // 📖 viewport permanently for the normal case.
177
+ // 📖 `lineBudget` lets callers reserve temporary footer/header rows without shrinking
178
+ // 📖 the viewport permanently for the normal case.
153
179
  // 📖 Returns { startIdx, endIdx, hasAbove, hasBelow } for rendering.
154
- export function calculateViewport(terminalRows, scrollOffset, totalModels, extraFixedLines = 0) {
180
+ export function calculateViewport(terminalRows, scrollOffset, totalModels, lineBudget = 0) {
155
181
  if (terminalRows <= 0) return { startIdx: 0, endIdx: totalModels, hasAbove: false, hasBelow: false }
156
- let maxSlots = terminalRows - TABLE_FIXED_LINES - extraFixedLines
182
+ const fixedLines = typeof lineBudget === 'number'
183
+ ? TABLE_FIXED_LINES + Math.max(0, lineBudget)
184
+ : getTableFixedLines(lineBudget)
185
+ let maxSlots = terminalRows - fixedLines
157
186
  if (maxSlots < 1) maxSlots = 1
158
187
  if (totalModels <= maxSlots) return { startIdx: 0, endIdx: totalModels, hasAbove: false, hasBelow: false }
159
188
 
@@ -206,7 +235,8 @@ export function sortResultsWithPinnedFavorites(results, sortColumn, sortDirectio
206
235
  // 📖 Modifies st.scrollOffset in-place, returns undefined.
207
236
  export function adjustScrollOffset(st) {
208
237
  const total = st.visibleSorted ? st.visibleSorted.length : st.results.filter(r => !r.hidden).length
209
- let maxSlots = st.terminalRows - TABLE_FIXED_LINES
238
+ const fixedLines = getTableFixedLines()
239
+ let maxSlots = st.terminalRows - fixedLines
210
240
  if (maxSlots < 1) maxSlots = 1
211
241
  if (total <= maxSlots) { st.scrollOffset = 0; return }
212
242
  // Ensure cursor is not above the visible window
@@ -36,19 +36,18 @@ 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'
@@ -58,10 +57,6 @@ import { detectPackageManager, getManualInstallCmd } from './updater.js'
58
57
  const require = createRequire(import.meta.url)
59
58
  const { version: LOCAL_VERSION } = require('../package.json')
60
59
 
61
- // 📖 HEALTH_CYCLE: cycles through health/status states (local constant for render-table.js)
62
- // VERDICT_CYCLE is now imported from constants.js
63
- const HEALTH_CYCLE = [null, 'up', 'timeout', 'down', 'auth_error', 'noauth', 'pending']
64
-
65
60
  // 📖 Mouse support: column boundary map updated every frame by renderTable().
66
61
  // 📖 Each entry maps a column name to its display X-start and X-end (1-based, inclusive).
67
62
  // 📖 headerRow is the 1-based terminal row of the column header line.
@@ -109,9 +104,10 @@ export const PROVIDER_COLOR = new Proxy({}, {
109
104
  })
110
105
 
111
106
  // ─── renderTable: mode param controls footer hint text (opencode vs openclaw) ─────────
112
- 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) {
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) {
113
108
  // 📖 Filter out hidden models for display
114
109
  const visibleResults = results.filter(r => !r.hidden)
110
+ void legacyFooterHidden
115
111
 
116
112
  const up = visibleResults.filter(r => r.status === 'up').length
117
113
  const down = visibleResults.filter(r => r.status === 'down').length
@@ -323,80 +319,8 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
323
319
  themeColors.warning(`⏳ ${timeout}`) + themeColors.dim(' timeout ') +
324
320
  themeColors.error(`❌ ${down}`) + themeColors.dim(' down ') +
325
321
  '',
326
- '',
327
322
  ]
328
323
 
329
- // 📖 Filter bar — llmfit-style horizontal filter pills (1 dedicated row above table)
330
- // 📖 Each block: title with hotkey hint + active value colored by filter state
331
- {
332
- const filterParts = []
333
- const filterSep = themeColors.dim(' │ ')
334
- const blockSep = ' │ '
335
-
336
- // 📖 Search filter block — shows active text filter or prompt
337
- if (customTextFilter && customTextFilter.trim()) {
338
- const badgeText = ` Search "/" ${blockSep} ${customTextFilter.trim().slice(0, 20)} `
339
- filterParts.push(themeColors.badge(badgeText, [52, 120, 88], [255, 255, 255]))
340
- } else {
341
- filterParts.push(themeColors.dim(' Search "/" '))
342
- }
343
-
344
- // 📖 Tier filter block — T key cycles through TIER_CYCLE
345
- if (tierFilterMode > 0) {
346
- const tierLabel = TIER_CYCLE_NAMES[tierFilterMode]
347
- const tierBg = getTierRgb(tierLabel)
348
- filterParts.push(themeColors.badge(` Tier (${tierLabel}) `, tierBg, [255, 255, 255]))
349
- } else {
350
- filterParts.push(themeColors.dim(' Tier (T) '))
351
- }
352
-
353
- // 📖 Provider filter block — D key cycles through providers
354
- if (originFilterMode > 0) {
355
- const originKeys = [null, ...Object.keys(sources)]
356
- const activeOriginKey = originKeys[originFilterMode]
357
- const activeOriginName = activeOriginKey ? sources[activeOriginKey]?.name ?? activeOriginKey : null
358
- if (activeOriginName) {
359
- const normName = normalizeOriginLabel(activeOriginName, activeOriginKey)
360
- const providerRgb = PROVIDER_COLOR[activeOriginKey] || [255, 255, 255]
361
- filterParts.push(themeColors.badge(` Provider (${normName}) `, providerRgb, [255, 255, 255]))
362
- }
363
- } else {
364
- filterParts.push(themeColors.dim(' Provider (D) '))
365
- }
366
-
367
- // 📖 Verdict filter block — V key cycles through verdicts
368
- if (verdictFilterMode > 0) {
369
- const verdictLabel = VERDICT_CYCLE[verdictFilterMode]
370
- const verdictColors = {
371
- 'Perfect': themeColors.success,
372
- 'Normal': themeColors.metricGood,
373
- 'Slow': (t) => chalk.bold.rgb(...getTierRgb('A-'))(t),
374
- 'Spiky': (t) => chalk.bold.rgb(...getTierRgb('A+'))(t),
375
- 'Very Slow': (t) => chalk.bold.rgb(...getTierRgb('B+'))(t),
376
- 'Overloaded': (t) => chalk.bold.rgb(...getTierRgb('B'))(t),
377
- 'Unstable': themeColors.errorBold,
378
- 'Not Active': themeColors.dim,
379
- 'Pending': themeColors.dim,
380
- }
381
- const vc = verdictColors[verdictLabel] || themeColors.accent
382
- filterParts.push(themeColors.badge(` Verdict (${verdictLabel}) `, [20, 20, 20], vc === themeColors.dim ? [130, 130, 130] : [255, 255, 255]))
383
- } else {
384
- filterParts.push(themeColors.dim(' Verdict (V) '))
385
- }
386
-
387
- // 📖 Health filter block — H key cycles through health states
388
- if (healthFilterMode > 0) {
389
- const healthLabel = HEALTH_CYCLE[healthFilterMode]
390
- const healthDisplay = healthLabel === 'auth_error' ? 'Auth Err' : healthLabel === 'noauth' ? 'No Key' : healthLabel.charAt(0).toUpperCase() + healthLabel.slice(1)
391
- const healthBg = healthLabel === 'up' ? [52, 120, 88] : healthLabel === 'timeout' ? [180, 130, 0] : healthLabel === 'down' ? [120, 40, 40] : [60, 60, 60]
392
- filterParts.push(themeColors.badge(` Health (${healthDisplay}) `, healthBg, [255, 255, 255]))
393
- } else {
394
- filterParts.push(themeColors.dim(' Health (H) '))
395
- }
396
-
397
- lines.push(filterParts.join(blockSep))
398
- }
399
-
400
324
  // 📖 Header row with sorting indicators
401
325
  // 📖 NOTE: padEnd on chalk strings counts ANSI codes, breaking alignment
402
326
  // 📖 Solution: build plain text first, then colorize
@@ -495,8 +419,11 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
495
419
 
496
420
  // 📖 Viewport clipping: only render models that fit on screen
497
421
  const hasCustomFilter = typeof customTextFilter === 'string' && customTextFilter.trim().length > 0
498
- const extraFooterLines = versionStatus.isOutdated ? 1 : 0
499
- 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
+ })
500
427
  const paintSweScore = (score, paddedText) => {
501
428
  if (score >= 70) return chalk.bold.rgb(...getTierRgb('S+'))(paddedText)
502
429
  if (score >= 60) return chalk.bold.rgb(...getTierRgb('S'))(paddedText)
@@ -537,10 +464,14 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
537
464
  ? providerName.slice(0, 4) + '…'
538
465
  : providerName
539
466
  const source = themeColors.provider(r.providerKey, providerDisplay.padEnd(wSource))
540
- // 📖 Favorites: always reserve 2 display columns at the start of Model column.
541
- // 📖 🎯 (2 cols) for recommended, ⭐ (2 cols) for favorites, ' ' (2 spaces) for non-favorites — keeps alignment stable.
542
- const favoritePrefix = r.isRecommended ? '🎯' : r.isFavorite ? '⭐' : ' '
543
- const prefixDisplayWidth = 2
467
+ // 📖 Favorites marked with a single no ranking numbers
468
+ let favoritePrefix = ''
469
+ if (r.isRecommended) {
470
+ favoritePrefix = '🎯 '
471
+ } else if (r.isFavorite) {
472
+ favoritePrefix = '⭐ '
473
+ }
474
+ const prefixDisplayWidth = displayWidth(favoritePrefix)
544
475
  const nameWidth = Math.max(0, W_MODEL - prefixDisplayWidth)
545
476
  const name = favoritePrefix + r.label.slice(0, nameWidth).padEnd(nameWidth)
546
477
  const sweScore = r.sweScore ?? '—'
@@ -786,7 +717,13 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
786
717
  lines.push(themeColors.dim(` ... ${sorted.length - vp.endIdx} more below ...`))
787
718
  }
788
719
 
789
- 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
+
790
727
  // 📖 Footer hints keep only navigation and secondary actions now that the
791
728
  // 📖 active tool target is already visible in the header badge.
792
729
  const hotkey = (keyLabel, text) => themeColors.hotkey(keyLabel) + themeColors.dim(text)
@@ -794,8 +731,6 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
794
731
  // 📖 states are obvious even when the user misses the smaller header badges.
795
732
  const configuredBadgeBg = getTheme() === 'dark' ? [52, 120, 88] : [195, 234, 206]
796
733
  const activeHotkey = (keyLabel, text, bg) => themeColors.badge(`${keyLabel}${text}`, bg, getReadableTextRgb(bg))
797
- const favoritesModeBg = favoritesPinnedAndSticky ? [157, 122, 48] : [95, 95, 95]
798
- const favoritesModeLabel = favoritesPinnedAndSticky ? ' Favorites Pinned' : ' Favorites Normal'
799
734
 
800
735
  // 📖 Mouse support: build footer hotkey zones alongside the footer lines.
801
736
  // 📖 Each zone records { key, row (1-based terminal row), xStart, xEnd (1-based display cols) }.
@@ -807,21 +742,19 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
807
742
  {
808
743
  const parts = [
809
744
  { text: ' ', key: null },
810
- { text: 'F Toggle Favorite', key: 'f' },
745
+ { text: 'F Favorite', key: 'f' },
811
746
  { text: ' • ', key: null },
812
- { text: 'Y' + favoritesModeLabel, key: 'y' },
747
+ { text: 'Y Fav Mode', key: 'y' },
813
748
  { text: ' • ', key: null },
814
749
  { text: tierFilterMode > 0 ? `T Tier (${activeTierLabel})` : 'T Tier', key: 't' },
815
750
  { text: ' • ', key: null },
816
751
  { text: originFilterMode > 0 ? `D Provider (${activeOriginLabel})` : 'D Provider', key: 'd' },
817
752
  { text: ' • ', key: null },
818
- { text: 'E Show only configured models', key: 'e' },
753
+ { text: 'E Active only', key: 'e' },
819
754
  { text: ' • ', key: null },
820
755
  { text: 'P Settings', key: 'p' },
821
756
  { text: ' • ', key: null },
822
- { text: 'J/K Navigate', key: null },
823
- { text: ' • ', key: null },
824
- { text: 'Ctrl+H Help', key: 'ctrl+h' },
757
+ { text: 'I Help', key: 'i' },
825
758
  ]
826
759
  const footerRow1 = lines.length + 1 // 📖 1-based terminal row (line hasn't been pushed yet)
827
760
  let xPos = 1
@@ -832,170 +765,108 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
832
765
  }
833
766
  }
834
767
 
835
- if (!footerHidden) {
836
- // 📖 Full footer all hint lines hidden when footerHidden=true to maximize table space
837
- lines.push(
838
- ' ' + hotkey('F', ' Toggle Favorite') +
839
- themeColors.dim(` • `) +
840
- activeHotkey('Y', favoritesModeLabel, favoritesModeBg) +
841
- themeColors.dim(` • `) +
842
- (tierFilterMode > 0
843
- ? activeHotkey('T', ` Tier (${activeTierLabel})`, getTierRgb(activeTierLabel))
844
- : hotkey('T', ' Tier')) +
845
- themeColors.dim(` • `) +
846
- (originFilterMode > 0
847
- ? activeHotkey('D', ` Provider (${activeOriginLabel})`, PROVIDER_COLOR[[null, ...Object.keys(sources)][originFilterMode]] || [255, 255, 255])
848
- : hotkey('D', ' Provider')) +
849
- themeColors.dim(` • `) +
850
- (hideUnconfiguredModels ? activeHotkey('E', ' Show only configured models', configuredBadgeBg) : hotkey('E', ' Show only configured models')) +
851
- themeColors.dim(` • `) +
852
- hotkey('P', ' Settings') +
853
- themeColors.dim(` • `) +
854
- themeColors.dim('J/K Navigate') +
855
- themeColors.dim(` • `) +
856
- themeColors.dim('Ctrl+H Help')
857
- )
858
-
859
- // 📖 Line 2: command palette, recommend, feedback, theme
860
- {
861
- const cpText = ' CTRL+P ⚡️ Command Palette '
862
- const parts = [
863
- { text: ' ', key: null },
864
- { text: cpText, key: 'ctrl+p' },
865
- { text: ' • ', key: null },
866
- { text: 'Q Smart Recommend', key: 'q' },
867
- { text: ' • ', key: null },
868
- { text: 'G Theme', key: 'g' },
869
- { text: ' • ', key: null },
870
- { text: 'I Feedback, bugs & requests', key: 'i' },
871
- ]
872
- const footerRow2 = lines.length + 1
873
- let xPos = 1
874
- for (const part of parts) {
875
- const w = displayWidth(part.text)
876
- if (part.key) footerHotkeys.push({ key: part.key, row: footerRow2, xStart: xPos, xEnd: xPos + w - 1 })
877
- xPos += w
878
- }
879
- }
880
-
881
- // 📖 Line 2: command palette (highlighted as new), recommend, feedback, and extended hints.
882
- // 📖 CTRL+P ⚡️ Command Palette uses neon-green-on-dark-green background to highlight the feature.
883
- const paletteLabel = chalk.bgRgb(0, 60, 0).rgb(57, 255, 20).bold(' CTRL+P ⚡️ Command Palette ')
884
- lines.push(
885
- ' ' + paletteLabel + themeColors.dim(` • `) +
886
- hotkey('Q', ' Smart Recommend') + themeColors.dim(` • `) +
887
- hotkey('G', ' Theme') + themeColors.dim(` • `) +
888
- hotkey('I', ' Feedback, bugs & requests')
889
- )
890
- // 📖 Proxy status is now shown via the badge in line 2 above — no need for a dedicated line
891
- const footerLine =
892
- themeColors.footerLove(' Made with 💖 & ☕ by \x1b]8;;https://github.com/vava-nessa\x1b\\vava-nessa\x1b]8;;\x1b\\') +
893
- themeColors.dim(' • ') +
894
- '⭐ ' +
895
- themeColors.link('\x1b]8;;https://github.com/vava-nessa/free-coding-models\x1b\\Star on GitHub\x1b]8;;\x1b\\') +
896
- themeColors.dim(' • ') +
897
- '🤝 ' +
898
- themeColors.warning('\x1b]8;;https://github.com/vava-nessa/free-coding-models/graphs/contributors\x1b\\Contributors\x1b]8;;\x1b\\') +
899
- themeColors.dim(' • ') +
900
- '☕ ' +
901
- themeColors.footerCoffee('\x1b]8;;https://buymeacoffee.com/vavanessadev\x1b\\Buy me a coffee\x1b]8;;\x1b\\')
902
- lines.push(footerLine)
903
-
904
- if (versionStatus.isOutdated) {
905
- const updateMsg = ` 🚀⬆️ UPDATE AVAILABLE — v${LOCAL_VERSION} → v${versionStatus.latestVersion} • Click here or press Shift+U to update 🚀⬆️ `
906
- const paddedBanner = terminalCols > 0
907
- ? updateMsg + ' '.repeat(Math.max(0, terminalCols - displayWidth(updateMsg)))
908
- : updateMsg
909
- const fluoGreenBanner = chalk.bgRgb(57, 255, 20).rgb(0, 0, 0).bold(paddedBanner)
910
- const updateBannerRow = lines.length + 1
911
- _lastLayout.updateBannerRow = updateBannerRow
912
- footerHotkeys.push({ key: 'update-click', row: updateBannerRow, xStart: 1, xEnd: Math.max(terminalCols, displayWidth(updateMsg)) })
913
- lines.push(fluoGreenBanner)
914
- } else {
915
- _lastLayout.updateBannerRow = 0
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
916
802
  }
803
+ }
917
804
 
918
- // 📖 Final footer line: changelog + optional active text-filter badge + exit hint.
919
- let filterBadge = ''
920
- if (hasCustomFilter) {
921
- const normalizedFilter = customTextFilter.trim().replace(/\s+/g, ' ')
922
- const filterPrefix = 'X Disable filter: "'
923
- const filterSuffix = '"'
924
- const separatorPlain = ' • '
925
- const baseFooterPlain = ' N Changelog' + separatorPlain + 'Ctrl+C Exit'
926
- const baseBadgeWidth = displayWidth(` ${filterPrefix}${filterSuffix} `)
927
- const availableFilterWidth = terminalCols > 0
928
- ? Math.max(8, terminalCols - displayWidth(baseFooterPlain) - displayWidth(separatorPlain) - baseBadgeWidth)
929
- : normalizedFilter.length
930
- const visibleFilter = normalizedFilter.length > availableFilterWidth
931
- ? `${normalizedFilter.slice(0, Math.max(3, availableFilterWidth - 3))}...`
932
- : normalizedFilter
933
- filterBadge = chalk.bgYellow.black.bold(` ${filterPrefix}${visibleFilter}${filterSuffix} `)
934
- }
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
+ }
935
827
 
936
- // 📖 Mouse support: track last footer line hotkey zones
937
- {
938
- const lastFooterRow = lines.length + 1 // 📖 1-based terminal row (line about to be pushed)
939
- const parts = [
940
- { text: ' ', key: null },
941
- { text: 'N Changelog', key: 'n' },
942
- ]
943
- if (hasCustomFilter) {
944
- parts.push({ text: ' • ', key: null })
945
- // 📖 X key clears filter — compute width from rendered badge text
946
- const badgePlain = `X Disable filter: "${customTextFilter.trim().replace(/\s+/g, ' ')}"`
947
- parts.push({ text: ` ${badgePlain} `, key: 'x' })
948
- }
949
- let xPos = 1
950
- for (const part of parts) {
951
- const w = displayWidth(part.text)
952
- if (part.key) footerHotkeys.push({ key: part.key, row: lastFooterRow, xStart: xPos, xEnd: xPos + w - 1 })
953
- xPos += w
954
- }
955
- }
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
+ }
956
845
 
957
- const releaseLabel = lastReleaseDate
958
- ? chalk.rgb(255, 182, 193)(`Last release: ${lastReleaseDate}`)
959
- : ''
960
-
961
- lines.push(
962
- ' ' + themeColors.hotkey('N') + themeColors.dim(' Changelog') +
963
- (filterBadge
964
- ? themeColors.dim('') + filterBadge
965
- : '') +
966
- themeColors.dim(' ') +
967
- themeColors.dim('Ctrl+C Exit') +
968
- (releaseLabel ? themeColors.dim(' • ') + releaseLabel : '')
969
- )
970
-
971
- // 📖 Discord link at the very bottom of the TUI
972
- lines.push(
973
- ' 💬 ' +
974
- themeColors.footerDiscord('\x1b]8;;https://discord.gg/ZTNFHvvCkU\x1b\\Join the Discord\x1b]8;;\x1b\\') +
975
- themeColors.dim(' → ') +
976
- themeColors.footerDiscord('https://discord.gg/ZTNFHvvCkU')
977
- )
978
- } else {
979
- // 📖 Collapsed footer: single line with toggle hint
980
- lines.push(
981
- ' ' + themeColors.hotkey('Ctrl+O') + themeColors.dim(' Toggle Footer') +
982
- themeColors.dim(' • Ctrl+C Exit')
983
- )
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)
984
856
  }
985
857
 
858
+ const releaseLabel = lastReleaseDate
859
+ ? chalk.rgb(255, 182, 193)(`Last release: ${lastReleaseDate}`)
860
+ : ''
861
+
862
+ if (releaseLabel) lines.push(' ' + releaseLabel)
986
863
  _lastLayout.footerHotkeys = footerHotkeys
987
864
 
988
865
  // 📖 Append \x1b[K (erase to EOL) to each line so leftover chars from previous
989
- // 📖 frames are cleared. \x1b[J (erase from cursor to end of screen) clears any
990
- // 📖 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.
991
868
  const EL = '\x1b[K'
992
869
  const cleared = lines.map(l => l + EL)
993
- if (footerHidden) {
994
- // 📖 When footer is hidden, \x1b[J erases stale footer content below the cursor
995
- cleared.push('\x1b[J')
996
- } else {
997
- const remaining = terminalRows > 0 ? Math.max(0, terminalRows - cleared.length) : 0
998
- for (let i = 0; i < remaining; i++) cleared.push(EL)
999
- }
870
+ if (cleared.length > 0) cleared[cleared.length - 1] += '\x1b[J'
1000
871
  return cleared.join('\n')
1001
872
  }