free-coding-models 0.3.79 β†’ 0.4.0

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,11 +45,11 @@ 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'
52
- import { formatBenchmarkResult } from './benchmark.js'
52
+ import { formatBenchmarkLatency, formatBenchmarkTps } from './benchmark.js'
53
53
  import { calculateViewport, sortResultsWithPinnedFavorites, padEndDisplay, displayWidth, stripAnsi } from './render-helpers.js'
54
54
  import { getToolMeta, TOOL_METADATA, TOOL_MODE_ORDER, isModelCompatibleWithTool } from './tool-metadata.js'
55
55
  import { getColumnSpacing } from './ui-config.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,6 +92,8 @@ const COLUMN_SORT_MAP = {
91
92
  verdict: 'verdict',
92
93
  stability: 'stability',
93
94
  uptime: 'uptime',
95
+ aiLatency: 'aiLatency',
96
+ tps: 'tps',
94
97
  }
95
98
  export { COLUMN_SORT_MAP }
96
99
 
@@ -184,7 +187,8 @@ export function renderTable({
184
187
  routerFooterRequests = 0,
185
188
  benchmarkResults = {},
186
189
  benchmarkRunning = new Set(),
187
- } = {}) {
190
+ headerFlashColumn = null,
191
+ } = _) {
188
192
  // πŸ“– Filter out hidden models for display
189
193
  const visibleResults = results.filter(r => !r.hidden)
190
194
 
@@ -268,16 +272,18 @@ export function renderTable({
268
272
  const COL_SEP = getColumnSpacing()
269
273
  const SEP_W = 3 // ' β”‚ ' display width
270
274
  const ROW_MARGIN = 2 // left margin ' '
275
+ const W_MOOD = 2
271
276
  const W_RANK = 6
272
277
  const W_TIER = 5
273
278
  const W_CTX = 4
274
279
  const W_SOURCE = 14
275
280
  const W_MODEL = 26
276
281
  const W_SWE = 5
277
- const W_STATUS = 18
278
- const W_VERDICT = 14
282
+ const W_STATUS = 17
283
+ const W_VERDICT = 13
279
284
  const W_UPTIME = 6
280
- const W_ANSWER = 14
285
+ const W_AI_LATENCY = 17
286
+ const W_TPS = 5
281
287
 
282
288
  // const W_TOKENS = 7 // Used column removed
283
289
  // const W_USAGE = 7 // Usage column removed
@@ -285,17 +291,19 @@ export function renderTable({
285
291
 
286
292
  // πŸ“– Responsive column visibility: progressively hide least-useful columns
287
293
  // πŸ“– and shorten header labels when terminal width is insufficient.
288
- // πŸ“– Hiding order (least useful first): Rank β†’ Answer Speed β†’ Up% β†’ Tier β†’ Stability
289
- // πŸ“– Compact mode shrinks: Latest Pingβ†’Lat. P (9), Avg Pingβ†’Avg. P (8),
290
- // πŸ“– Stabilityβ†’StaB. (8), Providerβ†’4chars+… (7), Healthβ†’6chars+… (13)
291
- // πŸ“– Breakpoints: full=183 | compact=160 | -Rank=151 | -Answer=142 | -Up%=133 | -Tier=125 | -Stab=114
292
- let wPing = 14
293
- let wAvg = 11
294
+ // πŸ“– Hiding order (least useful first): Rank β†’ AI Latency/TPS β†’ Up% β†’ Tier β†’ Stability
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).
298
+ // πŸ“– Breakpoints are computed dynamically from active column widths.
299
+ let wPing = 9
300
+ let wAvg = 9
294
301
  let wStab = 11
295
302
  let wSource = W_SOURCE
296
303
  let wStatus = W_STATUS
304
+ let wAiLatency = W_AI_LATENCY
297
305
  let showRank = true
298
- let showAnswerSpeed = true
306
+ let showBenchmarkColumns = true
299
307
  let showUptime = true
300
308
  let showTier = true
301
309
  let showStability = true
@@ -305,27 +313,29 @@ export function renderTable({
305
313
  // πŸ“– Dynamically compute needed row width from visible columns
306
314
  const calcWidth = () => {
307
315
  const cols = []
316
+ cols.push(W_MOOD)
308
317
  if (showRank) cols.push(W_RANK)
309
318
  if (showTier) cols.push(W_TIER)
310
319
  cols.push(W_SWE, W_CTX, W_MODEL, wSource, wPing, wAvg, wStatus, W_VERDICT)
311
320
  if (showStability) cols.push(wStab)
312
321
  if (showUptime) cols.push(W_UPTIME)
313
- if (showAnswerSpeed) cols.push(W_ANSWER)
322
+ if (showBenchmarkColumns) cols.push(wAiLatency, W_TPS)
314
323
  return ROW_MARGIN + cols.reduce((a, b) => a + b, 0) + (cols.length - 1) * SEP_W
315
324
  }
316
325
 
317
326
  // πŸ“– Step 1: Compact mode β€” shorten labels and reduce column widths
318
327
  if (calcWidth() > terminalCols) {
319
328
  isCompact = true
320
- wPing = 9 // 'Lat. P' instead of 'Latest Ping'
321
- 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
322
331
  wStab = 8 // 'StaB.' instead of 'Stability'
323
332
  wSource = 7 // Provider truncated to 4 chars + '…', 7 cols total
324
333
  wStatus = 13 // Health truncated after 6 chars + '…'
334
+ wAiLatency = 13 // Mirror compact Health text when health is not good
325
335
  }
326
336
  // πŸ“– Steps 2–6: Progressive column hiding (least useful first)
327
337
  if (calcWidth() > terminalCols) showRank = false
328
- if (calcWidth() > terminalCols) showAnswerSpeed = false
338
+ if (calcWidth() > terminalCols) showBenchmarkColumns = false
329
339
  if (calcWidth() > terminalCols) showUptime = false
330
340
  if (calcWidth() > terminalCols) showTier = false
331
341
  if (calcWidth() > terminalCols) showStability = false
@@ -336,6 +346,7 @@ export function renderTable({
336
346
  // πŸ“– matching exactly what renderTable paints so click-to-sort hits the right column.
337
347
  {
338
348
  const colDefs = []
349
+ colDefs.push({ name: 'mood', width: W_MOOD })
339
350
  if (showRank) colDefs.push({ name: 'rank', width: W_RANK })
340
351
  if (showTier) colDefs.push({ name: 'tier', width: W_TIER })
341
352
  colDefs.push({ name: 'swe', width: W_SWE })
@@ -348,7 +359,10 @@ export function renderTable({
348
359
  colDefs.push({ name: 'verdict', width: W_VERDICT })
349
360
  if (showStability) colDefs.push({ name: 'stability', width: wStab })
350
361
  if (showUptime) colDefs.push({ name: 'uptime', width: W_UPTIME })
351
- if (showAnswerSpeed) colDefs.push({ name: 'answerSpeed', width: W_ANSWER })
362
+ if (showBenchmarkColumns) {
363
+ colDefs.push({ name: 'aiLatency', width: wAiLatency })
364
+ colDefs.push({ name: 'tps', width: W_TPS })
365
+ }
352
366
  let x = ROW_MARGIN + 1 // πŸ“– 1-based: first column starts after the 2-char left margin
353
367
  const columns = []
354
368
  for (let i = 0; i < colDefs.length; i++) {
@@ -394,6 +408,7 @@ export function renderTable({
394
408
  // πŸ“– Sort models using the shared helper
395
409
  const sorted = sortResultsWithPinnedFavorites(visibleResults, sortColumn, sortDirection, {
396
410
  pinFavorites: favoritesPinnedAndSticky,
411
+ benchmarkResults,
397
412
  })
398
413
 
399
414
  const lines = [
@@ -410,24 +425,20 @@ export function renderTable({
410
425
  // πŸ“– Solution: build plain text first, then colorize
411
426
  const dir = sortDirection === 'asc' ? '↑' : '↓'
412
427
 
413
- const rankH = 'Rank'
414
- const tierH = 'Tier'
415
- const originH = 'Provider'
416
- const modelH = 'Model'
417
- const sweH = sortColumn === 'swe' ? (dir + 'SWE%') : 'SWE%'
418
- const ctxH = sortColumn === 'ctx' ? (dir + 'CTX') : 'CTX'
419
- // πŸ“– Compact labels: 'Lat. P' / 'Avg. P' / 'StaB.' to save horizontal space
420
- const pingLabel = isCompact ? 'Lat. P' : 'Latest Ping'
421
- const avgLabel = isCompact ? 'Avg. P' : 'Avg Ping'
422
- const stabLabel = isCompact ? 'StaB.' : 'Stability'
423
- const pingH = sortColumn === 'ping' ? dir + ' ' + pingLabel : pingLabel
424
- const avgH = sortColumn === 'avg' ? dir + ' ' + avgLabel : avgLabel
425
- const healthH = sortColumn === 'condition' ? dir + ' Health' : 'Health'
426
- const verdictH = sortColumn === 'verdict' ? dir + ' Verdict' : 'Verdict'
427
- // πŸ“– Stability: in non-compact the arrow eats 2 chars ('↑ '), so truncate to fit wStab.
428
- // πŸ“– Compact is fine because '↑ StaB.' (7) < wStab (8).
429
- const stabH = sortColumn === 'stability' ? (dir + (isCompact ? ' ' + stabLabel : 'Stability')) : stabLabel
430
- 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%'
431
442
 
432
443
  // πŸ“– Helper to colorize first letter for keyboard shortcuts
433
444
  // πŸ“– IMPORTANT: Pad PLAIN TEXT first, then apply colors to avoid alignment issues
@@ -439,59 +450,130 @@ export function renderTable({
439
450
  return colorFn(first) + themeColors.dim(rest + padding)
440
451
  }
441
452
 
442
- // πŸ“– Now colorize after padding is calculated on plain text
443
- const rankH_c = colorFirst(rankH, W_RANK)
444
- const tierH_c = colorFirst('Tier', W_TIER)
445
- const originLabel = isCompact ? 'PrOD…' : 'Provider'
446
- const originH_c = sortColumn === 'origin'
447
- ? themeColors.accentBold(originLabel.padEnd(wSource))
448
- : (originFilterMode > 0 ? themeColors.accentBold(originLabel.padEnd(wSource)) : (() => {
449
- // πŸ“– Provider keeps O for sorting and D for provider-filter cycling.
450
- // πŸ“– In compact mode, shorten to 'PrOD…' (4 chars + ellipsis) to save space.
451
- const plain = isCompact ? 'PrOD…' : 'PrOviDer'
452
- const padding = ' '.repeat(Math.max(0, wSource - plain.length))
453
- if (isCompact) {
454
- return themeColors.dim('Pr') + themeColors.hotkey('O') + themeColors.hotkey('D') + themeColors.dim('…' + padding)
455
- }
456
- return themeColors.dim('Pr') + themeColors.hotkey('O') + themeColors.dim('vi') + themeColors.hotkey('D') + themeColors.dim('er' + padding)
457
- })())
458
- const modelH_c = colorFirst(modelH, W_MODEL)
459
- const sweH_c = sortColumn === 'swe' ? themeColors.accentBold(sweH.padEnd(W_SWE)) : colorFirst(sweH, W_SWE)
460
- const ctxH_c = sortColumn === 'ctx' ? themeColors.accentBold(ctxH.padEnd(W_CTX)) : colorFirst(ctxH, W_CTX)
461
- const pingH_c = sortColumn === 'ping' ? themeColors.accentBold(pingH.padEnd(wPing)) : colorFirst(pingLabel, wPing)
462
- const avgH_c = sortColumn === 'avg' ? themeColors.accentBold(avgH.padEnd(wAvg)) : colorFirst(avgLabel, wAvg)
463
- const healthH_c = sortColumn === 'condition' ? themeColors.accentBold(healthH.padEnd(wStatus)) : colorFirst('Health', wStatus)
464
- const verdictH_c = sortColumn === 'verdict' ? themeColors.accentBold(verdictH.padEnd(W_VERDICT)) : colorFirst(verdictH, W_VERDICT)
465
- // πŸ“– Custom colorization for Stability: highlight 'B' (the sort key) since 'S' is taken by SWE
466
- 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)
467
517
  const plain = stabLabel
468
518
  const padding = ' '.repeat(Math.max(0, wStab - plain.length))
469
519
  return themeColors.dim('Sta') + themeColors.hotkey('B') + themeColors.dim((isCompact ? '.' : 'ility') + padding)
470
520
  })()
471
- // πŸ“– Up% sorts on U, so keep the highlighted shortcut in the shared yellow sort-key color.
472
- const uptimeH_c = sortColumn === 'uptime' ? themeColors.accentBold(uptimeH.padEnd(W_UPTIME)) : (() => {
473
- const plain = 'Up%'
474
- 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))
475
528
  return themeColors.hotkey('U') + themeColors.dim('p%' + padding)
476
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
+ })()
477
544
 
478
- // πŸ“– Answer Speed header β€” renamed to AI Speed, no sort hotkey
479
- const answerLabel = isCompact ? 'AI Sp.' : 'AI Speed'
480
- const answerH_c = (() => {
481
- const plain = answerLabel
482
- const padding = ' '.repeat(Math.max(0, W_ANSWER - plain.length))
545
+ // πŸ“– Benchmark headers β€” split the old combined AI Speed field into latency + throughput.
546
+ const aiLatencyLabel = isCompact ? 'AI Lat.' : 'AI Latency'
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)
553
+ const plain = aiLatencyLabel
554
+ const padding = ' '.repeat(Math.max(0, wAiLatency - plain.length))
555
+ return themeColors.dim(plain + padding)
556
+ })()
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)
563
+ const plain = 'TPS'
564
+ const padding = ' '.repeat(Math.max(0, W_TPS - plain.length))
483
565
  return themeColors.dim(plain + padding)
484
566
  })()
485
567
 
486
568
  // πŸ“– Usage column removed from UI – no header or separator for it.
487
569
  // πŸ“– Header row: conditionally include columns based on responsive visibility
488
- const headerParts = []
570
+ const headerParts = [moodH_c]
489
571
  if (showRank) headerParts.push(rankH_c)
490
572
  if (showTier) headerParts.push(tierH_c)
491
573
  headerParts.push(sweH_c, ctxH_c, modelH_c, originH_c, pingH_c, avgH_c, healthH_c, verdictH_c)
492
574
  if (showStability) headerParts.push(stabH_c)
493
575
  if (showUptime) headerParts.push(uptimeH_c)
494
- if (showAnswerSpeed) headerParts.push(answerH_c)
576
+ if (showBenchmarkColumns) headerParts.push(aiLatencyH_c, tpsH_c)
495
577
  lines.push(' ' + headerParts.join(COL_SEP))
496
578
 
497
579
  // πŸ“– Mouse support: the column header row is the last line we just pushed.
@@ -702,52 +784,63 @@ export function renderTable({
702
784
 
703
785
  // πŸ“– Verdict column - use getVerdict() for stability-aware verdicts, then render with emoji
704
786
  const verdict = getVerdict(r)
705
- let verdictText, verdictColor
787
+ let verdictText, verdictIcon, verdictColor
706
788
  // πŸ“– Verdict colors follow the same greenβ†’red gradient as TIER_COLOR / SWE%
707
789
  switch (verdict) {
708
790
  case 'Perfect':
709
- verdictText = 'Perfect πŸš€'
791
+ verdictIcon = '🟩'
792
+ verdictText = `${verdictIcon} Perfect`
710
793
  verdictColor = themeColors.successBold
711
794
  break
712
795
  case 'Normal':
713
- verdictText = 'Normal βœ…'
796
+ verdictIcon = '🟒'
797
+ verdictText = `${verdictIcon} Normal`
714
798
  verdictColor = themeColors.metricGood
715
799
  break
716
800
  case 'Spiky':
717
- verdictText = 'Spiky πŸ“ˆ'
801
+ verdictIcon = '🟑'
802
+ verdictText = `${verdictIcon} Spiky`
718
803
  verdictColor = (text) => chalk.bold.rgb(...getTierRgb('A+'))(text)
719
804
  break
720
805
  case 'Slow':
721
- verdictText = 'Slow 🐒'
806
+ verdictIcon = '🟠'
807
+ verdictText = `${verdictIcon} Slow`
722
808
  verdictColor = (text) => chalk.bold.rgb(...getTierRgb('A-'))(text)
723
809
  break
724
810
  case 'Very Slow':
725
- verdictText = 'Very Slow 🐌'
811
+ verdictIcon = 'πŸ”΄'
812
+ verdictText = `${verdictIcon} Very Slow`
726
813
  verdictColor = (text) => chalk.bold.rgb(...getTierRgb('B+'))(text)
727
814
  break
728
815
  case 'Overloaded':
729
- verdictText = 'Overloaded πŸ”₯'
816
+ verdictIcon = 'πŸ”₯'
817
+ verdictText = `${verdictIcon} Overloaded`
730
818
  verdictColor = (text) => chalk.bold.rgb(...getTierRgb('B'))(text)
731
819
  break
732
820
  case 'Unstable':
733
- verdictText = 'Unstable ⚠️'
821
+ verdictIcon = '⚠️'
822
+ verdictText = `${verdictIcon} Unstable`
734
823
  verdictColor = themeColors.errorBold
735
824
  break
736
825
  case 'Not Active':
737
- verdictText = 'Not Active πŸ‘»'
826
+ verdictIcon = '⚫'
827
+ verdictText = `${verdictIcon} Not Active`
738
828
  verdictColor = themeColors.dim
739
829
  break
740
830
  case 'Pending':
741
- verdictText = 'Pending ⏳'
831
+ verdictIcon = '⏳'
832
+ verdictText = `${verdictIcon} Pending`
742
833
  verdictColor = themeColors.dim
743
834
  break
744
835
  default:
745
- verdictText = 'Unusable πŸ’€'
836
+ verdictIcon = 'πŸ’€'
837
+ verdictText = `${verdictIcon} Unusable`
746
838
  verdictColor = (text) => chalk.bold.rgb(...getTierRgb('C'))(text)
747
839
  break
748
840
  }
749
841
  // πŸ“– Use padEndDisplay to account for emoji display width (2 cols each) so all rows align
750
842
  const speedCell = verdictColor(padEndDisplay(verdictText, W_VERDICT))
843
+ const moodCell = padEndDisplay(verdictIcon, W_MOOD)
751
844
 
752
845
  // πŸ“– Stability column - composite score (0–100) from p95 + jitter + spikes + uptime
753
846
  // πŸ“– Left-aligned to sit flush under the column header
@@ -793,33 +886,61 @@ export function renderTable({
793
886
  // (We keep the logic but do not render it.)
794
887
  const usageCell = ''
795
888
 
796
- // πŸ“– Answer Speed column β€” show benchmark result, running spinner, or dash
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.
797
894
  const benchmarkKey = `${r.providerKey}/${r.modelId}`
798
895
  const benchmarkResult = benchmarkResults[benchmarkKey]
799
896
  const isBenchmarkRunning = benchmarkRunning.has(benchmarkKey)
800
- let answerSpeedCell
801
- if (isBenchmarkRunning) {
802
- const spinner = FRAMES[frame % FRAMES.length]
803
- answerSpeedCell = themeColors.success(spinner.padEnd(W_ANSWER))
804
- } else if (benchmarkResult) {
805
- const text = formatBenchmarkResult(benchmarkResult)
806
- // πŸ“– Colorize: success = green, error = red/dim
807
- const isError = !benchmarkResult.ok
808
- answerSpeedCell = isError
809
- ? themeColors.metricBad(text.padEnd(W_ANSWER))
810
- : themeColors.metricGood(text.padEnd(W_ANSWER))
811
- } else {
812
- answerSpeedCell = themeColors.dim('β€”'.padEnd(W_ANSWER))
813
- }
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)))
814
935
 
815
936
  // πŸ“– Build row: conditionally include columns based on responsive visibility
816
- const rowParts = []
937
+ const rowParts = [moodCell]
817
938
  if (showRank) rowParts.push(num)
818
939
  if (showTier) rowParts.push(tier)
819
940
  rowParts.push(sweCell, ctxCell, nameCell, sourceCell, pingCell, avgCell, status, speedCell)
820
941
  if (showStability) rowParts.push(stabCell)
821
942
  if (showUptime) rowParts.push(uptimeCell)
822
- if (showAnswerSpeed) rowParts.push(answerSpeedCell)
943
+ if (showBenchmarkColumns) rowParts.push(latencyCell, tpsCell)
823
944
  const row = ' ' + rowParts.join(COL_SEP)
824
945
 
825
946
  if (isCursor) {
@@ -47,7 +47,7 @@ import {
47
47
  normalizeRouterConfig,
48
48
  saveConfig,
49
49
  } from './config.js'
50
- import { resolveCloudflareUrl } from './ping.js'
50
+ import { buildChatCompletionPingBody, resolveCloudflareUrl, shouldUseDisabledThinkingForProvider } from './ping.js'
51
51
  import { sendUsageTelemetry } from './telemetry.js'
52
52
 
53
53
  export const ROUTER_DEFAULT_PORT = 19280
@@ -1200,12 +1200,11 @@ class RouterRuntime {
1200
1200
  : await fetch(providerUrl, {
1201
1201
  method: 'POST',
1202
1202
  headers: cloneHeadersForUpstream({}, apiKey, candidate.provider),
1203
- body: JSON.stringify({
1204
- model: getApiModelId(candidate.provider, candidate.model),
1205
- messages: [{ role: 'user', content: 'hi' }],
1206
- max_tokens: 1,
1207
- stream: false,
1208
- }),
1203
+ body: JSON.stringify(buildChatCompletionPingBody(
1204
+ getApiModelId(candidate.provider, candidate.model),
1205
+ { stream: false },
1206
+ { disableThinking: shouldUseDisabledThinkingForProvider(candidate.provider) }
1207
+ )),
1209
1208
  signal: controller.signal,
1210
1209
  })
1211
1210
  const latencyMs = Math.round(performance.now() - started)
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
  }