free-coding-models 0.1.23 → 0.1.25

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/README.md CHANGED
@@ -7,6 +7,10 @@
7
7
 
8
8
  <h1 align="center">free-coding-models</h1>
9
9
 
10
+ <p align="center">
11
+ <strong>Want to contribute or discuss the project?</strong> Join our <a href="https://discord.gg/U4vz7mYQ">Discord community</a>!
12
+ </p>
13
+
10
14
  <p align="center">
11
15
 
12
16
  ```
@@ -266,10 +266,15 @@ async function promptUpdateNotification(latestVersion) {
266
266
  // ─── Alternate screen control ─────────────────────────────────────────────────
267
267
  // 📖 \x1b[?1049h = enter alt screen \x1b[?1049l = leave alt screen
268
268
  // 📖 \x1b[?25l = hide cursor \x1b[?25h = show cursor
269
- // 📖 \x1b[H = cursor to top \x1b[2J = clear screen
270
- const ALT_ENTER = '\x1b[?1049h\x1b[?25l'
271
- const ALT_LEAVE = '\x1b[?1049l\x1b[?25h'
272
- const ALT_CLEAR = '\x1b[H\x1b[2J'
269
+ // 📖 \x1b[H = cursor to top
270
+ // 📖 NOTE: We avoid \x1b[2J (clear screen) because Ghostty scrolls cleared
271
+ // 📖 content into the scrollback on the alt screen, pushing the header off-screen.
272
+ // 📖 Instead we overwrite in place: cursor home, then \x1b[K (erase to EOL) per line.
273
+ // 📖 \x1b[?7l disables auto-wrap so wide rows clip at the right edge instead of
274
+ // 📖 wrapping to the next line (which would double the row height and overflow).
275
+ const ALT_ENTER = '\x1b[?1049h\x1b[?25l\x1b[?7l'
276
+ const ALT_LEAVE = '\x1b[?7h\x1b[?1049l\x1b[?25h'
277
+ const ALT_HOME = '\x1b[H'
273
278
 
274
279
  // ─── API Configuration ───────────────────────────────────────────────────────────
275
280
  // 📖 Models are now loaded from sources.js to support multiple providers
@@ -321,11 +326,31 @@ const spinCell = (f, o = 0) => chalk.dim.yellow(FRAMES[(f + o) % FRAMES.length].
321
326
  // 📖 Core logic functions (getAvg, getVerdict, getUptime, sortResults, etc.)
322
327
  // 📖 are imported from lib/utils.js for testability
323
328
 
329
+ // ─── Viewport calculation ────────────────────────────────────────────────────
330
+ // 📖 Computes the visible slice of model rows that fits in the terminal.
331
+ // 📖 Fixed lines: 5 header + 5 footer = 10 lines always consumed.
332
+ // 📖 Header: empty, title, empty, column headers, separator (5)
333
+ // 📖 Footer: empty, hints, empty, credit, empty (5)
334
+ // 📖 When scroll indicators are needed, they each consume 1 line from the model budget.
335
+ function calculateViewport(terminalRows, scrollOffset, totalModels) {
336
+ if (terminalRows <= 0) return { startIdx: 0, endIdx: totalModels, hasAbove: false, hasBelow: false }
337
+ let maxSlots = terminalRows - 10 // 5 header + 5 footer
338
+ if (maxSlots < 1) maxSlots = 1
339
+ if (totalModels <= maxSlots) return { startIdx: 0, endIdx: totalModels, hasAbove: false, hasBelow: false }
340
+
341
+ const hasAbove = scrollOffset > 0
342
+ const hasBelow = scrollOffset + maxSlots - (hasAbove ? 1 : 0) < totalModels
343
+ // Recalculate with indicator lines accounted for
344
+ const modelSlots = maxSlots - (hasAbove ? 1 : 0) - (hasBelow ? 1 : 0)
345
+ const endIdx = Math.min(scrollOffset + modelSlots, totalModels)
346
+ return { startIdx: scrollOffset, endIdx, hasAbove, hasBelow }
347
+ }
348
+
324
349
  // 📖 renderTable: mode param controls footer hint text (opencode vs openclaw)
325
- function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilter = null) {
350
+ 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) {
326
351
  // 📖 Filter out hidden models for display
327
352
  const visibleResults = results.filter(r => !r.hidden)
328
-
353
+
329
354
  const up = visibleResults.filter(r => r.status === 'up').length
330
355
  const down = visibleResults.filter(r => r.status === 'down').length
331
356
  const timeout = visibleResults.filter(r => r.status === 'timeout').length
@@ -358,8 +383,9 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
358
383
 
359
384
  // 📖 Tier filter badge shown when filtering is active
360
385
  let tierBadge = ''
361
- if (tierFilter) {
362
- tierBadge = chalk.bold.rgb(255, 200, 0)(` [Tier ${tierFilter}]`)
386
+ if (tierFilterMode > 0) {
387
+ const tierNames = ['All', 'S+/S', 'A+/A/A-', 'B+/B', 'C']
388
+ tierBadge = chalk.bold.rgb(255, 200, 0)(` [${tierNames[tierFilterMode]}]`)
363
389
  }
364
390
 
365
391
  // 📖 Column widths (generous spacing with margins)
@@ -429,7 +455,14 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
429
455
  chalk.dim('─'.repeat(W_UPTIME))
430
456
  )
431
457
 
432
- for (let i = 0; i < sorted.length; i++) {
458
+ // 📖 Viewport clipping: only render models that fit on screen
459
+ const vp = calculateViewport(terminalRows, scrollOffset, sorted.length)
460
+
461
+ if (vp.hasAbove) {
462
+ lines.push(chalk.dim(` ... ${vp.startIdx} more above ...`))
463
+ }
464
+
465
+ for (let i = vp.startIdx; i < vp.endIdx; i++) {
433
466
  const r = sorted[i]
434
467
  const tierFn = TIER_COLOR[r.tier] ?? (t => chalk.white(t))
435
468
 
@@ -555,6 +588,10 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
555
588
  }
556
589
  }
557
590
 
591
+ if (vp.hasBelow) {
592
+ lines.push(chalk.dim(` ... ${sorted.length - vp.endIdx} more below ...`))
593
+ }
594
+
558
595
  lines.push('')
559
596
  const intervalSec = Math.round(pingInterval / 1000)
560
597
 
@@ -564,11 +601,20 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
564
601
  : mode === 'opencode-desktop'
565
602
  ? chalk.rgb(0, 200, 255)('Enter→OpenDesktop')
566
603
  : chalk.rgb(0, 200, 255)('Enter→OpenCode')
567
- lines.push(chalk.dim(` ↑↓ Navigate • `) + actionHint + chalk.dim(` • R/T/O/M/P/A/S/V/U Sort • W↓/X↑ Interval (${intervalSec}s) • E↑/D↓ Tier • Z Mode • Ctrl+C Exit`))
604
+ lines.push(chalk.dim(` ↑↓ Navigate • `) + actionHint + chalk.dim(` • R/T/O/M/P/A/S/V/U Sort • W↓/X↑ Interval (${intervalSec}s) • T Tier • Z Mode • Ctrl+C Exit`))
568
605
  lines.push('')
569
- lines.push(chalk.dim(' made with ') + '🩷' + chalk.dim(' by vava-nessa • ') + chalk.dim.underline('https://github.com/vava-nessa/free-coding-models'))
606
+ lines.push(chalk.dim(' Made with love by ') + '\x1b]8;;https://github.com/vava-nessa\x1b\\vava-nessa\x1b]8;;\x1b\\')
607
+ lines.push(chalk.dim(' 📂 Repository GitHub: ') + chalk.dim.underline('https://github.com/vava-nessa/free-coding-models'))
608
+ lines.push(chalk.dim(' 💬 Contribuer et discuter sur notre Discord: ') + chalk.dim.underline('https://discord.gg/U4vz7mYQ'))
570
609
  lines.push('')
571
- return lines.join('\n')
610
+ // 📖 Append \x1b[K (erase to EOL) to each line so leftover chars from previous
611
+ // 📖 frames are cleared. Then pad with blank cleared lines to fill the terminal,
612
+ // 📖 preventing stale content from lingering at the bottom after resize.
613
+ const EL = '\x1b[K'
614
+ const cleared = lines.map(l => l + EL)
615
+ const remaining = terminalRows > 0 ? Math.max(0, terminalRows - cleared.length) : 0
616
+ for (let i = 0; i < remaining; i++) cleared.push(EL)
617
+ return cleared.join('\n')
572
618
  }
573
619
 
574
620
  // ─── HTTP ping ────────────────────────────────────────────────────────────────
@@ -1030,6 +1076,31 @@ async function main() {
1030
1076
 
1031
1077
  // 📖 No initial filters - all models visible by default
1032
1078
 
1079
+ // 📖 Clamp scrollOffset so cursor is always within the visible viewport window.
1080
+ // 📖 Called after every cursor move, sort change, and terminal resize.
1081
+ const adjustScrollOffset = (st) => {
1082
+ const total = st.results.length
1083
+ let maxSlots = st.terminalRows - 10 // 5 header + 5 footer
1084
+ if (maxSlots < 1) maxSlots = 1
1085
+ if (total <= maxSlots) { st.scrollOffset = 0; return }
1086
+ // Ensure cursor is not above the visible window
1087
+ if (st.cursor < st.scrollOffset) {
1088
+ st.scrollOffset = st.cursor
1089
+ }
1090
+ // Ensure cursor is not below the visible window
1091
+ // Account for indicator lines eating into model slots
1092
+ const hasAbove = st.scrollOffset > 0
1093
+ const tentativeBelow = st.scrollOffset + maxSlots - (hasAbove ? 1 : 0) < total
1094
+ const modelSlots = maxSlots - (hasAbove ? 1 : 0) - (tentativeBelow ? 1 : 0)
1095
+ if (st.cursor >= st.scrollOffset + modelSlots) {
1096
+ st.scrollOffset = st.cursor - modelSlots + 1
1097
+ }
1098
+ // Final clamp
1099
+ const maxOffset = Math.max(0, total - maxSlots)
1100
+ if (st.scrollOffset > maxOffset) st.scrollOffset = maxOffset
1101
+ if (st.scrollOffset < 0) st.scrollOffset = 0
1102
+ }
1103
+
1033
1104
  // 📖 Add interactive selection state - cursor index and user's choice
1034
1105
  // 📖 sortColumn: 'rank'|'tier'|'origin'|'model'|'ping'|'avg'|'status'|'verdict'|'uptime'
1035
1106
  // 📖 sortDirection: 'asc' (default) or 'desc'
@@ -1046,8 +1117,16 @@ async function main() {
1046
1117
  pingInterval: PING_INTERVAL, // 📖 Track current interval for W/X keys
1047
1118
  lastPingTime: Date.now(), // 📖 Track when last ping cycle started
1048
1119
  mode, // 📖 'opencode' or 'openclaw' — controls Enter action
1120
+ scrollOffset: 0, // 📖 First visible model index in viewport
1121
+ terminalRows: process.stdout.rows || 24, // 📖 Current terminal height
1049
1122
  }
1050
1123
 
1124
+ // 📖 Re-clamp viewport on terminal resize
1125
+ process.stdout.on('resize', () => {
1126
+ state.terminalRows = process.stdout.rows || 24
1127
+ adjustScrollOffset(state)
1128
+ })
1129
+
1051
1130
  // 📖 Enter alternate screen — animation runs here, zero scrollback pollution
1052
1131
  process.stdout.write(ALT_ENTER)
1053
1132
 
@@ -1061,11 +1140,27 @@ async function main() {
1061
1140
  process.on('SIGINT', () => exit(0))
1062
1141
  process.on('SIGTERM', () => exit(0))
1063
1142
 
1064
- // 📖 No tier filtering by default - all models visible
1143
+ // 📖 Tier filtering system - cycles through filter modes
1144
+ let tierFilterMode = 0 // 0=all, 1=S+/S, 2=A+/A/A-, 3=B+/B, 4=C
1065
1145
  function applyTierFilter() {
1066
- // 📖 All models visible by default
1067
1146
  state.results.forEach(r => {
1068
- r.hidden = false
1147
+ switch (tierFilterMode) {
1148
+ case 0: // All tiers visible
1149
+ r.hidden = false
1150
+ break
1151
+ case 1: // S+ and S only
1152
+ r.hidden = !(r.tier === 'S+' || r.tier === 'S')
1153
+ break
1154
+ case 2: // A+, A, A- only
1155
+ r.hidden = !(r.tier === 'A+' || r.tier === 'A' || r.tier === 'A-')
1156
+ break
1157
+ case 3: // B+ and B only
1158
+ r.hidden = !(r.tier === 'B+' || r.tier === 'B')
1159
+ break
1160
+ case 4: // C only
1161
+ r.hidden = r.tier !== 'C'
1162
+ break
1163
+ }
1069
1164
  })
1070
1165
 
1071
1166
  return state.results
@@ -1096,6 +1191,7 @@ async function main() {
1096
1191
  state.sortColumn = col
1097
1192
  state.sortDirection = 'asc'
1098
1193
  }
1194
+ adjustScrollOffset(state)
1099
1195
  return
1100
1196
  }
1101
1197
 
@@ -1107,7 +1203,13 @@ async function main() {
1107
1203
  state.pingInterval = Math.min(60000, state.pingInterval + 1000)
1108
1204
  }
1109
1205
 
1110
- // 📖 Tier filtering removed for simplicity - all models visible by default
1206
+ // 📖 Tier toggle key: T = cycle through tier filters (all S+/S A+/A/A- → B+/B → C → all)
1207
+ if (key.name === 't') {
1208
+ tierFilterMode = (tierFilterMode + 1) % 5
1209
+ applyTierFilter()
1210
+ adjustScrollOffset(state)
1211
+ return
1212
+ }
1111
1213
 
1112
1214
  // 📖 Mode toggle key: Z = cycle through modes (CLI → Desktop → OpenClaw)
1113
1215
  if (key.name === 'z') {
@@ -1126,6 +1228,7 @@ async function main() {
1126
1228
  if (key.name === 'up') {
1127
1229
  if (state.cursor > 0) {
1128
1230
  state.cursor--
1231
+ adjustScrollOffset(state)
1129
1232
  }
1130
1233
  return
1131
1234
  }
@@ -1133,6 +1236,7 @@ async function main() {
1133
1236
  if (key.name === 'down') {
1134
1237
  if (state.cursor < results.length - 1) {
1135
1238
  state.cursor++
1239
+ adjustScrollOffset(state)
1136
1240
  }
1137
1241
  return
1138
1242
  }
@@ -1191,10 +1295,10 @@ async function main() {
1191
1295
  // 📖 Animation loop: clear alt screen + redraw table at FPS with cursor
1192
1296
  const ticker = setInterval(() => {
1193
1297
  state.frame++
1194
- process.stdout.write(ALT_CLEAR + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, null))
1298
+ process.stdout.write(ALT_HOME + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, tierFilterMode, state.scrollOffset, state.terminalRows))
1195
1299
  }, Math.round(1000 / FPS))
1196
1300
 
1197
- process.stdout.write(ALT_CLEAR + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, null))
1301
+ process.stdout.write(ALT_HOME + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, tierFilterMode, state.scrollOffset, state.terminalRows))
1198
1302
 
1199
1303
  // ── Continuous ping loop — ping all models every N seconds forever ──────────
1200
1304
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
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",