free-coding-models 0.3.24 → 0.3.26

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.
@@ -50,12 +50,47 @@ import { TIER_COLOR } from './tier-colors.js'
50
50
  import { getAvg, getVerdict, getUptime, getStabilityScore, getVersionStatusInfo } from './utils.js'
51
51
  import { usagePlaceholderForProvider } from './ping.js'
52
52
  import { calculateViewport, sortResultsWithPinnedFavorites, padEndDisplay, displayWidth } from './render-helpers.js'
53
- import { getToolMeta, TOOL_METADATA, TOOL_MODE_ORDER, COMPAT_COLUMN_SLOTS, getCompatibleTools, isModelCompatibleWithTool } from './tool-metadata.js'
53
+ import { getToolMeta, TOOL_METADATA, TOOL_MODE_ORDER, isModelCompatibleWithTool } from './tool-metadata.js'
54
54
  import { getColumnSpacing } from './ui-config.js'
55
55
 
56
56
  const require = createRequire(import.meta.url)
57
57
  const { version: LOCAL_VERSION } = require('../package.json')
58
58
 
59
+ // 📖 Mouse support: column boundary map updated every frame by renderTable().
60
+ // 📖 Each entry maps a column name to its display X-start and X-end (1-based, inclusive).
61
+ // 📖 headerRow is the 1-based terminal row of the column header line.
62
+ // 📖 firstModelRow/lastModelRow are the 1-based terminal rows of the first/last visible model row.
63
+ // 📖 Exported so the mouse handler can translate click coordinates into column/row targets.
64
+ let _lastLayout = {
65
+ columns: [], // 📖 Array of { name, xStart, xEnd } in display order
66
+ headerRow: 0, // 📖 1-based terminal row of the column headers
67
+ firstModelRow: 0, // 📖 1-based terminal row of the first visible model
68
+ lastModelRow: 0, // 📖 1-based terminal row of the last visible model
69
+ viewportStartIdx: 0, // 📖 index into sorted[] of the first visible model
70
+ viewportEndIdx: 0, // 📖 index into sorted[] past the last visible model
71
+ hasAboveIndicator: false, // 📖 whether "... N more above ..." is shown
72
+ hasBelowIndicator: false, // 📖 whether "... N more below ..." is shown
73
+ footerHotkeys: [], // 📖 Array of { key, row, xStart, xEnd } for footer click zones
74
+ }
75
+ export function getLastLayout() { return _lastLayout }
76
+
77
+ // 📖 Column name → sort key mapping for mouse click-to-sort on header row
78
+ const COLUMN_SORT_MAP = {
79
+ rank: 'rank',
80
+ tier: null, // 📖 Tier column click cycles tier filter rather than sorting
81
+ swe: 'swe',
82
+ ctx: 'ctx',
83
+ model: 'model',
84
+ source: 'origin',
85
+ ping: 'ping',
86
+ avg: 'avg',
87
+ health: 'condition',
88
+ verdict: 'verdict',
89
+ stability: 'stability',
90
+ uptime: 'uptime',
91
+ }
92
+ export { COLUMN_SORT_MAP }
93
+
59
94
  // 📖 Provider column palette: soft pastel rainbow so each provider stays easy
60
95
  // 📖 to spot without turning the table into a harsh neon wall.
61
96
  // 📖 Exported for use in overlays (settings screen) and logs.
@@ -160,7 +195,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
160
195
  const W_STATUS = 18
161
196
  const W_VERDICT = 14
162
197
  const W_UPTIME = 6
163
- const W_COMPAT = 22 // 📖 "Compatible with" column — 11 emoji slots (10×2 + 1×1 for π + 1 padding)
198
+
164
199
  // const W_TOKENS = 7 // Used column removed
165
200
  // const W_USAGE = 7 // Usage column removed
166
201
  const MIN_TABLE_WIDTH = WIDTH_WARNING_MIN_COLS
@@ -180,7 +215,6 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
180
215
  let showUptime = true
181
216
  let showTier = true
182
217
  let showStability = true
183
- let showCompat = true // 📖 "Compatible with" column — hidden on narrow terminals
184
218
  let isCompact = false
185
219
 
186
220
  if (terminalCols > 0) {
@@ -192,7 +226,6 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
192
226
  cols.push(W_SWE, W_CTX, W_MODEL, wSource, wPing, wAvg, wStatus, W_VERDICT)
193
227
  if (showStability) cols.push(wStab)
194
228
  if (showUptime) cols.push(W_UPTIME)
195
- if (showCompat) cols.push(W_COMPAT)
196
229
  return ROW_MARGIN + cols.reduce((a, b) => a + b, 0) + (cols.length - 1) * SEP_W
197
230
  }
198
231
 
@@ -206,12 +239,39 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
206
239
  wStatus = 13 // Health truncated after 6 chars + '…'
207
240
  }
208
241
  // 📖 Steps 2–5: Progressive column hiding (least useful first)
209
- if (calcWidth() > terminalCols) showCompat = false
210
242
  if (calcWidth() > terminalCols) showRank = false
211
243
  if (calcWidth() > terminalCols) showUptime = false
212
244
  if (calcWidth() > terminalCols) showTier = false
213
245
  if (calcWidth() > terminalCols) showStability = false
214
246
  }
247
+
248
+ // 📖 Mouse support: compute column boundaries from the resolved responsive widths.
249
+ // 📖 This builds an ordered array of { name, xStart, xEnd } (1-based display columns)
250
+ // 📖 matching exactly what renderTable paints so click-to-sort hits the right column.
251
+ {
252
+ const colDefs = []
253
+ if (showRank) colDefs.push({ name: 'rank', width: W_RANK })
254
+ if (showTier) colDefs.push({ name: 'tier', width: W_TIER })
255
+ colDefs.push({ name: 'swe', width: W_SWE })
256
+ colDefs.push({ name: 'ctx', width: W_CTX })
257
+ colDefs.push({ name: 'model', width: W_MODEL })
258
+ colDefs.push({ name: 'source', width: wSource })
259
+ colDefs.push({ name: 'ping', width: wPing })
260
+ colDefs.push({ name: 'avg', width: wAvg })
261
+ colDefs.push({ name: 'health', width: wStatus })
262
+ colDefs.push({ name: 'verdict', width: W_VERDICT })
263
+ if (showStability) colDefs.push({ name: 'stability', width: wStab })
264
+ if (showUptime) colDefs.push({ name: 'uptime', width: W_UPTIME })
265
+ let x = ROW_MARGIN + 1 // 📖 1-based: first column starts after the 2-char left margin
266
+ const columns = []
267
+ for (let i = 0; i < colDefs.length; i++) {
268
+ const { name, width } = colDefs[i]
269
+ const xEnd = x + width - 1
270
+ columns.push({ name, xStart: x, xEnd })
271
+ x = xEnd + 1 + SEP_W // 📖 skip past the ' │ ' separator
272
+ }
273
+ _lastLayout.columns = columns
274
+ }
215
275
  const warningDurationMs = 2_000
216
276
  const elapsed = widthWarningStartedAt ? Math.max(0, Date.now() - widthWarningStartedAt) : warningDurationMs
217
277
  const remainingMs = Math.max(0, warningDurationMs - elapsed)
@@ -268,8 +328,8 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
268
328
  const tierH = 'Tier'
269
329
  const originH = 'Provider'
270
330
  const modelH = 'Model'
271
- const sweH = sortColumn === 'swe' ? dir + ' SWE%' : 'SWE%'
272
- const ctxH = sortColumn === 'ctx' ? dir + ' CTX' : 'CTX'
331
+ const sweH = sortColumn === 'swe' ? (dir + 'SWE%') : 'SWE%'
332
+ const ctxH = sortColumn === 'ctx' ? (dir + 'CTX') : 'CTX'
273
333
  // 📖 Compact labels: 'Lat. P' / 'Avg. P' / 'StaB.' to save horizontal space
274
334
  const pingLabel = isCompact ? 'Lat. P' : 'Latest Ping'
275
335
  const avgLabel = isCompact ? 'Avg. P' : 'Avg Ping'
@@ -278,8 +338,10 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
278
338
  const avgH = sortColumn === 'avg' ? dir + ' ' + avgLabel : avgLabel
279
339
  const healthH = sortColumn === 'condition' ? dir + ' Health' : 'Health'
280
340
  const verdictH = sortColumn === 'verdict' ? dir + ' Verdict' : 'Verdict'
281
- const stabH = sortColumn === 'stability' ? dir + ' ' + stabLabel : stabLabel
282
- const uptimeH = sortColumn === 'uptime' ? dir + ' Up%' : 'Up%'
341
+ // 📖 Stability: in non-compact the arrow eats 2 chars (' '), so truncate to fit wStab.
342
+ // 📖 Compact is fine because ' StaB.' (7) < wStab (8).
343
+ const stabH = sortColumn === 'stability' ? (dir + (isCompact ? ' ' + stabLabel : 'Stability')) : stabLabel
344
+ const uptimeH = sortColumn === 'uptime' ? (dir + 'Up%') : 'Up%'
283
345
 
284
346
  // 📖 Helper to colorize first letter for keyboard shortcuts
285
347
  // 📖 IMPORTANT: Pad PLAIN TEXT first, then apply colors to avoid alignment issues
@@ -326,14 +388,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
326
388
  const padding = ' '.repeat(Math.max(0, W_UPTIME - plain.length))
327
389
  return themeColors.hotkey('U') + themeColors.dim('p%' + padding)
328
390
  })()
329
- // 📖 "Compatible with" column header — show all tool emojis in their colors as the header
330
- const compatHeaderEmojis = COMPAT_COLUMN_SLOTS.map(slot => {
331
- return chalk.rgb(...slot.color)(slot.emoji)
332
- }).join('')
333
- // 📖 padEndDisplay accounts for emoji widths (most are 2-wide, π is 1-wide)
334
- const compatHeaderRaw = COMPAT_COLUMN_SLOTS.reduce((w, slot) => w + displayWidth(slot.emoji), 0)
335
- const compatHeaderPad = Math.max(0, W_COMPAT - compatHeaderRaw)
336
- const compatH_c = compatHeaderEmojis + ' '.repeat(compatHeaderPad)
391
+
337
392
  // 📖 Usage column removed from UI – no header or separator for it.
338
393
  // 📖 Header row: conditionally include columns based on responsive visibility
339
394
  const headerParts = []
@@ -342,9 +397,12 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
342
397
  headerParts.push(sweH_c, ctxH_c, modelH_c, originH_c, pingH_c, avgH_c, healthH_c, verdictH_c)
343
398
  if (showStability) headerParts.push(stabH_c)
344
399
  if (showUptime) headerParts.push(uptimeH_c)
345
- if (showCompat) headerParts.push(compatH_c)
346
400
  lines.push(' ' + headerParts.join(COL_SEP))
347
401
 
402
+ // 📖 Mouse support: the column header row is the last line we just pushed.
403
+ // 📖 Terminal rows are 1-based, so line index (lines.length-1) → terminal row lines.length.
404
+ _lastLayout.headerRow = lines.length
405
+
348
406
 
349
407
 
350
408
  if (sorted.length === 0) {
@@ -376,6 +434,14 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
376
434
  lines.push(themeColors.dim(` ... ${vp.startIdx} more above ...`))
377
435
  }
378
436
 
437
+ // 📖 Mouse support: record where model rows begin in the terminal (1-based).
438
+ // 📖 The next line pushed will be the first visible model row.
439
+ const _firstModelLineIdx = lines.length // 📖 0-based index into lines[]
440
+ _lastLayout.viewportStartIdx = vp.startIdx
441
+ _lastLayout.viewportEndIdx = vp.endIdx
442
+ _lastLayout.hasAboveIndicator = vp.hasAbove
443
+ _lastLayout.hasBelowIndicator = vp.hasBelow
444
+
379
445
  for (let i = vp.startIdx; i < vp.endIdx; i++) {
380
446
  const r = sorted[i]
381
447
  const tierFn = TIER_COLOR[r.tier] ?? ((text) => themeColors.text(text))
@@ -601,28 +667,6 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
601
667
  const sourceCursorText = providerDisplay.padEnd(wSource)
602
668
  const sourceCell = isCursor ? themeColors.provider(r.providerKey, sourceCursorText, { bold: true }) : source
603
669
 
604
- // 📖 "Compatible with" column — show colored emojis for compatible tools
605
- // 📖 Each slot in COMPAT_COLUMN_SLOTS maps to one or more tool keys.
606
- // 📖 OpenCode CLI + Desktop are merged into a single 📦 slot.
607
- let compatCell = ''
608
- if (showCompat) {
609
- const compatTools = getCompatibleTools(r.providerKey)
610
- let compatDisplayWidth = 0
611
- const emojiCells = COMPAT_COLUMN_SLOTS.map(slot => {
612
- const isCompat = slot.toolKeys.some(tk => compatTools.includes(tk))
613
- const ew = displayWidth(slot.emoji)
614
- compatDisplayWidth += isCompat ? ew : ew
615
- if (isCompat) {
616
- return chalk.rgb(...slot.color)(slot.emoji)
617
- }
618
- // 📖 Replace incompatible emoji with dim spaces matching its display width
619
- return themeColors.dim(' '.repeat(ew))
620
- }).join('')
621
- // 📖 Pad to W_COMPAT — account for actual emoji display widths
622
- const extraPad = Math.max(0, W_COMPAT - compatDisplayWidth)
623
- compatCell = emojiCells + ' '.repeat(extraPad)
624
- }
625
-
626
670
  // 📖 Check if this model is incompatible with the active tool mode
627
671
  const isIncompatible = !isModelCompatibleWithTool(r.providerKey, mode)
628
672
 
@@ -637,7 +681,6 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
637
681
  rowParts.push(sweCell, ctxCell, nameCell, sourceCell, pingCell, avgCell, status, speedCell)
638
682
  if (showStability) rowParts.push(stabCell)
639
683
  if (showUptime) rowParts.push(uptimeCell)
640
- if (showCompat) rowParts.push(compatCell)
641
684
  const row = ' ' + rowParts.join(COL_SEP)
642
685
 
643
686
  if (isCursor) {
@@ -656,6 +699,11 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
656
699
  }
657
700
  }
658
701
 
702
+ // 📖 Mouse support: record the 1-based terminal row range of model data rows.
703
+ // 📖 _firstModelLineIdx was captured before the loop; lines.length is now past the last model row.
704
+ _lastLayout.firstModelRow = _firstModelLineIdx + 1 // 📖 convert 0-based line index → 1-based terminal row
705
+ _lastLayout.lastModelRow = lines.length // 📖 last pushed line is at lines.length (1-based)
706
+
659
707
  if (vp.hasBelow) {
660
708
  lines.push(themeColors.dim(` ... ${sorted.length - vp.endIdx} more below ...`))
661
709
  }
@@ -670,7 +718,40 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
670
718
  const activeHotkey = (keyLabel, text, bg) => themeColors.badge(`${keyLabel}${text}`, bg, getReadableTextRgb(bg))
671
719
  const favoritesModeBg = favoritesPinnedAndSticky ? [157, 122, 48] : [95, 95, 95]
672
720
  const favoritesModeLabel = favoritesPinnedAndSticky ? ' Favorites Pinned' : ' Favorites Normal'
721
+
722
+ // 📖 Mouse support: build footer hotkey zones alongside the footer lines.
723
+ // 📖 Each zone records { key, row (1-based terminal row), xStart, xEnd (1-based display cols) }.
724
+ // 📖 We accumulate display position as we build each footer line's parts.
725
+ const footerHotkeys = []
726
+
673
727
  // 📖 Line 1: core navigation + filtering shortcuts
728
+ // 📖 Build as parts array so we can compute click zones and still join for display.
729
+ {
730
+ const parts = [
731
+ { text: ' ', key: null },
732
+ { text: 'F Toggle Favorite', key: 'f' },
733
+ { text: ' • ', key: null },
734
+ { text: 'Y' + favoritesModeLabel, key: 'y' },
735
+ { text: ' • ', key: null },
736
+ { text: tierFilterMode > 0 ? `T Tier (${activeTierLabel})` : 'T Tier', key: 't' },
737
+ { text: ' • ', key: null },
738
+ { text: originFilterMode > 0 ? `D Provider (${activeOriginLabel})` : 'D Provider', key: 'd' },
739
+ { text: ' • ', key: null },
740
+ { text: 'E Show only configured models', key: 'e' },
741
+ { text: ' • ', key: null },
742
+ { text: 'P Settings', key: 'p' },
743
+ { text: ' • ', key: null },
744
+ { text: 'K Help', key: 'k' },
745
+ ]
746
+ const footerRow1 = lines.length + 1 // 📖 1-based terminal row (line hasn't been pushed yet)
747
+ let xPos = 1
748
+ for (const part of parts) {
749
+ const w = displayWidth(part.text)
750
+ if (part.key) footerHotkeys.push({ key: part.key, row: footerRow1, xStart: xPos, xEnd: xPos + w - 1 })
751
+ xPos += w
752
+ }
753
+ }
754
+
674
755
  lines.push(
675
756
  ' ' + hotkey('F', ' Toggle Favorite') +
676
757
  themeColors.dim(` • `) +
@@ -684,12 +765,35 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
684
765
  ? activeHotkey('D', ` Provider (${activeOriginLabel})`, PROVIDER_COLOR[[null, ...Object.keys(sources)][originFilterMode]] || [255, 255, 255])
685
766
  : hotkey('D', ' Provider')) +
686
767
  themeColors.dim(` • `) +
687
- (hideUnconfiguredModels ? activeHotkey('E', ' Configured Models Only', configuredBadgeBg) : hotkey('E', ' Configured Models Only')) +
768
+ (hideUnconfiguredModels ? activeHotkey('E', ' Show only configured models', configuredBadgeBg) : hotkey('E', ' Show only configured models')) +
688
769
  themeColors.dim(` • `) +
689
770
  hotkey('P', ' Settings') +
690
771
  themeColors.dim(` • `) +
691
772
  hotkey('K', ' Help')
692
773
  )
774
+
775
+ // 📖 Line 2: command palette, recommend, feedback, theme
776
+ {
777
+ const cpText = ' NEW ! CTRL+P ⚡️ Command Palette '
778
+ const parts = [
779
+ { text: ' ', key: null },
780
+ { text: cpText, key: 'ctrl+p' },
781
+ { text: ' • ', key: null },
782
+ { text: 'Q Smart Recommend', key: 'q' },
783
+ { text: ' • ', key: null },
784
+ { text: 'G Theme', key: 'g' },
785
+ { text: ' • ', key: null },
786
+ { text: 'I Feedback, bugs & requests', key: 'i' },
787
+ ]
788
+ const footerRow2 = lines.length + 1
789
+ let xPos = 1
790
+ for (const part of parts) {
791
+ const w = displayWidth(part.text)
792
+ if (part.key) footerHotkeys.push({ key: part.key, row: footerRow2, xStart: xPos, xEnd: xPos + w - 1 })
793
+ xPos += w
794
+ }
795
+ }
796
+
693
797
  // 📖 Line 2: command palette (highlighted as new), recommend, feedback, and extended hints.
694
798
  // 📖 CTRL+P ⚡️ Command Palette uses neon-green-on-dark-green background to highlight the feature.
695
799
  const paletteLabel = chalk.bgRgb(0, 60, 0).rgb(57, 255, 20).bold(' NEW ! CTRL+P ⚡️ Command Palette ')
@@ -745,6 +849,29 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
745
849
  filterBadge = chalk.bgYellow.black.bold(` ${filterPrefix}${visibleFilter}${filterSuffix} `)
746
850
  }
747
851
 
852
+ // 📖 Mouse support: track last footer line hotkey zones
853
+ {
854
+ const lastFooterRow = lines.length + 1 // 📖 1-based terminal row (line about to be pushed)
855
+ const parts = [
856
+ { text: ' ', key: null },
857
+ { text: 'N Changelog', key: 'n' },
858
+ ]
859
+ if (hasCustomFilter) {
860
+ parts.push({ text: ' • ', key: null })
861
+ // 📖 X key clears filter — compute width from rendered badge text
862
+ const badgePlain = `X Disable filter: "${customTextFilter.trim().replace(/\s+/g, ' ')}"`
863
+ parts.push({ text: ` ${badgePlain} `, key: 'x' })
864
+ }
865
+ let xPos = 1
866
+ for (const part of parts) {
867
+ const w = displayWidth(part.text)
868
+ if (part.key) footerHotkeys.push({ key: part.key, row: lastFooterRow, xStart: xPos, xEnd: xPos + w - 1 })
869
+ xPos += w
870
+ }
871
+ }
872
+
873
+ _lastLayout.footerHotkeys = footerHotkeys
874
+
748
875
  lines.push(
749
876
  ' ' + themeColors.hotkey('N') + themeColors.dim(' Changelog') +
750
877
  (filterBadge