free-coding-models 0.3.80 β†’ 0.4.1

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.
@@ -8,7 +8,7 @@
8
8
  * with consistent alignment, colorization, and viewport clipping.
9
9
  *
10
10
  * 🎯 Key features:
11
- * - Full table layout with tier, latency, stability, uptime, token totals, and usage columns
11
+ * - Full table layout with micro verdict indicator, tier, latency, stability, uptime, token totals, and usage columns
12
12
  * - Hotkey-aware header lettering so highlighted letters always match live sort/filter keys
13
13
  * - Emoji-aware padding via padEndDisplay for aligned verdict/status cells
14
14
  * - Viewport clipping with above/below indicators
@@ -45,7 +45,7 @@ import {
45
45
  TABLE_FOOTER_LINES,
46
46
  FRAMES
47
47
  } from './constants.js'
48
- import { themeColors, getProviderRgb, getTierRgb, getReadableTextRgb, getTheme } from './theme.js'
48
+ import { themeColors, currentPalette, 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
51
  import { usagePlaceholderForProvider } from './ping.js'
@@ -79,6 +79,7 @@ export function getLastLayout() { return _lastLayout }
79
79
 
80
80
  // πŸ“– Column name β†’ sort key mapping for mouse click-to-sort on header row
81
81
  const COLUMN_SORT_MAP = {
82
+ mood: 'verdict',
82
83
  rank: 'rank',
83
84
  tier: null, // πŸ“– Tier column click cycles tier filter rather than sorting
84
85
  swe: 'swe',
@@ -91,8 +92,8 @@ const COLUMN_SORT_MAP = {
91
92
  verdict: 'verdict',
92
93
  stability: 'stability',
93
94
  uptime: 'uptime',
94
- aiLatency: null,
95
- tps: null,
95
+ aiLatency: 'aiLatency',
96
+ tps: 'tps',
96
97
  }
97
98
  export { COLUMN_SORT_MAP }
98
99
 
@@ -186,7 +187,8 @@ export function renderTable({
186
187
  routerFooterRequests = 0,
187
188
  benchmarkResults = {},
188
189
  benchmarkRunning = new Set(),
189
- } = {}) {
190
+ headerFlashColumn = null,
191
+ } = _) {
190
192
  // πŸ“– Filter out hidden models for display
191
193
  const visibleResults = results.filter(r => !r.hidden)
192
194
 
@@ -270,16 +272,17 @@ export function renderTable({
270
272
  const COL_SEP = getColumnSpacing()
271
273
  const SEP_W = 3 // ' β”‚ ' display width
272
274
  const ROW_MARGIN = 2 // left margin ' '
275
+ const W_MOOD = 2
273
276
  const W_RANK = 6
274
277
  const W_TIER = 5
275
278
  const W_CTX = 4
276
279
  const W_SOURCE = 14
277
280
  const W_MODEL = 26
278
281
  const W_SWE = 5
279
- const W_STATUS = 18
280
- const W_VERDICT = 14
282
+ const W_STATUS = 17
283
+ const W_VERDICT = 13
281
284
  const W_UPTIME = 6
282
- const W_AI_LATENCY = 18
285
+ const W_AI_LATENCY = 17
283
286
  const W_TPS = 5
284
287
 
285
288
  // const W_TOKENS = 7 // Used column removed
@@ -289,11 +292,12 @@ export function renderTable({
289
292
  // πŸ“– Responsive column visibility: progressively hide least-useful columns
290
293
  // πŸ“– and shorten header labels when terminal width is insufficient.
291
294
  // πŸ“– Hiding order (least useful first): Rank β†’ AI Latency/TPS β†’ Up% β†’ Tier β†’ Stability
292
- // πŸ“– Compact mode shrinks: Latest Pingβ†’Lat. P (9), Avg Pingβ†’Avg. P (8),
293
- // πŸ“– Stabilityβ†’StaB. (8), Providerβ†’4chars+… (7), Healthβ†’6chars+… (13)
295
+ // πŸ“– Ping columns stay compact because the cell values are tiny numbers without a "ms" suffix.
296
+ // πŸ“– Both ping columns use the same 9-char width so Last Ping and Avg Ping fit cleanly.
297
+ // πŸ“– Compact mode also shrinks Stabilityβ†’StaB. (8), Providerβ†’4chars+… (7), Healthβ†’6chars+… (13).
294
298
  // πŸ“– Breakpoints are computed dynamically from active column widths.
295
- let wPing = 14
296
- let wAvg = 11
299
+ let wPing = 9
300
+ let wAvg = 9
297
301
  let wStab = 11
298
302
  let wSource = W_SOURCE
299
303
  let wStatus = W_STATUS
@@ -309,6 +313,7 @@ export function renderTable({
309
313
  // πŸ“– Dynamically compute needed row width from visible columns
310
314
  const calcWidth = () => {
311
315
  const cols = []
316
+ cols.push(W_MOOD)
312
317
  if (showRank) cols.push(W_RANK)
313
318
  if (showTier) cols.push(W_TIER)
314
319
  cols.push(W_SWE, W_CTX, W_MODEL, wSource, wPing, wAvg, wStatus, W_VERDICT)
@@ -321,8 +326,8 @@ export function renderTable({
321
326
  // πŸ“– Step 1: Compact mode β€” shorten labels and reduce column widths
322
327
  if (calcWidth() > terminalCols) {
323
328
  isCompact = true
324
- wPing = 9 // 'Lat. P' instead of 'Latest Ping'
325
- wAvg = 8 // 'Avg. P' instead of 'Avg Ping'
329
+ wPing = 9 // 'Last Ping' stays compact and matches Avg Ping width
330
+ wAvg = 9 // 'Avg Ping' stays aligned with Last Ping
326
331
  wStab = 8 // 'StaB.' instead of 'Stability'
327
332
  wSource = 7 // Provider truncated to 4 chars + '…', 7 cols total
328
333
  wStatus = 13 // Health truncated after 6 chars + '…'
@@ -341,6 +346,7 @@ export function renderTable({
341
346
  // πŸ“– matching exactly what renderTable paints so click-to-sort hits the right column.
342
347
  {
343
348
  const colDefs = []
349
+ colDefs.push({ name: 'mood', width: W_MOOD })
344
350
  if (showRank) colDefs.push({ name: 'rank', width: W_RANK })
345
351
  if (showTier) colDefs.push({ name: 'tier', width: W_TIER })
346
352
  colDefs.push({ name: 'swe', width: W_SWE })
@@ -402,6 +408,7 @@ export function renderTable({
402
408
  // πŸ“– Sort models using the shared helper
403
409
  const sorted = sortResultsWithPinnedFavorites(visibleResults, sortColumn, sortDirection, {
404
410
  pinFavorites: favoritesPinnedAndSticky,
411
+ benchmarkResults,
405
412
  })
406
413
 
407
414
  const lines = [
@@ -418,24 +425,20 @@ export function renderTable({
418
425
  // πŸ“– Solution: build plain text first, then colorize
419
426
  const dir = sortDirection === 'asc' ? '↑' : '↓'
420
427
 
421
- const rankH = 'Rank'
422
- const tierH = 'Tier'
423
- const originH = 'Provider'
424
- const modelH = 'Model'
425
- const sweH = sortColumn === 'swe' ? (dir + 'SWE%') : 'SWE%'
426
- const ctxH = sortColumn === 'ctx' ? (dir + 'CTX') : 'CTX'
427
- // πŸ“– Compact labels: 'Lat. P' / 'Avg. P' / 'StaB.' to save horizontal space
428
- const pingLabel = isCompact ? 'Lat. P' : 'Latest Ping'
429
- const avgLabel = isCompact ? 'Avg. P' : 'Avg Ping'
430
- const stabLabel = isCompact ? 'StaB.' : 'Stability'
431
- const pingH = sortColumn === 'ping' ? dir + ' ' + pingLabel : pingLabel
432
- const avgH = sortColumn === 'avg' ? dir + ' ' + avgLabel : avgLabel
433
- const healthH = sortColumn === 'condition' ? dir + ' Health' : 'Health'
434
- const verdictH = sortColumn === 'verdict' ? dir + ' Verdict' : 'Verdict'
435
- // πŸ“– Stability: in non-compact the arrow eats 2 chars ('↑ '), so truncate to fit wStab.
436
- // πŸ“– Compact is fine because '↑ StaB.' (7) < wStab (8).
437
- const stabH = sortColumn === 'stability' ? (dir + (isCompact ? ' ' + stabLabel : 'Stability')) : stabLabel
438
- const uptimeH = sortColumn === 'uptime' ? (dir + 'Up%') : 'Up%'
428
+ // πŸ“– Plain header labels β€” arrows are appended dynamically below.
429
+ const moodLabel = '❔'
430
+ const rankLabel = 'Rank'
431
+ const tierLabel = 'Tier'
432
+ const originLabel = isCompact ? 'PrOD…' : 'Provider'
433
+ const modelLabel = 'Model'
434
+ const sweLabel = 'SWE%'
435
+ const ctxLabel = 'CTX'
436
+ const pingLabel = 'Last Ping'
437
+ const avgLabel = 'Avg Ping'
438
+ const healthLabel = 'Health'
439
+ const verdictLabel = 'Verdict'
440
+ const stabLabel = isCompact ? 'StaB.' : 'Stability'
441
+ const uptimeLabel = 'Up%'
439
442
 
440
443
  // πŸ“– Helper to colorize first letter for keyboard shortcuts
441
444
  // πŸ“– IMPORTANT: Pad PLAIN TEXT first, then apply colors to avoid alignment issues
@@ -447,50 +450,116 @@ export function renderTable({
447
450
  return colorFn(first) + themeColors.dim(rest + padding)
448
451
  }
449
452
 
450
- // πŸ“– Now colorize after padding is calculated on plain text
451
- const rankH_c = colorFirst(rankH, W_RANK)
452
- const tierH_c = colorFirst('Tier', W_TIER)
453
- const originLabel = isCompact ? 'PrOD…' : 'Provider'
454
- const originH_c = sortColumn === 'origin'
455
- ? themeColors.accentBold(originLabel.padEnd(wSource))
456
- : (originFilterMode > 0 ? themeColors.accentBold(originLabel.padEnd(wSource)) : (() => {
457
- // πŸ“– Provider keeps O for sorting and D for provider-filter cycling.
458
- // πŸ“– In compact mode, shorten to 'PrOD…' (4 chars + ellipsis) to save space.
459
- const plain = isCompact ? 'PrOD…' : 'PrOviDer'
460
- const padding = ' '.repeat(Math.max(0, wSource - plain.length))
461
- if (isCompact) {
462
- return themeColors.dim('Pr') + themeColors.hotkey('O') + themeColors.hotkey('D') + themeColors.dim('…' + padding)
463
- }
464
- return themeColors.dim('Pr') + themeColors.hotkey('O') + themeColors.dim('vi') + themeColors.hotkey('D') + themeColors.dim('er' + padding)
465
- })())
466
- const modelH_c = colorFirst(modelH, W_MODEL)
467
- const sweH_c = sortColumn === 'swe' ? themeColors.accentBold(sweH.padEnd(W_SWE)) : colorFirst(sweH, W_SWE)
468
- const ctxH_c = sortColumn === 'ctx' ? themeColors.accentBold(ctxH.padEnd(W_CTX)) : colorFirst(ctxH, W_CTX)
469
- const pingH_c = sortColumn === 'ping' ? themeColors.accentBold(pingH.padEnd(wPing)) : colorFirst(pingLabel, wPing)
470
- const avgH_c = sortColumn === 'avg' ? themeColors.accentBold(avgH.padEnd(wAvg)) : colorFirst(avgLabel, wAvg)
471
- const healthH_c = sortColumn === 'condition' ? themeColors.accentBold(healthH.padEnd(wStatus)) : colorFirst('Health', wStatus)
472
- const verdictH_c = sortColumn === 'verdict' ? themeColors.accentBold(verdictH.padEnd(W_VERDICT)) : colorFirst(verdictH, W_VERDICT)
473
- // πŸ“– Custom colorization for Stability: highlight 'B' (the sort key) since 'S' is taken by SWE
474
- const stabH_c = sortColumn === 'stability' ? themeColors.accentBold(stabH.padEnd(wStab)) : (() => {
453
+ // πŸ“– Flash animation: when a column header is clicked, it briefly renders with
454
+ // πŸ“– a vivid inverse style (bright accent bg + white bold fg) for ~250ms.
455
+ // πŸ“– This gives satisfying visual feedback that the click was registered.
456
+ const flashHeader = (plainText, width) => {
457
+ const padded = plainText.length <= width ? plainText.padEnd(width) : plainText.slice(0, width)
458
+ const [r, g, b] = currentPalette().accentStrong
459
+ return chalk.bold.rgb(255, 255, 255).bgRgb(r, g, b)(padded)
460
+ }
461
+
462
+ // πŸ“– Sort-active header: renders the column header with a subtle background color
463
+ // πŸ“– to visually indicate which column is currently sorted.
464
+ // πŸ“– Includes the ↑/↓ arrow and bold white text on a tinted background.
465
+ // πŸ“– If the arrow prefix doesn't fit, appends it: 'SWE↑' instead of '↑ SWE%'.
466
+ const sortActiveHeader = (label, width) => {
467
+ const arrow = dir
468
+ const prefixed = arrow + ' ' + label
469
+ const text = prefixed.length <= width ? prefixed : label + arrow
470
+ const padded = text.padEnd(width).slice(0, width)
471
+ // πŸ“– Subtle dark accent background β€” visible but not overwhelming.
472
+ const bg = currentPalette().cursor.defaultBg
473
+ return chalk.bold.rgb(255, 255, 255).bgRgb(...bg)(padded)
474
+ }
475
+
476
+ // πŸ“– Now colorize each column header.
477
+ // πŸ“– Three rendering states per column:
478
+ // πŸ“– 1. FLASH β€” headerFlashColumn matches β†’ vivid inverse style (click feedback)
479
+ // πŸ“– 2. ACTIVE β€” sortColumn matches β†’ subtle bg + ↑/↓ arrow
480
+ // πŸ“– 3. DEFAULT β€” normal dim text with highlighted first letter
481
+
482
+ // πŸ“– Helper: pick the right style for a standard column header.
483
+ // πŸ“– colKey = sort key (e.g. 'rank', 'swe'), label = plain text, width = column width.
484
+ const headerStyle = (colKey, label, width) => {
485
+ const arrowText = dir + ' ' + label
486
+ const flashText = arrowText.length <= width ? arrowText : label + dir
487
+ if (headerFlashColumn === colKey) return flashHeader(flashText, width)
488
+ if (sortColumn === colKey) return sortActiveHeader(label, width)
489
+ return colorFirst(label, width)
490
+ }
491
+
492
+ const moodH_c = (() => {
493
+ // πŸ“– Tiny verdict indicator column: keep it emoji-only, no arrow, so it stays 2 cells wide.
494
+ const padded = padEndDisplay(moodLabel, W_MOOD)
495
+ if (headerFlashColumn === 'verdict') return chalk.bold.rgb(255, 255, 255).bgRgb(...currentPalette().accentStrong)(padded)
496
+ if (sortColumn === 'verdict') return chalk.bold.rgb(255, 255, 255).bgRgb(...currentPalette().cursor.defaultBg)(padded)
497
+ return themeColors.hotkey(padded)
498
+ })()
499
+ const rankH_c = headerStyle('rank', rankLabel, W_RANK)
500
+ const tierH_c = (() => {
501
+ if (headerFlashColumn === 'tier') return flashHeader(tierLabel, W_TIER)
502
+ return colorFirst(tierLabel, W_TIER)
503
+ })()
504
+ const modelH_c = headerStyle('model', modelLabel, W_MODEL)
505
+ const sweH_c = headerStyle('swe', sweLabel, W_SWE)
506
+ const ctxH_c = headerStyle('ctx', ctxLabel, W_CTX)
507
+ const pingH_c = headerStyle('ping', pingLabel, wPing)
508
+ const avgH_c = headerStyle('avg', avgLabel, wAvg)
509
+ const healthH_c = headerStyle('condition', healthLabel, wStatus)
510
+ const verdictH_c = headerStyle('verdict', verdictLabel, W_VERDICT)
511
+ const stabH_c = (() => {
512
+ if (headerFlashColumn === 'stability') {
513
+ const ft = (dir + ' ' + stabLabel).length <= wStab ? dir + ' ' + stabLabel : stabLabel + dir
514
+ return flashHeader(ft, wStab)
515
+ }
516
+ if (sortColumn === 'stability') return sortActiveHeader(stabLabel, wStab)
475
517
  const plain = stabLabel
476
518
  const padding = ' '.repeat(Math.max(0, wStab - plain.length))
477
519
  return themeColors.dim('Sta') + themeColors.hotkey('B') + themeColors.dim((isCompact ? '.' : 'ility') + padding)
478
520
  })()
479
- // πŸ“– Up% sorts on U, so keep the highlighted shortcut in the shared yellow sort-key color.
480
- const uptimeH_c = sortColumn === 'uptime' ? themeColors.accentBold(uptimeH.padEnd(W_UPTIME)) : (() => {
481
- const plain = 'Up%'
482
- const padding = ' '.repeat(Math.max(0, W_UPTIME - plain.length))
521
+ const uptimeH_c = (() => {
522
+ if (headerFlashColumn === 'uptime') {
523
+ const ft = (dir + ' ' + uptimeLabel).length <= W_UPTIME ? dir + ' ' + uptimeLabel : uptimeLabel + dir
524
+ return flashHeader(ft, W_UPTIME)
525
+ }
526
+ if (sortColumn === 'uptime') return sortActiveHeader(uptimeLabel, W_UPTIME)
527
+ const padding = ' '.repeat(Math.max(0, W_UPTIME - uptimeLabel.length))
483
528
  return themeColors.hotkey('U') + themeColors.dim('p%' + padding)
484
529
  })()
530
+ const originH_c = (() => {
531
+ if (headerFlashColumn === 'origin') {
532
+ const ft = (dir + ' ' + originLabel).length <= wSource ? dir + ' ' + originLabel : originLabel + dir
533
+ return flashHeader(ft, wSource)
534
+ }
535
+ if (sortColumn === 'origin') return sortActiveHeader(originLabel, wSource)
536
+ if (originFilterMode > 0) return themeColors.accentBold(originLabel.padEnd(wSource))
537
+ const plain = isCompact ? 'PrOD…' : 'PrOviDer'
538
+ const padding = ' '.repeat(Math.max(0, wSource - plain.length))
539
+ if (isCompact) {
540
+ return themeColors.dim('Pr') + themeColors.hotkey('O') + themeColors.hotkey('D') + themeColors.dim('…' + padding)
541
+ }
542
+ return themeColors.dim('Pr') + themeColors.hotkey('O') + themeColors.dim('vi') + themeColors.hotkey('D') + themeColors.dim('er' + padding)
543
+ })()
485
544
 
486
545
  // πŸ“– Benchmark headers β€” split the old combined AI Speed field into latency + throughput.
487
546
  const aiLatencyLabel = isCompact ? 'AI Lat.' : 'AI Latency'
488
547
  const aiLatencyH_c = (() => {
548
+ if (headerFlashColumn === 'aiLatency') {
549
+ const ft = (dir + ' ' + aiLatencyLabel).length <= wAiLatency ? dir + ' ' + aiLatencyLabel : aiLatencyLabel + dir
550
+ return flashHeader(ft, wAiLatency)
551
+ }
552
+ if (sortColumn === 'aiLatency') return sortActiveHeader(aiLatencyLabel, wAiLatency)
489
553
  const plain = aiLatencyLabel
490
554
  const padding = ' '.repeat(Math.max(0, wAiLatency - plain.length))
491
555
  return themeColors.dim(plain + padding)
492
556
  })()
493
557
  const tpsH_c = (() => {
558
+ if (headerFlashColumn === 'tps') {
559
+ const ft = (dir + ' ' + 'TPS').length <= W_TPS ? dir + ' ' + 'TPS' : 'TPS' + dir
560
+ return flashHeader(ft, W_TPS)
561
+ }
562
+ if (sortColumn === 'tps') return sortActiveHeader('TPS', W_TPS)
494
563
  const plain = 'TPS'
495
564
  const padding = ' '.repeat(Math.max(0, W_TPS - plain.length))
496
565
  return themeColors.dim(plain + padding)
@@ -498,7 +567,7 @@ export function renderTable({
498
567
 
499
568
  // πŸ“– Usage column removed from UI – no header or separator for it.
500
569
  // πŸ“– Header row: conditionally include columns based on responsive visibility
501
- const headerParts = []
570
+ const headerParts = [moodH_c]
502
571
  if (showRank) headerParts.push(rankH_c)
503
572
  if (showTier) headerParts.push(tierH_c)
504
573
  headerParts.push(sweH_c, ctxH_c, modelH_c, originH_c, pingH_c, avgH_c, healthH_c, verdictH_c)
@@ -715,52 +784,63 @@ export function renderTable({
715
784
 
716
785
  // πŸ“– Verdict column - use getVerdict() for stability-aware verdicts, then render with emoji
717
786
  const verdict = getVerdict(r)
718
- let verdictText, verdictColor
787
+ let verdictText, verdictIcon, verdictColor
719
788
  // πŸ“– Verdict colors follow the same greenβ†’red gradient as TIER_COLOR / SWE%
720
789
  switch (verdict) {
721
790
  case 'Perfect':
722
- verdictText = 'Perfect πŸš€'
791
+ verdictIcon = '🟩'
792
+ verdictText = `${verdictIcon} Perfect`
723
793
  verdictColor = themeColors.successBold
724
794
  break
725
795
  case 'Normal':
726
- verdictText = 'Normal βœ…'
796
+ verdictIcon = '🟒'
797
+ verdictText = `${verdictIcon} Normal`
727
798
  verdictColor = themeColors.metricGood
728
799
  break
729
800
  case 'Spiky':
730
- verdictText = 'Spiky πŸ“ˆ'
801
+ verdictIcon = '🟑'
802
+ verdictText = `${verdictIcon} Spiky`
731
803
  verdictColor = (text) => chalk.bold.rgb(...getTierRgb('A+'))(text)
732
804
  break
733
805
  case 'Slow':
734
- verdictText = 'Slow 🐒'
806
+ verdictIcon = '🟠'
807
+ verdictText = `${verdictIcon} Slow`
735
808
  verdictColor = (text) => chalk.bold.rgb(...getTierRgb('A-'))(text)
736
809
  break
737
810
  case 'Very Slow':
738
- verdictText = 'Very Slow 🐌'
811
+ verdictIcon = 'πŸ”΄'
812
+ verdictText = `${verdictIcon} Very Slow`
739
813
  verdictColor = (text) => chalk.bold.rgb(...getTierRgb('B+'))(text)
740
814
  break
741
815
  case 'Overloaded':
742
- verdictText = 'Overloaded πŸ”₯'
816
+ verdictIcon = 'πŸ”₯'
817
+ verdictText = `${verdictIcon} Overloaded`
743
818
  verdictColor = (text) => chalk.bold.rgb(...getTierRgb('B'))(text)
744
819
  break
745
820
  case 'Unstable':
746
- verdictText = 'Unstable ⚠️'
821
+ verdictIcon = '⚠️'
822
+ verdictText = `${verdictIcon} Unstable`
747
823
  verdictColor = themeColors.errorBold
748
824
  break
749
825
  case 'Not Active':
750
- verdictText = 'Not Active πŸ‘»'
826
+ verdictIcon = '⚫'
827
+ verdictText = `${verdictIcon} Not Active`
751
828
  verdictColor = themeColors.dim
752
829
  break
753
830
  case 'Pending':
754
- verdictText = 'Pending ⏳'
831
+ verdictIcon = '⏳'
832
+ verdictText = `${verdictIcon} Pending`
755
833
  verdictColor = themeColors.dim
756
834
  break
757
835
  default:
758
- verdictText = 'Unusable πŸ’€'
836
+ verdictIcon = 'πŸ’€'
837
+ verdictText = `${verdictIcon} Unusable`
759
838
  verdictColor = (text) => chalk.bold.rgb(...getTierRgb('C'))(text)
760
839
  break
761
840
  }
762
841
  // πŸ“– Use padEndDisplay to account for emoji display width (2 cols each) so all rows align
763
842
  const speedCell = verdictColor(padEndDisplay(verdictText, W_VERDICT))
843
+ const moodCell = padEndDisplay(verdictIcon, W_MOOD)
764
844
 
765
845
  // πŸ“– Stability column - composite score (0–100) from p95 + jitter + spikes + uptime
766
846
  // πŸ“– Left-aligned to sit flush under the column header
@@ -807,30 +887,54 @@ export function renderTable({
807
887
  const usageCell = ''
808
888
 
809
889
  // πŸ“– AI Latency + TPS columns β€” same benchmark result, split into two readable metrics.
890
+ // πŸ“– Benchmark results are shown regardless of health status (up/timeout/down/429/noauth).
891
+ // πŸ“– If benchmark failed β†’ red dash. Error details live in the Health column.
892
+ // πŸ“– If no benchmark has been run yet, show dim dash.
893
+ // πŸ“– Retry badge (↻N) is colored blue and spaced from the main value.
810
894
  const benchmarkKey = `${r.providerKey}/${r.modelId}`
811
895
  const benchmarkResult = benchmarkResults[benchmarkKey]
812
896
  const isBenchmarkRunning = benchmarkRunning.has(benchmarkKey)
813
- const healthIsGood = r.status === 'up'
814
- const latencyText = healthIsGood
815
- ? formatBenchmarkLatency(benchmarkResult, { running: isBenchmarkRunning, frame })
816
- : statusDisplayText
817
- const tpsText = healthIsGood
818
- ? formatBenchmarkTps(benchmarkResult, { running: isBenchmarkRunning, frame })
819
- : 'β€”'
820
- const benchmarkIsError = healthIsGood && benchmarkResult && !benchmarkResult.ok
821
- const latencyCell = !healthIsGood
822
- ? statusColor(padEndDisplay(latencyText, wAiLatency))
823
- : benchmarkIsError
824
- ? themeColors.metricBad(latencyText.padEnd(wAiLatency))
825
- : benchmarkResult || isBenchmarkRunning
826
- ? themeColors.metricGood(latencyText.padEnd(wAiLatency))
827
- : themeColors.dim(latencyText.padEnd(wAiLatency))
828
- const tpsCell = healthIsGood && (benchmarkResult?.ok || isBenchmarkRunning)
829
- ? themeColors.metricGood(tpsText.padEnd(W_TPS))
830
- : themeColors.dim(tpsText.padEnd(W_TPS))
897
+ const hasBenchmark = benchmarkResult || isBenchmarkRunning
898
+ const benchmarkOk = benchmarkResult && benchmarkResult.ok
899
+
900
+ // πŸ“– Build latency cell: value + blue retry badge
901
+ const latParsed = isBenchmarkRunning
902
+ ? formatBenchmarkLatency(benchmarkResult, { running: true, frame })
903
+ : benchmarkOk
904
+ ? formatBenchmarkLatency(benchmarkResult)
905
+ : { text: 'β€”', retryBadge: '' }
906
+ const latValue = benchmarkOk
907
+ ? themeColors.metricGood(latParsed.text)
908
+ : hasBenchmark
909
+ ? themeColors.metricBad(latParsed.text)
910
+ : themeColors.dim(latParsed.text)
911
+ const latBadge = latParsed.retryBadge
912
+ ? themeColors.info(' ' + latParsed.retryBadge)
913
+ : ''
914
+ const latBadgeWidth = latParsed.retryBadge ? displayWidth(' ' + latParsed.retryBadge) : 0
915
+ const latPad = wAiLatency - displayWidth(latParsed.text) - latBadgeWidth
916
+ const latencyCell = latValue + latBadge + themeColors.dim(''.padEnd(Math.max(0, latPad)))
917
+
918
+ // πŸ“– Build TPS cell: value + blue retry badge
919
+ const tpsParsed = isBenchmarkRunning
920
+ ? formatBenchmarkTps(benchmarkResult, { running: true, frame })
921
+ : benchmarkOk
922
+ ? formatBenchmarkTps(benchmarkResult)
923
+ : { text: 'β€”', retryBadge: '' }
924
+ const tpsValue = benchmarkOk || isBenchmarkRunning
925
+ ? themeColors.metricGood(tpsParsed.text)
926
+ : hasBenchmark
927
+ ? themeColors.metricBad(tpsParsed.text)
928
+ : themeColors.dim(tpsParsed.text)
929
+ const tpsBadge = tpsParsed.retryBadge
930
+ ? themeColors.info(' ' + tpsParsed.retryBadge)
931
+ : ''
932
+ const tpsBadgeWidth = tpsParsed.retryBadge ? displayWidth(' ' + tpsParsed.retryBadge) : 0
933
+ const tpsPad = W_TPS - displayWidth(tpsParsed.text) - tpsBadgeWidth
934
+ const tpsCell = tpsValue + tpsBadge + themeColors.dim(''.padEnd(Math.max(0, tpsPad)))
831
935
 
832
936
  // πŸ“– Build row: conditionally include columns based on responsive visibility
833
- const rowParts = []
937
+ const rowParts = [moodCell]
834
938
  if (showRank) rowParts.push(num)
835
939
  if (showTier) rowParts.push(tier)
836
940
  rowParts.push(sweCell, ctxCell, nameCell, sourceCell, pingCell, avgCell, status, speedCell)
package/src/setup.js CHANGED
@@ -16,8 +16,9 @@
16
16
  * 1. Builds a `providers` list from `Object.keys(sources)` so new providers added to
17
17
  * sources.js automatically appear in the wizard without any code changes here.
18
18
  * 2. Uses `readline.createInterface` for line-at-a-time input (not raw mode).
19
- * 3. Calls `saveConfig(config)` once after collecting all answers.
20
- * 4. Returns the nvidia key (or the first entered key) for backward-compatibility with
19
+ * 3. Asks whether the opt-in startup AI Speed Test should run on every launch.
20
+ * 4. Calls `saveConfig(config)` once after collecting all answers.
21
+ * 5. Returns the nvidia key (or the first entered key) for backward-compatibility with
21
22
  * the `main()` caller that originally checked for `nvidiKey !== null` before continuing.
22
23
  *
23
24
  * @functions
@@ -86,18 +87,27 @@ export async function promptApiKey(config) {
86
87
  }
87
88
  }
88
89
 
89
- rl.close()
90
-
91
- // πŸ“– Check at least one key was entered
90
+ // πŸ“– Check at least one key was entered before asking optional behavior questions.
92
91
  const anyKey = Object.values(config.apiKeys).some(v => v)
93
92
  if (!anyKey) {
93
+ rl.close()
94
94
  return null
95
95
  }
96
96
 
97
+ console.log(chalk.bold(' ⚑ Startup AI Speed Scan'))
98
+ console.log(chalk.dim(' FCM can automatically run the Ctrl+U benchmark after launch to fill AI Latency + TPS.'))
99
+ console.log(chalk.dim(' This uses real provider requests, so it is opt-in and can be changed later in Settings.'))
100
+ const autoBenchmarkAnswer = await ask(chalk.dim(' Run the AI Speed Scan automatically on every launch? (y/N): '))
101
+ if (!config.settings || typeof config.settings !== 'object') config.settings = {}
102
+ config.settings.runAiSpeedTestOnStartup = ['y', 'yes', 'oui', 'o'].includes(autoBenchmarkAnswer.toLowerCase())
103
+ console.log()
104
+
105
+ rl.close()
106
+
97
107
  saveConfig(config)
98
108
  const savedCount = Object.values(config.apiKeys).filter(v => v).length
99
109
  console.log(chalk.green(` βœ… ${savedCount} key(s) saved to ~/.free-coding-models.json`))
100
- console.log(chalk.dim(' You can add or change keys anytime with the ') + chalk.yellow('P') + chalk.dim(' key in the TUI.'))
110
+ console.log(chalk.dim(' You can add/change keys and toggle Startup AI Speed Scan anytime with the ') + chalk.yellow('P') + chalk.dim(' key in the TUI.'))
101
111
  console.log()
102
112
 
103
113
  // πŸ“– Return nvidia key for backward-compat (main() checks it exists before continuing)
package/src/theme.js CHANGED
@@ -199,7 +199,7 @@ const TIER_PALETTES = {
199
199
  },
200
200
  }
201
201
 
202
- function currentPalette() {
202
+ export function currentPalette() {
203
203
  return PALETTES[activeTheme] ?? PALETTES.dark
204
204
  }
205
205
 
package/src/tui-state.js CHANGED
@@ -273,5 +273,10 @@ export function createTuiState({
273
273
  globalBenchmarkRunning: false,
274
274
  globalBenchmarkTotal: 0,
275
275
  globalBenchmarkCompleted: 0,
276
+
277
+ // πŸ“– Header click flash animation: briefly highlights the clicked column header
278
+ // πŸ“– with an inverse/bright style for ~250ms (3 frames at 12 FPS).
279
+ headerFlashColumn: null, // πŸ“– Column name being flashed (null = no flash active)
280
+ headerFlashUntilFrame: 0, // πŸ“– Frame number when flash expires
276
281
  }
277
282
  }
package/src/utils.js CHANGED
@@ -235,7 +235,7 @@ export const getStabilityScore = (r) => {
235
235
  // - 'stability' (B key) β€” stability score (0–100, higher = more stable)
236
236
  //
237
237
  // πŸ“– sortDirection 'asc' = ascending (smallest first), 'desc' = descending (largest first)
238
- export const sortResults = (results, sortColumn, sortDirection) => {
238
+ export const sortResults = (results, sortColumn, sortDirection, { benchmarkResults = {} } = {}) => {
239
239
  return [...results].sort((a, b) => {
240
240
  let cmp = 0
241
241
 
@@ -317,6 +317,30 @@ export const sortResults = (results, sortColumn, sortDirection) => {
317
317
  // πŸ“– Models with no data (-1) sort to the bottom
318
318
  cmp = getStabilityScore(a) - getStabilityScore(b)
319
319
  break
320
+ case 'aiLatency': {
321
+ // πŸ“– Sort by AI benchmark latency (totalMs). Lower = better.
322
+ // πŸ“– Models without benchmark data sort to the bottom.
323
+ const aKey = `${a.providerKey}/${a.modelId}`
324
+ const bKey = `${b.providerKey}/${b.modelId}`
325
+ const aBench = benchmarkResults[aKey]
326
+ const bBench = benchmarkResults[bKey]
327
+ const aMs = (aBench?.ok && aBench.totalMs != null) ? aBench.totalMs : Infinity
328
+ const bMs = (bBench?.ok && bBench.totalMs != null) ? bBench.totalMs : Infinity
329
+ cmp = aMs - bMs
330
+ break
331
+ }
332
+ case 'tps': {
333
+ // πŸ“– Sort by benchmark throughput (tokens/second). Higher = better.
334
+ // πŸ“– Models without benchmark data sort to the bottom.
335
+ const aKey2 = `${a.providerKey}/${a.modelId}`
336
+ const bKey2 = `${b.providerKey}/${b.modelId}`
337
+ const aBench2 = benchmarkResults[aKey2]
338
+ const bBench2 = benchmarkResults[bKey2]
339
+ const aTps = (aBench2?.ok && aBench2.tokensPerSecond != null) ? aBench2.tokensPerSecond : -1
340
+ const bTps = (bBench2?.ok && bBench2.tokensPerSecond != null) ? bBench2.tokensPerSecond : -1
341
+ cmp = aTps - bTps
342
+ break
343
+ }
320
344
  case 'usage':
321
345
  // πŸ“– Sort by quota usage percent (usagePercent numeric field, 0–100)
322
346
  // πŸ“– Models with no usage data (undefined/null) are treated as 0 β€” stable tie-break