free-coding-models 0.5.0 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/README.md +40 -6
  2. package/bin/free-coding-models.js +10 -0
  3. package/changelog/v0.5.1.md +24 -0
  4. package/changelog/v0.5.4.md +24 -0
  5. package/package.json +11 -3
  6. package/sources.js +2 -0
  7. package/src/core/router-daemon.js +166 -1
  8. package/src/core/utils.js +2 -0
  9. package/src/tui/cli-help.js +2 -0
  10. package/src/tui/key-handler.js +1 -1
  11. package/src/tui/render-table.js +1 -1
  12. package/web/README.md +8 -5
  13. package/web/assets/providers/aistudio/aistudio-text.svg +1 -0
  14. package/web/assets/providers/aistudio/aistudio.svg +1 -0
  15. package/web/assets/providers/cerebras/cerebras-brand-color.svg +1 -0
  16. package/web/assets/providers/cerebras/cerebras-brand.svg +1 -0
  17. package/web/assets/providers/cerebras/cerebras-color.svg +1 -0
  18. package/web/assets/providers/cerebras/cerebras-text.svg +1 -0
  19. package/web/assets/providers/cerebras/cerebras.svg +1 -0
  20. package/web/assets/providers/cloudflare/cloudflare-color.svg +1 -0
  21. package/web/assets/providers/cloudflare/cloudflare-text.svg +1 -0
  22. package/web/assets/providers/cloudflare/cloudflare.svg +1 -0
  23. package/web/assets/providers/gemini/gemini-color.svg +1 -0
  24. package/web/assets/providers/gemini/gemini-text.svg +1 -0
  25. package/web/assets/providers/gemini/gemini.svg +1 -0
  26. package/web/assets/providers/github/github-text.svg +1 -0
  27. package/web/assets/providers/github/github.svg +1 -0
  28. package/web/assets/providers/groq/groq-text.svg +1 -0
  29. package/web/assets/providers/groq/groq.svg +1 -0
  30. package/web/assets/providers/mistral/mistral-color.svg +1 -0
  31. package/web/assets/providers/mistral/mistral-text.svg +1 -0
  32. package/web/assets/providers/mistral/mistral.svg +1 -0
  33. package/web/assets/providers/nvidia/nvidia-color.svg +1 -0
  34. package/web/assets/providers/nvidia/nvidia-text.svg +1 -0
  35. package/web/assets/providers/nvidia/nvidia.svg +1 -0
  36. package/web/assets/providers/opencode/opencode-text.svg +1 -0
  37. package/web/assets/providers/opencode/opencode.svg +1 -0
  38. package/web/assets/providers/openrouter/openrouter (1).svg +1 -0
  39. package/web/assets/providers/openrouter/openrouter-text.svg +1 -0
  40. package/web/assets/providers/openrouter/openrouter.svg +1 -0
  41. package/web/assets/providers/ovhcloud/ovhcloud.svg +12 -0
  42. package/web/assets/providers/qwen/qwen-color.svg +1 -0
  43. package/web/assets/providers/qwen/qwen-text.svg +1 -0
  44. package/web/assets/providers/qwen/qwen.svg +1 -0
  45. package/web/assets/providers/sambanova/sambanova-color.svg +1 -0
  46. package/web/assets/providers/sambanova/sambanova-text.svg +1 -0
  47. package/web/assets/providers/sambanova/sambanova.svg +1 -0
  48. package/web/assets/providers/scalewaylogo/ScalewayLogo.svg +13 -0
  49. package/web/assets/providers/zai/zai-text.svg +1 -0
  50. package/web/assets/providers/zai/zai.svg +1 -0
  51. package/web/dist/assets/index-BoWmUveV.js +39 -0
  52. package/web/dist/assets/index-BrpHevg4.css +1 -0
  53. package/web/dist/favicon.ico +0 -0
  54. package/web/dist/favicons/apple-touch-icon.png +0 -0
  55. package/web/dist/favicons/browserconfig.xml +12 -0
  56. package/web/dist/favicons/favicon-16x16.png +0 -0
  57. package/web/dist/favicons/favicon-192x192.png +0 -0
  58. package/web/dist/favicons/favicon-32x32.png +0 -0
  59. package/web/dist/favicons/favicon-48x48.png +0 -0
  60. package/web/dist/favicons/favicon-512x512.png +0 -0
  61. package/web/dist/favicons/favicon-96x96.png +0 -0
  62. package/web/dist/favicons/favicon.ico +0 -0
  63. package/web/dist/favicons/mstile-150x150.png +0 -0
  64. package/web/dist/favicons/mstile-310x150.png +0 -0
  65. package/web/dist/favicons/mstile-310x310.png +0 -0
  66. package/web/dist/favicons/mstile-512x512.png +0 -0
  67. package/web/dist/favicons/mstile-70x70.png +0 -0
  68. package/web/dist/favicons/site.webmanifest +25 -0
  69. package/web/dist/index.html +42 -6
  70. package/web/index.html +40 -4
  71. package/web/package.json +11 -0
  72. package/web/public/favicon.ico +0 -0
  73. package/web/public/favicons/apple-touch-icon.png +0 -0
  74. package/web/public/favicons/browserconfig.xml +12 -0
  75. package/web/public/favicons/favicon-16x16.png +0 -0
  76. package/web/public/favicons/favicon-192x192.png +0 -0
  77. package/web/public/favicons/favicon-32x32.png +0 -0
  78. package/web/public/favicons/favicon-48x48.png +0 -0
  79. package/web/public/favicons/favicon-512x512.png +0 -0
  80. package/web/public/favicons/favicon-96x96.png +0 -0
  81. package/web/public/favicons/favicon.ico +0 -0
  82. package/web/public/favicons/mstile-150x150.png +0 -0
  83. package/web/public/favicons/mstile-310x150.png +0 -0
  84. package/web/public/favicons/mstile-310x310.png +0 -0
  85. package/web/public/favicons/mstile-512x512.png +0 -0
  86. package/web/public/favicons/mstile-70x70.png +0 -0
  87. package/web/public/favicons/site.webmanifest +25 -0
  88. package/web/server.js +676 -211
  89. package/web/src/App.jsx +233 -71
  90. package/web/src/components/analytics/AnalyticsView.jsx +40 -6
  91. package/web/src/components/analytics/AnalyticsView.module.css +4 -0
  92. package/web/src/components/atoms/AILatencyCell.jsx +38 -0
  93. package/web/src/components/atoms/AILatencyCell.module.css +51 -0
  94. package/web/src/components/atoms/HealthCell.jsx +138 -0
  95. package/web/src/components/atoms/HealthCell.module.css +56 -0
  96. package/web/src/components/atoms/LastPingCell.jsx +35 -0
  97. package/web/src/components/atoms/LastPingCell.module.css +45 -0
  98. package/web/src/components/atoms/MoodCell.jsx +25 -0
  99. package/web/src/components/atoms/MoodCell.module.css +6 -0
  100. package/web/src/components/atoms/NoKeyIcon.jsx +65 -0
  101. package/web/src/components/atoms/NoKeyIcon.module.css +30 -0
  102. package/web/src/components/atoms/ProviderLogo.jsx +188 -0
  103. package/web/src/components/atoms/ProviderLogo.module.css +103 -0
  104. package/web/src/components/atoms/RankCell.jsx +9 -0
  105. package/web/src/components/atoms/RankCell.module.css +9 -0
  106. package/web/src/components/atoms/Sparkline.jsx +21 -2
  107. package/web/src/components/atoms/StabilityCell.module.css +5 -0
  108. package/web/src/components/atoms/StatusDot.module.css +7 -0
  109. package/web/src/components/atoms/TPSCell.jsx +36 -0
  110. package/web/src/components/atoms/TPSCell.module.css +44 -0
  111. package/web/src/components/atoms/TierBadge.module.css +11 -0
  112. package/web/src/components/atoms/Toast.module.css +6 -0
  113. package/web/src/components/atoms/VerdictBadge.jsx +30 -7
  114. package/web/src/components/atoms/VerdictBadge.module.css +36 -15
  115. package/web/src/components/dashboard/DetailPanel.jsx +63 -2
  116. package/web/src/components/dashboard/DetailPanel.module.css +130 -0
  117. package/web/src/components/dashboard/ExportModal.jsx +9 -4
  118. package/web/src/components/dashboard/FilterBar.jsx +228 -33
  119. package/web/src/components/dashboard/FilterBar.module.css +187 -22
  120. package/web/src/components/dashboard/ModelTable.jsx +500 -54
  121. package/web/src/components/dashboard/ModelTable.module.css +318 -31
  122. package/web/src/components/layout/Footer.jsx +1 -1
  123. package/web/src/components/layout/Header.jsx +171 -9
  124. package/web/src/components/layout/Header.module.css +223 -25
  125. package/web/src/components/palette/CommandPalette.jsx +146 -0
  126. package/web/src/components/palette/CommandPalette.module.css +121 -0
  127. package/web/src/components/settings/SettingsView.jsx +24 -6
  128. package/web/src/components/settings/SettingsView.module.css +0 -1
  129. package/web/src/global.css +118 -84
  130. package/web/src/hooks/useColumnSizing.js +211 -0
  131. package/web/src/hooks/useFavorites.js +150 -0
  132. package/web/src/hooks/useFilter.js +262 -37
  133. package/web/src/hooks/useSSE.js +33 -9
  134. package/web/src/hooks/useSocket.js +200 -0
  135. package/web/src/hooks/useTheme.js +72 -16
  136. package/web/src/hooks/useUrlState.js +93 -0
  137. package/web/vite.config.js +41 -0
  138. package/web/dist/assets/index-CGN-0_A0.css +0 -1
  139. package/web/dist/assets/index-CvMUM9Jr.js +0 -11
  140. package/web/src/components/dashboard/StatsBar.jsx +0 -40
  141. package/web/src/components/dashboard/StatsBar.module.css +0 -28
  142. package/web/src/components/layout/Sidebar.jsx +0 -42
  143. package/web/src/components/layout/Sidebar.module.css +0 -76
  144. package/web/src/components/map/MapView.jsx +0 -17
  145. package/web/src/components/map/MapView.module.css +0 -25
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @file web/src/components/atoms/ProviderLogo.module.css
3
+ * @description Styles for the inline [icon + wordmark] provider cell.
4
+ *
5
+ * - Single horizontal row, max-height pinned so the cell never exceeds the
6
+ * table's row height (matches the 7px vertical padding on .td).
7
+ * - `color: var(--color-text)` propagates the active theme's text color into
8
+ * inlined SVGs that use `fill="currentColor"` (mono icons + all wordmarks).
9
+ * - Sized to fit within a ~150px column at default row height. Cloudflare
10
+ * is the widest wordmark (~145px at 12px high) — it fits but is tight.
11
+ */
12
+
13
+ .row {
14
+ display: inline-flex;
15
+ align-items: center;
16
+ gap: 6px;
17
+ max-width: 100%;
18
+ max-height: var(--pl-max-h, 22px);
19
+ overflow: hidden;
20
+ /* 📖 Inherited by the inlined SVGs that use `fill="currentColor"`. Set on
21
+ 📖 the row so both the mono icon and the wordmark pick up the theme. */
22
+ color: var(--color-text);
23
+ line-height: 1;
24
+ vertical-align: middle;
25
+ }
26
+
27
+ .icon {
28
+ display: inline-flex;
29
+ align-items: center;
30
+ justify-content: center;
31
+ height: var(--pl-icon-h, 16px);
32
+ width: auto;
33
+ flex-shrink: 0;
34
+ }
35
+ /* 📖 The inlined <svg> from lobe-icons ships with `width="1em" height="1em"`.
36
+ 📖 We override both so the wrapper's height controls the rendered size, and
37
+ 📖 let the viewBox dictate the width (preserves each icon's aspect ratio). */
38
+ .icon svg {
39
+ height: 100%;
40
+ width: auto;
41
+ display: block;
42
+ }
43
+
44
+ .textLogo {
45
+ display: inline-flex;
46
+ align-items: center;
47
+ justify-content: center;
48
+ height: var(--pl-text-h, 12px);
49
+ width: auto;
50
+ flex-shrink: 0;
51
+ }
52
+ .textLogo svg {
53
+ height: 100%;
54
+ width: auto;
55
+ display: block;
56
+ }
57
+
58
+ /* 📖 Wordmark-only variant used by OVH (and any future provider whose "icon"
59
+ 📖 asset is itself a full horizontal wordmark). Sized to a slightly tighter
60
+ 📖 max height than the square icons so the wordmark doesn't visually crowd
61
+ 📖 the row. The viewBox aspect ratio of the OVH wordmark is ~6.3:1, so at
62
+ 📖 14px high it renders ~88px wide — fits comfortably in 150px. */
63
+ .iconLegacyWordmark {
64
+ display: inline-flex;
65
+ align-items: center;
66
+ justify-content: center;
67
+ height: var(--pl-icon-h, 14px);
68
+ width: auto;
69
+ flex-shrink: 0;
70
+ /* 📖 Default text color is inherited from the row. Wordmarks that use
71
+ 📖 `fill="currentColor"` (OVH) follow the theme automatically. Wordmarks
72
+ 📖 with hardcoded fills (Scaleway) need a per-theme override — see
73
+ 📖 `.scalewayLogo` below. */
74
+ color: var(--color-text);
75
+ }
76
+ .iconLegacyWordmark svg {
77
+ height: 100%;
78
+ width: auto;
79
+ display: block;
80
+ }
81
+
82
+ /* 📖 Scaleway: brand purple #521094 is the official color. It looks great on
83
+ 📖 white but is barely visible on the dark background — the contrast is
84
+ 📖 too low (dark purple on near-black). Bump to a lighter, more saturated
85
+ 📖 shade in dark mode so the wordmark reads cleanly without losing brand
86
+ 📖 identity. Paths were rewritten to use `fill="currentColor"` so this
87
+ 📖 single CSS color property controls the whole wordmark. */
88
+ :global(.scalewayLogo) {
89
+ color: #521094;
90
+ transition: color 200ms var(--ease-out, ease);
91
+ }
92
+ :global([data-theme="dark"]) :global(.scalewayLogo) {
93
+ color: #a78bfa;
94
+ }
95
+
96
+ .fallback {
97
+ font-size: 11px;
98
+ font-weight: 500;
99
+ color: var(--color-text);
100
+ white-space: nowrap;
101
+ /* 📖 Slight tracking so the origin name reads cleanly next to the icon. */
102
+ letter-spacing: 0.2px;
103
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @file web/src/components/atoms/RankCell.jsx
3
+ * @description Row index number — triable column, matches CLI Rank (idx).
4
+ */
5
+ import styles from './RankCell.module.css'
6
+
7
+ export default function RankCell({ index }) {
8
+ return <span className={styles.rank}>{index}</span>
9
+ }
@@ -0,0 +1,9 @@
1
+ .rank {
2
+ display: inline-block;
3
+ font-family: var(--font-mono);
4
+ font-size: 12px;
5
+ font-weight: 600;
6
+ color: var(--color-text-dim);
7
+ text-align: center;
8
+ min-width: 28px;
9
+ }
@@ -2,9 +2,28 @@
2
2
  * @file web/src/components/atoms/Sparkline.jsx
3
3
  * @description SVG sparkline chart for ping history trend visualization.
4
4
  * 📖 Renders a polyline with gradient area fill and endpoint dot.
5
+ * 📖 Colors are theme-aware: reads CSS custom properties from :root so the
6
+ * 📖 stroke/dot swap to the AA-contrast palette in light mode automatically.
5
7
  */
6
8
  import { useMemo } from 'react'
7
9
 
10
+ // 📖 Read CSS custom properties at module scope; falls back to dark-mode neon
11
+ // 📖 values when running outside the browser (e.g. during server rendering).
12
+ function readCssVar(name, fallback) {
13
+ if (typeof window === 'undefined' || !window.document?.documentElement) return fallback
14
+ const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
15
+ return v || fallback
16
+ }
17
+
18
+ // 📖 Threshold-based color picker that returns the current theme's variant of
19
+ // 📖 each color. Reading the variables on every render is fine — they only
20
+ // 📖 change when the user toggles the theme, and the memo cache key catches it.
21
+ function pickSparkColor(lastVal) {
22
+ if (lastVal < 500) return readCssVar('--color-success', '#00ff88')
23
+ if (lastVal < 1500) return readCssVar('--color-warning', '#ffaa00')
24
+ return readCssVar('--color-danger', '#ff4444')
25
+ }
26
+
8
27
  export default function Sparkline({ history }) {
9
28
  const svg = useMemo(() => {
10
29
  if (!history || history.length < 2) return null
@@ -28,13 +47,13 @@ export default function Sparkline({ history }) {
28
47
  .join(' ')
29
48
 
30
49
  const lastVal = values[values.length - 1]
31
- const color = lastVal < 500 ? '#00ff88' : lastVal < 1500 ? '#ffaa00' : '#ff4444'
50
+ const color = pickSparkColor(lastVal)
32
51
  const lastX = ((values.length - 1) * step).toFixed(1)
33
52
  const lastY = (h - ((lastVal - min) / range) * (h - 4) - 2).toFixed(1)
34
53
 
35
54
  return (
36
55
  <svg className="sparkline-svg" width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
37
- <polyline fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" points={points} opacity="0.8" />
56
+ <polyline fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" points={points} opacity="0.85" />
38
57
  <circle cx={lastX} cy={lastY} r="2.5" fill={color} />
39
58
  </svg>
40
59
  )
@@ -6,3 +6,8 @@
6
6
  .low { background: linear-gradient(90deg, #ff4444, #cc0000); }
7
7
  .value { font-family: 'JetBrains Mono', monospace; font-size: 12px; font-weight: 600; }
8
8
  .none { color: var(--color-text-dim); }
9
+
10
+ /* 📖 Light theme: drop the neon gradients to deep, saturated versions. */
11
+ :global([data-theme="light"]) .high { background: linear-gradient(90deg, #008f4d, #4a7a00); }
12
+ :global([data-theme="light"]) .mid { background: linear-gradient(90deg, #b86b00, #c45a00); }
13
+ :global([data-theme="light"]) .low { background: linear-gradient(90deg, #c8143a, #990000); }
@@ -15,3 +15,10 @@
15
15
  0%, 100% { box-shadow: 0 0 0 0 rgba(118,185,0,0.25); }
16
16
  50% { box-shadow: 0 0 0 6px transparent; }
17
17
  }
18
+
19
+ /* 📖 Light theme: deep green/red for the dot so it still reads as "up"/"down"
20
+ 📖 on a white card; the glow halo is dimmed so it doesn't blow out. */
21
+ :global([data-theme="light"]) .up { background: #008f4d; box-shadow: 0 0 4px rgba(0,143,77,0.35); }
22
+ :global([data-theme="light"]) .down,
23
+ :global([data-theme="light"]) .timeout { background: #c8143a; }
24
+ :global([data-theme="light"]) .pending { background: #888899; }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @file web/src/components/atoms/TPSCell.jsx
3
+ * @description Benchmark tokens-per-second column — animated spinner during benchmarks.
4
+ * 📖 Shows: 13, 45, —. Retry badge ↻N in blue.
5
+ * 📖 When isRunning: animated CSS spinner matches AILatencyCell.
6
+ */
7
+ import styles from './TPSCell.module.css'
8
+
9
+ function formatTps(result, isRunning) {
10
+ if (isRunning) return { text: '…', badge: '' }
11
+ if (!result || !result.ok) return { text: '—', badge: '' }
12
+ const badge = result.retries > 0 ? `↻${result.retries}` : ''
13
+ return { text: String(Math.round(result.tokensPerSecond ?? 0)), badge }
14
+ }
15
+
16
+ export default function TPSCell({ result, isRunning }) {
17
+ const { text, badge } = formatTps(result, isRunning)
18
+ const ok = result?.ok
19
+ const colorCls = ok ? styles.fast : (result ? styles.slow : styles.dim)
20
+
21
+ return (
22
+ <span className={`${styles.cell} ${isRunning ? styles.runningCell : ''}`}>
23
+ {isRunning ? (
24
+ <>
25
+ <span className={styles.miniSpinner} />
26
+ <span className={`${styles.value} ${styles.running}`}>{text}</span>
27
+ </>
28
+ ) : (
29
+ <>
30
+ <span className={`${styles.value} ${colorCls}`}>{text}</span>
31
+ {badge && <span className={styles.badge}>{badge}</span>}
32
+ </>
33
+ )}
34
+ </span>
35
+ )
36
+ }
@@ -0,0 +1,44 @@
1
+ .cell {
2
+ font-family: var(--font-mono);
3
+ font-size: 12px;
4
+ font-weight: 600;
5
+ display: inline-flex;
6
+ align-items: center;
7
+ gap: 4px;
8
+ min-width: 40px;
9
+ transition: background 200ms;
10
+ border-radius: 4px;
11
+ padding: 1px 3px;
12
+ }
13
+ .value { }
14
+ .fast { color: #00ff88; }
15
+ .slow { color: #ff4444; }
16
+ .dim { color: var(--color-text-dim); }
17
+ .running { color: #b400ff; font-weight: 700; }
18
+ .badge { color: #06b6d4; font-size: 10px; }
19
+
20
+ /* ── Running row highlight ── */
21
+ .runningCell {
22
+ background: rgba(180, 0, 255, 0.12);
23
+ }
24
+
25
+ /* ── Mini spinner matching AILatencyCell ── */
26
+ @keyframes miniSpin {
27
+ to { transform: rotate(360deg); }
28
+ }
29
+ .miniSpinner {
30
+ display: inline-block;
31
+ width: 10px;
32
+ height: 10px;
33
+ border: 1.5px solid rgba(180, 0, 255, 0.3);
34
+ border-top-color: #b400ff;
35
+ border-radius: 50%;
36
+ animation: miniSpin 0.6s linear infinite;
37
+ flex-shrink: 0;
38
+ }
39
+
40
+ /* 📖 Light theme overrides — match AILatencyCell. */
41
+ :global([data-theme="light"]) .fast { color: #008f4d; }
42
+ :global([data-theme="light"]) .slow { color: #c8143a; }
43
+ :global([data-theme="light"]) .badge { color: #0e7490; }
44
+ :global([data-theme="light"]) .runningCell { background: rgba(125, 0, 180, 0.08); }
@@ -16,3 +16,14 @@
16
16
  .tier_bplus { background: rgba(168,168,200,0.1); color: #a8a8c8; border: 1px solid rgba(168,168,200,0.15); }
17
17
  .tier_b { background: rgba(128,128,152,0.1); color: #808098; border: 1px solid rgba(128,128,152,0.15); }
18
18
  .tier_c { background: rgba(96,96,120,0.1); color: #606078; border: 1px solid rgba(96,96,120,0.15); }
19
+
20
+ /* 📖 Light theme: darker, AA-contrast tier text colors. Borders stay the same
21
+ 📖 hue so the badge still feels "tier-colored", just more legible. */
22
+ :global([data-theme="light"]) .tier_splus { color: #7a5e00; border-color: rgba(176,136,0,0.4); }
23
+ :global([data-theme="light"]) .tier_s { color: #a14a00; border-color: rgba(196,90,0,0.4); }
24
+ :global([data-theme="light"]) .tier_aplus { color: #005d8a; border-color: rgba(0,115,168,0.4); }
25
+ :global([data-theme="light"]) .tier_a { color: #1f7a3e; border-color: rgba(31,122,62,0.4); }
26
+ :global([data-theme="light"]) .tier_aminus { color: #2f7a2f; border-color: rgba(47,122,47,0.4); }
27
+ :global([data-theme="light"]) .tier_bplus { color: #4a4a72; border-color: rgba(90,90,138,0.4); }
28
+ :global([data-theme="light"]) .tier_b { color: #555566; border-color: rgba(85,85,102,0.4); }
29
+ :global([data-theme="light"]) .tier_c { color: #5a5a6a; border-color: rgba(106,106,122,0.4); }
@@ -18,6 +18,12 @@
18
18
  .error { border-left: 4px solid #ff4444; }
19
19
  .warning { border-left: 4px solid #ffaa00; }
20
20
  .info { border-left: 4px solid #06b6d4; }
21
+
22
+ /* 📖 Light theme: deeper accent bars so they pop on the white toast card. */
23
+ :global([data-theme="light"]) .success { border-left-color: #008f4d; }
24
+ :global([data-theme="light"]) .error { border-left-color: #c8143a; }
25
+ :global([data-theme="light"]) .warning { border-left-color: #b86b00; }
26
+ :global([data-theme="light"]) .info { border-left-color: #0e7490; }
21
27
  .icon { font-size: 18px; flex-shrink: 0; }
22
28
  .message { flex: 1; }
23
29
  .close {
@@ -1,13 +1,36 @@
1
1
  /**
2
2
  * @file web/src/components/atoms/VerdictBadge.jsx
3
- * @description Renders a verdict badge (Perfect, Normal, Slow, etc.) with styled pill.
3
+ * @description Renders a verdict badge matching exactly the TUI format.
4
+ * 📖 Shows emoji + text: 🟩 Perfect, 🟢 Normal, 🟡 Spiky, 🟠 Slow, 🔴 Very Slow, 🔥 Overloaded, 🟥 Unstable, ⚫ Not Active, ⏳ Pending.
4
5
  */
5
- import { verdictCls } from '../../utils/ranks.js'
6
6
  import styles from './VerdictBadge.module.css'
7
7
 
8
- export default function VerdictBadge({ verdict, httpCode }) {
9
- if (!verdict) return <span className={`${styles.badge} ${styles.pending}`}>Pending</span>
10
- if (httpCode === '429') return <span className={`${styles.badge} ${styles.ratelimited}`}>⚠️ Rate Limited</span>
11
- const cls = verdictCls(verdict)
12
- return <span className={`${styles.badge} ${styles[cls]}`}>{verdict}</span>
8
+ // ─── Emoji map matching TUI render-table.js verdictIcon ──────────────────────────
9
+ const VERDICT_WITH_EMOJI = {
10
+ Perfect: { emoji: '🟩', text: 'Perfect', cls: 'perfect' },
11
+ Normal: { emoji: '🟢', text: 'Normal', cls: 'normal' },
12
+ Spiky: { emoji: '🟡', text: 'Spiky', cls: 'spiky' },
13
+ Slow: { emoji: '🟠', text: 'Slow', cls: 'slow' },
14
+ 'Very Slow': { emoji: '🔴', text: 'Very Slow', cls: 'veryslow' },
15
+ Overloaded: { emoji: '🔥', text: 'Overloaded', cls: 'overloaded' },
16
+ Unstable: { emoji: '🟥', text: 'Unstable', cls: 'unstable' },
17
+ 'Not Active':{ emoji: '⚫', text: 'Not Active', cls: 'notactive' },
18
+ Pending: { emoji: '⏳', text: 'Pending', cls: 'pending' },
13
19
  }
20
+
21
+ const DEFAULT_ENTRY = { emoji: '❔', text: 'Pending', cls: 'pending' }
22
+
23
+ export default function VerdictBadge({ verdict, httpCode }) {
24
+ // Handle 429 rate limit from HTTP code (TUI shows 🔥 429 TRY LATER in Health column)
25
+ // In Verdict column, TUI shows 'Overloaded' for 429 — keep same behavior
26
+ const entry = verdict
27
+ ? (VERDICT_WITH_EMOJI[verdict] || { emoji: '❔', text: verdict, cls: 'pending' })
28
+ : DEFAULT_ENTRY
29
+
30
+ return (
31
+ <span className={`${styles.badge} ${styles[entry.cls]}`}>
32
+ <span className={styles.emoji}>{entry.emoji}</span>
33
+ <span className={styles.text}>{entry.text}</span>
34
+ </span>
35
+ )
36
+ }
@@ -1,19 +1,40 @@
1
+ /**
2
+ * @file web/src/components/atoms/VerdictBadge.module.css
3
+ * @description Styles matching TUI verdict colors (emoji + text).
4
+ */
1
5
  .badge {
2
- display: inline-block;
3
- font-size: 10px;
6
+ display: inline-flex;
7
+ align-items: center;
8
+ gap: 3px;
9
+ font-size: 11px;
4
10
  font-weight: 700;
5
- text-transform: uppercase;
6
- padding: 2px 8px;
11
+ padding: 2px 7px;
7
12
  border-radius: 999px;
8
- letter-spacing: 0.5px;
13
+ white-space: nowrap;
9
14
  }
10
- .perfect { background: rgba(0,255,136,0.12); color: #00ff88; border: 1px solid rgba(0,255,136,0.25); }
11
- .normal { background: rgba(118,185,0,0.12); color: #76b900; border: 1px solid rgba(118,185,0,0.25); }
12
- .slow { background: rgba(255,170,0,0.12); color: #ffaa00; border: 1px solid rgba(255,170,0,0.25); }
13
- .spiky { background: rgba(255,102,0,0.12); color: #ff6600; border: 1px solid rgba(255,102,0,0.25); }
14
- .veryslow { background: rgba(255,68,68,0.12); color: #ff4444; border: 1px solid rgba(255,68,68,0.2); }
15
- .overloaded { background: rgba(255,34,34,0.12); color: #ff2222; border: 1px solid rgba(255,34,34,0.2); }
16
- .unstable { background: rgba(204,0,0,0.12); color: #cc0000; border: 1px solid rgba(204,0,0,0.2); }
17
- .notactive { background: rgba(85,85,112,0.12); color: #555570; border: 1px solid rgba(85,85,112,0.15); }
18
- .pending { background: rgba(68,68,96,0.1); color: #444460; border: 1px solid rgba(68,68,96,0.15); }
19
- .ratelimited { background: rgba(255,170,0,0.15); color: #ffaa00; border: 1px solid rgba(255,170,0,0.3); }
15
+
16
+ .emoji { font-size: 12px; line-height: 1; }
17
+ .text { line-height: 1; }
18
+
19
+ /* ─── Color variants matching TUI theme ─── */
20
+ .perfect { background: rgba(0,255,136,0.12); color: #00ff88; }
21
+ .normal { background: rgba(118,185,0,0.12); color: #76b900; }
22
+ .slow { background: rgba(255,170,0,0.12); color: #ffaa00; }
23
+ .spiky { background: rgba(255,102,0,0.12); color: #ff6600; }
24
+ .veryslow { background: rgba(255,68,68,0.12); color: #ff4444; }
25
+ .overloaded { background: rgba(255,34,34,0.12); color: #ff2222; }
26
+ .unstable { background: rgba(204,0,0,0.12); color: #cc0000; }
27
+ .notactive { background: rgba(85,85,112,0.12); color: #555570; }
28
+ .pending { background: rgba(68,68,96,0.1); color: #444460; }
29
+
30
+ /* 📖 Light theme: deeper, more saturated text colors. Backgrounds stay
31
+ 📖 the same since they are translucent and adapt to the white card. */
32
+ :global([data-theme="light"]) .perfect { color: #008f4d; background: rgba(0,143,77,0.10); }
33
+ :global([data-theme="light"]) .normal { color: #4a7a00; background: rgba(74,122,0,0.10); }
34
+ :global([data-theme="light"]) .slow { color: #b86b00; background: rgba(184,107,0,0.10); }
35
+ :global([data-theme="light"]) .spiky { color: #c45000; background: rgba(196,80,0,0.10); }
36
+ :global([data-theme="light"]) .veryslow { color: #c8143a; background: rgba(200,20,58,0.10); }
37
+ :global([data-theme="light"]) .overloaded { color: #b8101f; background: rgba(184,16,31,0.10); }
38
+ :global([data-theme="light"]) .unstable { color: #990000; background: rgba(153,0,0,0.10); }
39
+ :global([data-theme="light"]) .notactive { color: #5a5a72; }
40
+ :global([data-theme="light"]) .pending { color: #5a5a8a; }
@@ -3,10 +3,12 @@
3
3
  * @description Slide-in detail panel showing full model stats, metrics, and a latency chart.
4
4
  * 📖 Rendered as a fixed overlay on the right side. Controlled by `model` prop (null = hidden).
5
5
  * Displays model ID, provider, tier, SWE score, context, status, pings, stability, verdict, uptime,
6
- * ping count, API key status, and a larger SVG latency trend chart.
6
+ * ping count, API key status, a larger SVG latency trend chart, and an M1 favorites + benchmark
7
+ * 📖 action section (star + reorder + per-row AI Speed Test button).
7
8
  * @functions DetailPanel → main panel component, buildDetailChart → SVG chart builder
8
9
  */
9
10
  import { useMemo } from 'react'
11
+ import { IconStar, IconStarFilled, IconChevronUp, IconChevronDown, IconPlayerPlayFilled } from '@tabler/icons-react'
10
12
  import TierBadge from '../atoms/TierBadge.jsx'
11
13
  import VerdictBadge from '../atoms/VerdictBadge.jsx'
12
14
  import StatusDot from '../atoms/StatusDot.jsx'
@@ -68,10 +70,14 @@ function StatRow({ label, children }) {
68
70
  )
69
71
  }
70
72
 
71
- export default function DetailPanel({ model, onClose }) {
73
+ export default function DetailPanel({ model, onClose, favorites, onBenchmark, onToast }) {
72
74
  if (!model) return null
73
75
 
74
76
  const chartSvg = buildDetailChart(model.pingHistory)
77
+ const isFav = favorites?.isFavorite(model) ?? false
78
+ const favRank = favorites?.favoriteRank(model) ?? Number.MAX_SAFE_INTEGER
79
+ const isFavAtTop = isFav && favRank === 0
80
+ const isFavAtBottom = isFav && favRank === (favorites?.favorites.length ?? 0) - 1
75
81
 
76
82
  return (
77
83
  <div className={styles.panel}>
@@ -121,6 +127,61 @@ export default function DetailPanel({ model, onClose }) {
121
127
  <StatRow label="API Key">
122
128
  {model.hasApiKey ? '✅ Configured' : '❌ Missing'}
123
129
  </StatRow>
130
+
131
+ {/* ── M1: favorites + per-row benchmark action section ── */}
132
+ {(favorites || onBenchmark) && (
133
+ <div className={styles.actions}>
134
+ {favorites && (
135
+ <div className={styles.favBlock}>
136
+ <button
137
+ className={`${styles.favBtn} ${isFav ? styles.favBtnActive : ''}`}
138
+ onClick={() => favorites.toggle(model)}
139
+ title={isFav ? `Unfavorite ${model.label} (TUI: F)` : `Favorite ${model.label} (TUI: F)`}
140
+ >
141
+ {isFav ? <IconStarFilled size={14} stroke={1.5} /> : <IconStar size={14} stroke={1.5} />}
142
+ <span>{isFav ? 'Favorited' : 'Add to favorites'}</span>
143
+ </button>
144
+ {isFav && (
145
+ <div className={styles.reorderRow}>
146
+ <button
147
+ className={styles.reorderBtn}
148
+ disabled={isFavAtTop}
149
+ onClick={() => favorites.reorder(model, 'up')}
150
+ title="Move up in router priority (TUI: Shift+↑)"
151
+ aria-label="Move favorite up"
152
+ >
153
+ <IconChevronUp size={12} stroke={1.5} /> Up
154
+ </button>
155
+ <button
156
+ className={styles.reorderBtn}
157
+ disabled={isFavAtBottom}
158
+ onClick={() => favorites.reorder(model, 'down')}
159
+ title="Move down in router priority (TUI: Shift+↓)"
160
+ aria-label="Move favorite down"
161
+ >
162
+ <IconChevronDown size={12} stroke={1.5} /> Down
163
+ </button>
164
+ <span className={styles.rankBadge}>#{favRank + 1}</span>
165
+ </div>
166
+ )}
167
+ </div>
168
+ )}
169
+ {onBenchmark && (
170
+ <button
171
+ className={styles.benchBtn}
172
+ onClick={() => {
173
+ onBenchmark(model)
174
+ onToast?.(`Benchmark started for ${model.label}…`, 'info')
175
+ }}
176
+ title="Run AI Speed Test on this model (TUI: Ctrl+A)"
177
+ >
178
+ <IconPlayerPlayFilled size={12} stroke={1.5} />
179
+ <span>AI Speed Test</span>
180
+ </button>
181
+ )}
182
+ </div>
183
+ )}
184
+
124
185
  <div className={styles.chart}>
125
186
  <div className={styles.chartTitle}>Latency Trend (last 20 pings)</div>
126
187
  {chartSvg}
@@ -75,6 +75,10 @@
75
75
  .pingSlow { color: #ff4444; }
76
76
  .pingNone { color: var(--color-text-dim); }
77
77
 
78
+ :global([data-theme="light"]) .pingFast { color: #008f4d; }
79
+ :global([data-theme="light"]) .pingMedium { color: #b86b00; }
80
+ :global([data-theme="light"]) .pingSlow { color: #c8143a; }
81
+
78
82
  .chart {
79
83
  margin: 20px 0;
80
84
  padding: 16px;
@@ -97,3 +101,129 @@
97
101
  text-align: center;
98
102
  padding: 20px;
99
103
  }
104
+
105
+ /* ─── M1: favorites + per-row benchmark actions ─── */
106
+ .actions {
107
+ display: flex;
108
+ flex-direction: column;
109
+ gap: 10px;
110
+ margin: 16px 0;
111
+ padding: 12px;
112
+ background: var(--color-surface);
113
+ border-radius: var(--radius-md);
114
+ border: 1px solid var(--color-border);
115
+ }
116
+
117
+ .favBlock {
118
+ display: flex;
119
+ flex-direction: column;
120
+ gap: 8px;
121
+ }
122
+
123
+ .favBtn {
124
+ display: inline-flex;
125
+ align-items: center;
126
+ gap: 8px;
127
+ padding: 8px 12px;
128
+ background: var(--color-bg-card);
129
+ color: var(--color-text-muted);
130
+ border: 1px solid var(--color-border);
131
+ border-radius: 6px;
132
+ font-size: 12px;
133
+ font-weight: 600;
134
+ cursor: pointer;
135
+ transition: all 150ms;
136
+ text-align: left;
137
+ }
138
+ .favBtn:hover {
139
+ border-color: #ffd700;
140
+ color: #ffd700;
141
+ background: rgba(255, 215, 0, 0.08);
142
+ }
143
+ .favBtnActive {
144
+ border-color: #ffd700;
145
+ color: #ffd700;
146
+ background: rgba(255, 215, 0, 0.12);
147
+ }
148
+ .favBtnActive:hover {
149
+ background: rgba(255, 215, 0, 0.16);
150
+ }
151
+ :global([data-theme="light"]) .favBtn { color: #555; }
152
+ :global([data-theme="light"]) .favBtn:hover { color: #b08800; border-color: #b08800; background: rgba(176, 136, 0, 0.06); }
153
+ :global([data-theme="light"]) .favBtnActive { color: #b08800; border-color: #b08800; background: rgba(176, 136, 0, 0.10); }
154
+
155
+ .reorderRow {
156
+ display: flex;
157
+ align-items: center;
158
+ gap: 6px;
159
+ padding: 0 4px;
160
+ }
161
+ .reorderBtn {
162
+ display: inline-flex;
163
+ align-items: center;
164
+ gap: 3px;
165
+ padding: 4px 8px;
166
+ font-size: 10px;
167
+ font-weight: 600;
168
+ font-family: var(--font-mono);
169
+ background: var(--color-bg-card);
170
+ color: var(--color-text-muted);
171
+ border: 1px solid var(--color-border);
172
+ border-radius: 4px;
173
+ cursor: pointer;
174
+ transition: all 120ms;
175
+ }
176
+ .reorderBtn:hover:not(:disabled) {
177
+ background: var(--color-bg-hover);
178
+ color: var(--color-text);
179
+ border-color: var(--color-text-muted);
180
+ }
181
+ .reorderBtn:disabled {
182
+ opacity: 0.4;
183
+ cursor: not-allowed;
184
+ }
185
+ .rankBadge {
186
+ margin-left: auto;
187
+ font-size: 10px;
188
+ font-family: var(--font-mono);
189
+ font-weight: 700;
190
+ color: #ffd700;
191
+ background: rgba(255, 215, 0, 0.12);
192
+ border: 1px solid rgba(255, 215, 0, 0.3);
193
+ padding: 2px 6px;
194
+ border-radius: 999px;
195
+ }
196
+ :global([data-theme="light"]) .rankBadge {
197
+ color: #b08800;
198
+ background: rgba(176, 136, 0, 0.12);
199
+ border-color: rgba(176, 136, 0, 0.3);
200
+ }
201
+
202
+ .benchBtn {
203
+ display: inline-flex;
204
+ align-items: center;
205
+ justify-content: center;
206
+ gap: 6px;
207
+ padding: 8px 12px;
208
+ font-size: 12px;
209
+ font-weight: 700;
210
+ font-family: var(--font-mono);
211
+ background: rgba(180, 0, 255, 0.12);
212
+ color: #b400ff;
213
+ border: 1px solid rgba(180, 0, 255, 0.4);
214
+ border-radius: 6px;
215
+ cursor: pointer;
216
+ transition: all 150ms;
217
+ }
218
+ .benchBtn:hover {
219
+ background: rgba(180, 0, 255, 0.20);
220
+ border-color: rgba(180, 0, 255, 0.7);
221
+ }
222
+ :global([data-theme="light"]) .benchBtn {
223
+ background: rgba(140, 0, 200, 0.06);
224
+ color: #6a00a0;
225
+ border-color: rgba(140, 0, 200, 0.3);
226
+ }
227
+ :global([data-theme="light"]) .benchBtn:hover {
228
+ background: rgba(140, 0, 200, 0.12);
229
+ }