free-coding-models 0.5.1 โ†’ 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 (122) hide show
  1. package/README.md +31 -5
  2. package/changelog/v0.5.4.md +24 -0
  3. package/package.json +5 -2
  4. package/sources.js +2 -0
  5. package/src/tui/key-handler.js +1 -1
  6. package/web/assets/providers/aistudio/aistudio-text.svg +1 -0
  7. package/web/assets/providers/aistudio/aistudio.svg +1 -0
  8. package/web/assets/providers/cerebras/cerebras-brand-color.svg +1 -0
  9. package/web/assets/providers/cerebras/cerebras-brand.svg +1 -0
  10. package/web/assets/providers/cerebras/cerebras-color.svg +1 -0
  11. package/web/assets/providers/cerebras/cerebras-text.svg +1 -0
  12. package/web/assets/providers/cerebras/cerebras.svg +1 -0
  13. package/web/assets/providers/cloudflare/cloudflare-color.svg +1 -0
  14. package/web/assets/providers/cloudflare/cloudflare-text.svg +1 -0
  15. package/web/assets/providers/cloudflare/cloudflare.svg +1 -0
  16. package/web/assets/providers/gemini/gemini-color.svg +1 -0
  17. package/web/assets/providers/gemini/gemini-text.svg +1 -0
  18. package/web/assets/providers/gemini/gemini.svg +1 -0
  19. package/web/assets/providers/github/github-text.svg +1 -0
  20. package/web/assets/providers/github/github.svg +1 -0
  21. package/web/assets/providers/groq/groq-text.svg +1 -0
  22. package/web/assets/providers/groq/groq.svg +1 -0
  23. package/web/assets/providers/mistral/mistral-color.svg +1 -0
  24. package/web/assets/providers/mistral/mistral-text.svg +1 -0
  25. package/web/assets/providers/mistral/mistral.svg +1 -0
  26. package/web/assets/providers/nvidia/nvidia-color.svg +1 -0
  27. package/web/assets/providers/nvidia/nvidia-text.svg +1 -0
  28. package/web/assets/providers/nvidia/nvidia.svg +1 -0
  29. package/web/assets/providers/opencode/opencode-text.svg +1 -0
  30. package/web/assets/providers/opencode/opencode.svg +1 -0
  31. package/web/assets/providers/openrouter/openrouter (1).svg +1 -0
  32. package/web/assets/providers/openrouter/openrouter-text.svg +1 -0
  33. package/web/assets/providers/openrouter/openrouter.svg +1 -0
  34. package/web/assets/providers/ovhcloud/ovhcloud.svg +12 -0
  35. package/web/assets/providers/qwen/qwen-color.svg +1 -0
  36. package/web/assets/providers/qwen/qwen-text.svg +1 -0
  37. package/web/assets/providers/qwen/qwen.svg +1 -0
  38. package/web/assets/providers/sambanova/sambanova-color.svg +1 -0
  39. package/web/assets/providers/sambanova/sambanova-text.svg +1 -0
  40. package/web/assets/providers/sambanova/sambanova.svg +1 -0
  41. package/web/assets/providers/scalewaylogo/ScalewayLogo.svg +13 -0
  42. package/web/assets/providers/zai/zai-text.svg +1 -0
  43. package/web/assets/providers/zai/zai.svg +1 -0
  44. package/web/dist/assets/index-BoWmUveV.js +39 -0
  45. package/web/dist/assets/index-BrpHevg4.css +1 -0
  46. package/web/dist/favicon.ico +0 -0
  47. package/web/dist/favicons/apple-touch-icon.png +0 -0
  48. package/web/dist/favicons/browserconfig.xml +12 -0
  49. package/web/dist/favicons/favicon-16x16.png +0 -0
  50. package/web/dist/favicons/favicon-192x192.png +0 -0
  51. package/web/dist/favicons/favicon-32x32.png +0 -0
  52. package/web/dist/favicons/favicon-48x48.png +0 -0
  53. package/web/dist/favicons/favicon-512x512.png +0 -0
  54. package/web/dist/favicons/favicon-96x96.png +0 -0
  55. package/web/dist/favicons/favicon.ico +0 -0
  56. package/web/dist/favicons/mstile-150x150.png +0 -0
  57. package/web/dist/favicons/mstile-310x150.png +0 -0
  58. package/web/dist/favicons/mstile-310x310.png +0 -0
  59. package/web/dist/favicons/mstile-512x512.png +0 -0
  60. package/web/dist/favicons/mstile-70x70.png +0 -0
  61. package/web/dist/favicons/site.webmanifest +25 -0
  62. package/web/dist/index.html +42 -3
  63. package/web/index.html +40 -1
  64. package/web/public/favicon.ico +0 -0
  65. package/web/public/favicons/apple-touch-icon.png +0 -0
  66. package/web/public/favicons/browserconfig.xml +12 -0
  67. package/web/public/favicons/favicon-16x16.png +0 -0
  68. package/web/public/favicons/favicon-192x192.png +0 -0
  69. package/web/public/favicons/favicon-32x32.png +0 -0
  70. package/web/public/favicons/favicon-48x48.png +0 -0
  71. package/web/public/favicons/favicon-512x512.png +0 -0
  72. package/web/public/favicons/favicon-96x96.png +0 -0
  73. package/web/public/favicons/favicon.ico +0 -0
  74. package/web/public/favicons/mstile-150x150.png +0 -0
  75. package/web/public/favicons/mstile-310x150.png +0 -0
  76. package/web/public/favicons/mstile-310x310.png +0 -0
  77. package/web/public/favicons/mstile-512x512.png +0 -0
  78. package/web/public/favicons/mstile-70x70.png +0 -0
  79. package/web/public/favicons/site.webmanifest +25 -0
  80. package/web/server.js +71 -1
  81. package/web/src/App.jsx +209 -89
  82. package/web/src/components/analytics/AnalyticsView.jsx +30 -2
  83. package/web/src/components/analytics/AnalyticsView.module.css +4 -0
  84. package/web/src/components/atoms/AILatencyCell.module.css +9 -1
  85. package/web/src/components/atoms/HealthCell.jsx +113 -28
  86. package/web/src/components/atoms/HealthCell.module.css +43 -2
  87. package/web/src/components/atoms/LastPingCell.module.css +10 -0
  88. package/web/src/components/atoms/NoKeyIcon.jsx +65 -0
  89. package/web/src/components/atoms/NoKeyIcon.module.css +30 -0
  90. package/web/src/components/atoms/ProviderLogo.jsx +188 -0
  91. package/web/src/components/atoms/ProviderLogo.module.css +103 -0
  92. package/web/src/components/atoms/Sparkline.jsx +21 -2
  93. package/web/src/components/atoms/StabilityCell.module.css +5 -0
  94. package/web/src/components/atoms/StatusDot.module.css +7 -0
  95. package/web/src/components/atoms/TPSCell.module.css +7 -1
  96. package/web/src/components/atoms/TierBadge.module.css +11 -0
  97. package/web/src/components/atoms/Toast.module.css +6 -0
  98. package/web/src/components/atoms/VerdictBadge.module.css +12 -0
  99. package/web/src/components/dashboard/DetailPanel.jsx +63 -2
  100. package/web/src/components/dashboard/DetailPanel.module.css +130 -0
  101. package/web/src/components/dashboard/FilterBar.jsx +146 -53
  102. package/web/src/components/dashboard/FilterBar.module.css +138 -58
  103. package/web/src/components/dashboard/ModelTable.jsx +235 -30
  104. package/web/src/components/dashboard/ModelTable.module.css +205 -16
  105. package/web/src/components/layout/Header.jsx +140 -12
  106. package/web/src/components/layout/Header.module.css +191 -27
  107. package/web/src/components/palette/CommandPalette.jsx +146 -0
  108. package/web/src/components/palette/CommandPalette.module.css +121 -0
  109. package/web/src/global.css +52 -15
  110. package/web/src/hooks/useColumnSizing.js +211 -0
  111. package/web/src/hooks/useFavorites.js +150 -0
  112. package/web/src/hooks/useFilter.js +157 -24
  113. package/web/src/hooks/useTheme.js +72 -16
  114. package/web/src/hooks/useUrlState.js +93 -0
  115. package/web/dist/assets/index-ByGf4Kq-.js +0 -14
  116. package/web/dist/assets/index-Ds7wmHBv.css +0 -1
  117. package/web/src/components/dashboard/StatsBar.jsx +0 -42
  118. package/web/src/components/dashboard/StatsBar.module.css +0 -28
  119. package/web/src/components/layout/Sidebar.jsx +0 -50
  120. package/web/src/components/layout/Sidebar.module.css +0 -86
  121. package/web/src/components/map/MapView.jsx +0 -17
  122. package/web/src/components/map/MapView.module.css +0 -25
@@ -1,63 +1,149 @@
1
+ /**
2
+ * @file web/src/components/dashboard/FilterBar.module.css
3
+ * @description Filter bar styles โ€” M1 parity chips (tier, status, verdict, health,
4
+ * visibility, provider, custom text filter, reset, ping mode).
5
+ * ๐Ÿ“– Sticky below the header (`top: 60px` = header height) so the filter chips
6
+ * ๐Ÿ“– stay visible while the user scrolls a long table.
7
+ */
8
+
1
9
  .filters {
2
- position: relative; z-index: 1;
3
- display: flex; align-items: center; gap: 20px; flex-wrap: wrap;
4
- padding: 16px 24px;
10
+ position: sticky; top: 60px; z-index: 99;
11
+ display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
12
+ padding: 10px 20px;
13
+ background: var(--color-bg-card);
14
+ border-bottom: 1px solid var(--color-border);
15
+ backdrop-filter: blur(20px) saturate(1.5);
16
+ -webkit-backdrop-filter: blur(20px) saturate(1.5);
5
17
  }
6
- .group { display: flex; align-items: center; gap: 8px; }
18
+
19
+ .group { display: flex; align-items: center; gap: 6px; }
20
+
7
21
  .filterLabel {
8
- font-size: 11px; font-weight: 600; color: var(--color-text-muted);
22
+ font-size: 10px; font-weight: 700; color: var(--color-text-muted);
9
23
  text-transform: uppercase; letter-spacing: 0.5px;
24
+ white-space: nowrap;
10
25
  }
11
- .tierRow { display: flex; gap: 4px; }
12
- .tierBtn {
13
- font-size: 12px; font-weight: 600; font-family: var(--font-mono);
14
- padding: 4px 10px; border-radius: 6px;
26
+
27
+ /* โ”€โ”€ Chip rows (TUI cycle behavior) โ”€โ”€ */
28
+ .chipRow { display: flex; gap: 3px; }
29
+ .chip {
30
+ font-size: 11px; font-weight: 600; font-family: var(--font-mono);
31
+ padding: 4px 8px; border-radius: 5px;
15
32
  border: 1px solid var(--color-border); background: var(--color-surface);
16
33
  color: var(--color-text-muted); cursor: pointer; transition: all 150ms;
34
+ white-space: nowrap;
17
35
  }
18
- .tierBtn:hover { background: var(--color-bg-hover); color: var(--color-text); }
19
- .active {
36
+ .chip:hover { background: var(--color-bg-hover); color: var(--color-text); }
37
+ .chipActive {
20
38
  background: var(--color-accent) !important; color: var(--color-bg) !important;
21
39
  border-color: var(--color-accent) !important; font-weight: 700;
22
40
  }
23
- .providerSelect {
41
+
42
+ /* โ”€โ”€ Select inputs (provider, visibility) โ”€โ”€ */
43
+ .select, .providerSelect {
24
44
  background: var(--color-surface); color: var(--color-text);
25
- border: 1px solid var(--color-border); border-radius: 6px;
26
- padding: 5px 10px; font-size: 12px; font-family: var(--font-sans);
45
+ border: 1px solid var(--color-border); border-radius: 5px;
46
+ padding: 4px 8px; font-size: 11px; font-family: var(--font-sans);
27
47
  cursor: pointer; outline: none;
48
+ min-width: 130px;
28
49
  }
29
- .providerSelect:focus { border-color: var(--color-accent); }
50
+ .select:focus, .providerSelect:focus { border-color: var(--color-accent); }
51
+
52
+ /* โ”€โ”€ Spacer + reset โ”€โ”€ */
30
53
  .spacer { flex: 1; }
31
- .live {
32
- display: flex; align-items: center; gap: 6px;
33
- font-size: 11px; font-weight: 700; color: var(--color-accent);
34
- text-transform: uppercase; letter-spacing: 1px;
54
+
55
+ .resetBtn {
56
+ display: inline-flex; align-items: center; gap: 5px;
57
+ font-size: 11px; font-weight: 700; font-family: var(--font-mono);
58
+ padding: 4px 9px; border-radius: 5px;
59
+ border: 1px solid var(--color-border-hover);
60
+ background: var(--color-accent-dim);
61
+ color: var(--color-text);
62
+ cursor: pointer; transition: all 150ms;
63
+ }
64
+ .resetBtn:hover {
65
+ background: var(--color-accent);
66
+ color: var(--color-bg);
67
+ }
68
+ .resetBadge {
69
+ display: inline-flex; align-items: center; justify-content: center;
70
+ min-width: 16px; height: 16px;
71
+ font-size: 9px; font-weight: 800;
72
+ background: var(--color-bg);
73
+ color: var(--color-accent);
74
+ border-radius: 999px;
75
+ padding: 0 4px;
35
76
  }
36
- .liveDot {
37
- width: 8px; height: 8px; border-radius: 50%;
38
- background: var(--color-accent); animation: pulseDot 2s ease-in-out infinite;
77
+
78
+ /* โ”€โ”€ Custom text filter chip (TUI Ctrl+P "Apply text filter") โ”€โ”€ */
79
+ .customFilterChip {
80
+ display: inline-flex; align-items: center; gap: 6px;
81
+ font-size: 11px; font-weight: 600; font-family: var(--font-mono);
82
+ padding: 3px 4px 3px 8px;
83
+ border: 1px solid var(--color-accent);
84
+ background: var(--color-accent-dim);
85
+ color: var(--color-accent);
86
+ border-radius: 5px;
87
+ max-width: 240px;
88
+ }
89
+ .customFilterIcon { display: inline-flex; flex-shrink: 0; }
90
+ .customFilterLabel {
91
+ overflow: hidden;
92
+ text-overflow: ellipsis;
93
+ white-space: nowrap;
39
94
  }
95
+ .customFilterClear {
96
+ display: inline-flex; align-items: center; justify-content: center;
97
+ width: 18px; height: 18px;
98
+ background: transparent;
99
+ border: none;
100
+ color: var(--color-accent);
101
+ border-radius: 3px;
102
+ cursor: pointer;
103
+ flex-shrink: 0;
104
+ }
105
+ .customFilterClear:hover { background: var(--color-accent); color: var(--color-bg); }
40
106
 
41
- /* โ”€โ”€ Next ping countdown โ”€โ”€ */
107
+ /* โ”€โ”€ Ping interval selector โ”€โ”€ */
108
+ .pingRow { display: flex; gap: 3px; }
109
+ .pingBtn {
110
+ font-size: 11px; font-weight: 600; font-family: var(--font-mono);
111
+ padding: 4px 8px; border-radius: 5px;
112
+ border: 1px solid var(--color-border); background: var(--color-surface);
113
+ color: var(--color-text-muted); cursor: pointer; transition: all 150ms;
114
+ white-space: nowrap;
115
+ }
116
+ .pingBtn:hover { background: var(--color-bg-hover); color: var(--color-text); border-color: var(--color-text-muted); }
117
+ .pingBtnActive {
118
+ background: var(--color-bg-hover) !important;
119
+ color: var(--ping-active-color, var(--color-accent)) !important;
120
+ border-color: var(--ping-active-color, var(--color-accent)) !important;
121
+ font-weight: 700;
122
+ }
123
+
124
+ /* โ”€โ”€ Next ping countdown (TUI parity) โ”€โ”€ */
42
125
  .nextPing {
43
126
  display: flex; align-items: center; gap: 6px;
44
- font-size: 12px; font-family: var(--font-mono);
127
+ font-size: 11px; font-family: var(--font-mono);
128
+ padding: 4px 8px;
129
+ border: 1px solid var(--color-border);
130
+ border-radius: 5px;
131
+ background: var(--color-surface);
45
132
  }
46
133
  .nextPingLabel {
47
- font-size: 11px; font-weight: 500; color: var(--color-text-muted);
134
+ font-size: 10px; font-weight: 500; color: var(--color-text-muted);
48
135
  text-transform: uppercase; letter-spacing: 0.5px;
49
136
  }
50
137
  .nextPingTime {
51
- font-size: 14px; font-weight: 700; color: var(--color-text);
138
+ font-size: 13px; font-weight: 700; color: var(--color-text);
52
139
  min-width: 32px;
140
+ text-align: right;
141
+ font-variant-numeric: tabular-nums;
53
142
  }
54
143
  .pingingDot {
55
- width: 8px; height: 8px; border-radius: 50%;
144
+ width: 7px; height: 7px; border-radius: 50%;
56
145
  background: #ff4466; animation: pulseDot 0.6s ease-in-out infinite;
57
- }
58
- .pingingText {
59
- font-size: 12px; font-weight: 600; color: #ff4466;
60
- font-family: var(--font-mono);
146
+ flex-shrink: 0;
61
147
  }
62
148
 
63
149
  @keyframes pulseDot {
@@ -65,50 +151,44 @@
65
151
  50% { box-shadow: 0 0 0 6px transparent; }
66
152
  }
67
153
 
68
- /* โ”€โ”€ Ping interval selector โ”€โ”€ */
69
- .pingRow { display: flex; gap: 4px; }
70
- .pingBtn {
71
- font-size: 12px; font-weight: 600; font-family: var(--font-mono);
72
- padding: 4px 10px; border-radius: 6px;
73
- border: 1px solid var(--color-border); background: var(--color-surface);
74
- color: var(--color-text-muted); cursor: pointer; transition: all 150ms;
75
- white-space: nowrap;
154
+ .live {
155
+ display: flex; align-items: center; gap: 5px;
156
+ font-size: 10px; font-weight: 700; color: var(--color-accent);
157
+ text-transform: uppercase; letter-spacing: 1px;
76
158
  }
77
- .pingBtn:hover { background: var(--color-bg-hover); color: var(--color-text); border-color: var(--color-text-muted); }
78
- .pingBtnActive {
79
- background: var(--color-bg-hover) !important;
80
- color: var(--ping-active-color, var(--color-accent)) !important;
81
- border-color: var(--ping-active-color, var(--color-accent)) !important;
82
- font-weight: 700;
159
+ .liveDot {
160
+ width: 7px; height: 7px; border-radius: 50%;
161
+ background: var(--color-accent); animation: pulseDot 2s ease-in-out infinite;
83
162
  }
84
163
 
85
164
  /* โ”€โ”€ Global benchmark progress bar (Ctrl+U) โ”€โ”€ */
86
165
  .benchmarkBar {
87
- display: flex; align-items: center; gap: 12px;
166
+ display: flex; align-items: center; gap: 10px;
88
167
  background: rgba(180, 0, 255, 0.12);
89
168
  border: 1px solid rgba(180, 0, 255, 0.4);
90
- border-radius: 8px;
91
- padding: 8px 14px;
169
+ border-radius: 6px;
170
+ padding: 6px 12px;
92
171
  flex-shrink: 0;
172
+ width: 100%;
173
+ margin-bottom: 4px;
93
174
  }
94
175
  .benchmarkLabel {
95
- display: flex; align-items: center; gap: 6px;
96
- font-size: 12px; font-weight: 700;
176
+ display: flex; align-items: center; gap: 5px;
177
+ font-size: 11px; font-weight: 700;
97
178
  color: #b400ff; font-family: var(--font-mono);
98
179
  white-space: nowrap;
99
180
  }
100
181
  .benchmarkSpinner {
101
182
  display: inline-block;
102
183
  animation: spinSp 0.8s linear infinite;
103
- font-size: 14px; line-height: 1;
104
- }
105
- .benchmarkCount {
106
- font-size: 11px; opacity: 0.8;
184
+ font-size: 12px; line-height: 1;
107
185
  }
186
+ .benchmarkCount { font-size: 10px; opacity: 0.8; }
108
187
  .benchmarkTrack {
109
- width: 120px; height: 6px;
188
+ width: 120px; height: 5px;
110
189
  background: rgba(255,255,255,0.1);
111
190
  border-radius: 3px; overflow: hidden;
191
+ flex: 1;
112
192
  }
113
193
  .benchmarkFill {
114
194
  height: 100%;
@@ -117,12 +197,12 @@
117
197
  transition: width 0.5s ease;
118
198
  }
119
199
  .benchmarkPct {
120
- font-size: 12px; font-weight: 700;
200
+ font-size: 11px; font-weight: 700;
121
201
  color: #b400ff; font-family: var(--font-mono);
122
- min-width: 36px; text-align: right;
202
+ min-width: 32px; text-align: right;
123
203
  }
124
204
 
125
205
  @keyframes spinSp {
126
206
  from { transform: rotate(0deg); }
127
207
  to { transform: rotate(360deg); }
128
- }
208
+ }
@@ -1,17 +1,27 @@
1
1
  /**
2
2
  * @file web/src/components/dashboard/ModelTable.jsx
3
3
  * @description Main data table with ALL CLI columns powered by TanStack Table.
4
- * ๐Ÿ“– Full CLI column parity: โ”(mood) | # | Tier | SWE% | Ctx | Model | Provider | Last Ping | Avg | Health | Verdict | Stability | Up% | AI Lat. | TPS | Trend
4
+ * ๐Ÿ“– Full CLI column parity: โญ(fav) | โ”(mood) | # | Tier | SWE% | Ctx | Model | Provider | Last Ping | Avg | Health | Verdict | Stability | Up% | AI Lat. | TPS | Trend
5
+ * ๐Ÿ“– M1 additions:
6
+ * - โญ/โ˜† star button per row (TUI `F` key)
7
+ * - Clickable AI Lat. cell โ†’ per-row benchmark (TUI `Ctrl+A`)
8
+ * - Dark-red row class for incompatible models when `toolMode` is set
9
+ * (TUI behavior; tool mode picker ships in M3, but the class is ready now)
5
10
  * ๐Ÿ“– Clickable headers for sorting, medal rankings for top 3, horizontal scroll.
6
11
  * ๐Ÿ“– Sorting is handled by useFilter hook which pushes null/Infinity values to bottom.
12
+ * ๐Ÿ“– Columns are user-resizable: drag the right edge of any header to resize.
13
+ * ๐Ÿ“– Custom widths persist in localStorage via the useColumnSizing hook and survive reloads.
14
+ * ๐Ÿ“– A "Reset columns" button appears in the toolbar only when the user has custom widths.
7
15
  */
8
- import { useMemo } from 'react'
16
+ import { useMemo, useEffect } from 'react'
9
17
  import {
10
18
  useReactTable,
11
19
  getCoreRowModel,
12
20
  flexRender,
13
21
  createColumnHelper,
14
22
  } from '@tanstack/react-table'
23
+ import { IconStar, IconStarFilled, IconPlayerPlayFilled } from '@tabler/icons-react'
24
+ import { useColumnSizing } from '../../hooks/useColumnSizing.js'
15
25
 
16
26
  import MoodCell from '../atoms/MoodCell.jsx'
17
27
  import TierBadge from '../atoms/TierBadge.jsx'
@@ -23,9 +33,15 @@ import VerdictBadge from '../atoms/VerdictBadge.jsx'
23
33
  import Sparkline from '../atoms/Sparkline.jsx'
24
34
  import AILatencyCell from '../atoms/AILatencyCell.jsx'
25
35
  import TPSCell from '../atoms/TPSCell.jsx'
36
+ import NoKeyIcon from '../atoms/NoKeyIcon.jsx'
37
+ import ProviderLogo from '../atoms/ProviderLogo.jsx'
26
38
 
27
39
  import { pingClass } from '../../utils/format.js'
28
40
  import { sweClass } from '../../utils/ranks.js'
41
+ // ๐Ÿ“– Pure-JS, browser-safe import of the TUI's tool compatibility helper. Same
42
+ // ๐Ÿ“– file the TUI uses; both surfaces share the source of truth for which
43
+ // ๐Ÿ“– providers are compatible with which tools.
44
+ import { getCompatibleTools } from '../../../../src/core/tool-metadata.js'
29
45
  import styles from './ModelTable.module.css'
30
46
 
31
47
  const colHelper = createColumnHelper()
@@ -46,13 +62,19 @@ function RankCellRenderer({ row }) {
46
62
 
47
63
  function ModelCellRenderer({ row }) {
48
64
  const m = row.original
65
+ const showNoKey = !m.hasApiKey && !m.cliOnly
49
66
  return (
50
67
  <div className={styles.modelCell}>
51
68
  <StatusDot status={m.status} />
52
69
  <div className={styles.modelMeta}>
53
70
  <div className={styles.modelHeader}>
54
71
  <span className={styles.modelName}>{m.label}</span>
55
- {!m.hasApiKey && !m.cliOnly && <span className={styles.noKey}>NO KEY</span>}
72
+ {showNoKey && (
73
+ <NoKeyIcon
74
+ size={14}
75
+ title="No API key configured โ€” health will stay in 'NO KEY' state"
76
+ />
77
+ )}
56
78
  </div>
57
79
  <div className={styles.modelId}>{m.modelId}</div>
58
80
  </div>
@@ -71,7 +93,10 @@ function CtxCellRenderer({ row }) {
71
93
  }
72
94
 
73
95
  function ProviderCellRenderer({ row }) {
74
- return <span className={styles.providerPill}>{row.original.origin}</span>
96
+ // ๐Ÿ“– Renders the provider as [icon + text-wordmark] on a single row-height
97
+ // ๐Ÿ“– line. Replaces the old bordered `.providerPill` text label. See
98
+ // ๐Ÿ“– atoms/ProviderLogo.jsx for the asset resolution + theme logic.
99
+ return <ProviderLogo providerKey={row.original.providerKey} origin={row.original.origin} />
75
100
  }
76
101
 
77
102
  function LastPingCellRenderer({ row }) {
@@ -112,7 +137,26 @@ function UptimeCellRenderer({ row }) {
112
137
 
113
138
  function AILatencyCellRenderer({ row }) {
114
139
  const m = row.original
115
- return <AILatencyCell result={m.benchmark || null} isRunning={Boolean(m.isBenchmarking)} />
140
+ const isRunning = Boolean(m.isBenchmarking)
141
+ return (
142
+ <div
143
+ className={`${styles.aiLatencyWrap} ${isRunning ? styles.aiLatencyRunning : ''}`}
144
+ onClick={(e) => {
145
+ // ๐Ÿ“– Click anywhere in the cell = fire per-row benchmark (TUI: Ctrl+A).
146
+ e.stopPropagation()
147
+ const handler = window.__fcmRowBenchmarkHandler
148
+ if (typeof handler === 'function') handler(m)
149
+ }}
150
+ title={isRunning ? 'Benchmarkingโ€ฆ' : 'Click to run AI Speed Test (TUI: Ctrl+A)'}
151
+ >
152
+ <AILatencyCell result={m.benchmark || null} isRunning={isRunning} />
153
+ {!isRunning && (
154
+ <span className={styles.aiLatencyPlay} aria-hidden="true">
155
+ <IconPlayerPlayFilled size={10} stroke={1.5} />
156
+ </span>
157
+ )}
158
+ </div>
159
+ )
116
160
  }
117
161
 
118
162
  function TPSCellRenderer({ row }) {
@@ -125,7 +169,32 @@ function TrendCellRenderer({ row }) {
125
169
  }
126
170
 
127
171
  // โ”€โ”€โ”€ Column definitions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
128
- const columns = [
172
+ // ๐Ÿ“– Note: `favorites` / `onBenchmarkRow` are read by the cell renderers below
173
+ // ๐Ÿ“– via closure. We rebuild the columns list whenever any of these change so
174
+ // ๐Ÿ“– the cells always see fresh values.
175
+ const buildColumns = ({ favorites, onBenchmarkRow, onSelectModel }) => [
176
+ // ๐Ÿ“– Star column โ€” leftmost, mirrors the TUI's `F` key.
177
+ colHelper.display({
178
+ id: 'fav',
179
+ header: 'โญ',
180
+ size: 32,
181
+ enableSorting: false,
182
+ cell: ({ row }) => {
183
+ const m = row.original
184
+ const isFav = favorites.isFavorite(m)
185
+ return (
186
+ <button
187
+ className={`${styles.favBtn} ${isFav ? styles.favBtnActive : ''}`}
188
+ onClick={(e) => { e.stopPropagation(); favorites.toggle(m) }}
189
+ title={isFav ? `Unfavorite ${m.label} (TUI: F)` : `Favorite ${m.label} (TUI: F)`}
190
+ aria-label={isFav ? `Unfavorite ${m.label}` : `Favorite ${m.label}`}
191
+ aria-pressed={isFav}
192
+ >
193
+ {isFav ? <IconStarFilled size={14} stroke={1.5} /> : <IconStar size={14} stroke={1.5} />}
194
+ </button>
195
+ )
196
+ },
197
+ }),
129
198
  colHelper.display({
130
199
  id: 'mood',
131
200
  header: 'โ”',
@@ -167,7 +236,11 @@ const columns = [
167
236
  }),
168
237
  colHelper.accessor('origin', {
169
238
  header: 'Provider',
170
- size: 110,
239
+ // ๐Ÿ“– 150px fits the widest wordmark (cloudflare ~145px) at 12px height,
240
+ // ๐Ÿ“– with a small buffer. Users can still drag the column wider if they
241
+ // ๐Ÿ“– want a larger render. The default before this was 110px, but the
242
+ // ๐Ÿ“– icon+wordmark layout needs more horizontal room.
243
+ size: 150,
171
244
  enableSorting: true,
172
245
  cell: ProviderCellRenderer,
173
246
  }),
@@ -232,6 +305,21 @@ const columns = [
232
305
  }),
233
306
  ]
234
307
 
308
+ // ๐Ÿ“– DEFAULT_COLUMN_SIZING: extracted from the columns above. This is the single
309
+ // ๐Ÿ“– source of truth for both the table layout and the useColumnSizing hook's reset target.
310
+ // ๐Ÿ“– The 'Reset columns' button restores these exact widths.
311
+ // ๐Ÿ“– We build a placeholder columns list (no callbacks needed) to extract sizes.
312
+ const PLACEHOLDER_COLUMNS = buildColumns({ favorites: { isFavorite: () => false, toggle: () => {} }, onBenchmarkRow: null, onSelectModel: null })
313
+ export const DEFAULT_COLUMN_SIZING = PLACEHOLDER_COLUMNS.reduce((acc, c) => {
314
+ if (c.id) acc[c.id] = c.size ?? 80
315
+ return acc
316
+ }, {})
317
+
318
+ // ๐Ÿ“– RESIZE_MIN/MAX bounds (px) โ€” used by the handle's drag preview and mirrored in
319
+ // ๐Ÿ“– the hook's clamp. Keep them in sync if you change one.
320
+ const COLUMN_RESIZE_MIN = 24
321
+ const COLUMN_RESIZE_MAX = 1200
322
+
235
323
  // โ”€โ”€โ”€ Sort icon component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
236
324
  function SortIcon({ column }) {
237
325
  if (!column.getCanSort()) return null
@@ -241,56 +329,157 @@ function SortIcon({ column }) {
241
329
  }
242
330
 
243
331
  // โ”€โ”€โ”€ Main component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
244
- export default function ModelTable({ filtered, onSelectModel, sortColumn, sortDirection, onSort }) {
332
+ export default function ModelTable({
333
+ filtered, onSelectModel, onBenchmarkRow, favorites,
334
+ sortColumn, sortDirection, onSort,
335
+ toolMode = null,
336
+ }) {
245
337
  // Compute top3 for medal rows
246
338
  const top3Ids = useMemo(() => {
247
339
  const online = filtered.filter(m => m.status === 'up' && m.avg != null && m.avg !== Infinity && m.avg < 99000)
248
340
  return new Set([...online].sort((a, b) => a.avg - b.avg).slice(0, 3).map(m => m.modelId))
249
341
  }, [filtered])
250
342
 
343
+ // ๐Ÿ“– Wire the per-row benchmark handler through a window-level ref so the
344
+ // ๐Ÿ“– AI Lat. cell can call it without prop-drilling through TanStack's
345
+ // ๐Ÿ“– cell render context. Cleaner than threading `meta` through 17 columns.
346
+ useEffect(() => {
347
+ if (typeof onBenchmarkRow !== 'function') {
348
+ window.__fcmRowBenchmarkHandler = null
349
+ return
350
+ }
351
+ window.__fcmRowBenchmarkHandler = onBenchmarkRow
352
+ return () => {
353
+ if (window.__fcmRowBenchmarkHandler === onBenchmarkRow) {
354
+ window.__fcmRowBenchmarkHandler = null
355
+ }
356
+ }
357
+ }, [onBenchmarkRow])
358
+
359
+ // ๐Ÿ“– Build the columns with the current favorites/onBenchmarkRow handlers.
360
+ // ๐Ÿ“– Re-derive when favorites reference changes so the star cells re-render.
361
+ const columns = useMemo(
362
+ () => buildColumns({ favorites, onBenchmarkRow, onSelectModel }),
363
+ [favorites, onBenchmarkRow, onSelectModel]
364
+ )
365
+
366
+ // ๐Ÿ“– Column sizing state with localStorage persistence (see useColumnSizing.js).
367
+ // ๐Ÿ“– onColumnSizingChange + state.columnSizing are TanStack's contract for resize.
368
+ const { columnSizing, setColumnSizing, resetColumnSizing, hasCustomSizing } =
369
+ useColumnSizing(DEFAULT_COLUMN_SIZING)
370
+
251
371
  // TanStack Table โ€” no getSortedRowModel, sorting lives in useFilter
252
372
  const table = useReactTable({
253
373
  data: filtered,
254
374
  columns,
255
375
  defaultColumn: { enableSorting: true },
256
376
  getCoreRowModel: getCoreRowModel(),
377
+ columnResizeMode: 'onChange',
378
+ state: { columnSizing },
379
+ onColumnSizingChange: setColumnSizing,
257
380
  })
258
381
 
259
382
  const rows = table.getRowModel().rows
260
383
 
384
+ // ๐Ÿ“– Total table width = sum of all column sizes. Used to drop the min-width
385
+ // ๐Ÿ“– override once the user has manually tuned the columns, so horizontal
386
+ // ๐Ÿ“– scrolling reflects the user's layout rather than the 1200px default.
387
+ const totalTableWidth = useMemo(
388
+ () => table.getAllColumns().reduce((sum, c) => sum + c.getSize(), 0),
389
+ // ๐Ÿ“– Re-derive when the sizing state object changes (a new object is created per change).
390
+ [columnSizing]
391
+ )
392
+
393
+ // ๐Ÿ“– Global cursor lock: when the user is dragging a resize handle, the cursor
394
+ // ๐Ÿ“– can leave the handle briefly. Adding a body class keeps the col-resize
395
+ // ๐Ÿ“– cursor visible AND blocks accidental text selection until the drag ends.
396
+ const isResizing = Boolean(table.getState().columnSizingInfo?.isResizingColumn)
397
+ useEffect(() => {
398
+ if (typeof document === 'undefined') return
399
+ if (isResizing) {
400
+ document.body.classList.add('col-resizing')
401
+ } else {
402
+ document.body.classList.remove('col-resizing')
403
+ }
404
+ return () => {
405
+ document.body.classList.remove('col-resizing')
406
+ }
407
+ }, [isResizing])
408
+
261
409
  if (rows.length === 0) {
262
410
  return <div className={styles.empty}>No models match your filters</div>
263
411
  }
264
412
 
265
413
  return (
266
414
  <div className={styles.container}>
267
- <table className={styles.table}>
415
+ {hasCustomSizing && (
416
+ <div className={styles.resizeToolbar}>
417
+ <span className={styles.resizeToolbarHint}>
418
+ ๐Ÿ“ Custom column widths ยท total <strong>{Math.round(totalTableWidth)}px</strong>
419
+ </span>
420
+ <button
421
+ type="button"
422
+ className={styles.resizeResetButton}
423
+ onClick={resetColumnSizing}
424
+ title="Restore the default column widths"
425
+ >
426
+ โ†บ Reset columns
427
+ </button>
428
+ </div>
429
+ )}
430
+ <table
431
+ className={styles.table}
432
+ style={hasCustomSizing ? { minWidth: `${totalTableWidth}px` } : undefined}
433
+ >
268
434
  <thead>
269
435
  {table.getHeaderGroups().map(headerGroup => (
270
436
  <tr key={headerGroup.id}>
271
437
  {headerGroup.headers.map(header => {
272
438
  const col = header.column
273
439
  const canSort = SORTABLE_COLUMN_IDS.has(col.id)
440
+ const isResizing = col.getIsResizing?.() === true
441
+ const sizePx = `${header.getSize()}px`
274
442
  return (
275
443
  <th
276
444
  key={header.id}
277
445
  className={styles.th}
278
446
  onClick={canSort ? () => onSort(header.id) : undefined}
279
- style={{ cursor: canSort ? 'pointer' : 'default' }}
447
+ style={{ width: sizePx, minWidth: sizePx, maxWidth: sizePx, cursor: canSort ? 'pointer' : 'default' }}
280
448
  title={`Sort ${col.columnDef.header}: asc โ†’ desc โ†’ reset`}
281
449
  >
282
- {flexRender(col.columnDef.header, header.getContext())}
283
- {canSort && (
284
- <span className={
285
- sortColumn === col.id
286
- ? styles.sortIconActive
287
- : styles.sortIcon
288
- }>
289
- {sortColumn === col.id
290
- ? (sortDirection === 'asc' ? 'โ†‘' : 'โ†“')
291
- : 'โ‡…'}
450
+ <div className={styles.thInner}>
451
+ <span className={styles.thLabel}>
452
+ {flexRender(col.columnDef.header, header.getContext())}
453
+ {canSort && (
454
+ <span className={
455
+ sortColumn === col.id
456
+ ? styles.sortIconActive
457
+ : styles.sortIcon
458
+ }>
459
+ {sortColumn === col.id
460
+ ? (sortDirection === 'asc' ? 'โ†‘' : 'โ†“')
461
+ : 'โ‡…'}
462
+ </span>
463
+ )}
292
464
  </span>
293
- )}
465
+ {/* ๐Ÿ“– Resize handle: full-height grab zone on the right edge of every header.
466
+ ๐Ÿ“– TanStack's column.getResizeHandler() wires mouse + touch to the
467
+ ๐Ÿ“– table's columnSizing state via the onColumnSizingChange callback. */}
468
+ <span
469
+ className={`${styles.resizeHandle} ${isResizing ? styles.resizeHandleActive : ''}`}
470
+ onClick={(e) => e.stopPropagation()}
471
+ onMouseDown={header.getResizeHandler()}
472
+ onTouchStart={header.getResizeHandler()}
473
+ onDoubleClick={() => {
474
+ // ๐Ÿ“– Double-click resets just this column to its declared default width.
475
+ setColumnSizing((prev) => ({ ...prev, [col.id]: DEFAULT_COLUMN_SIZING[col.id] }))
476
+ }}
477
+ title={`Drag to resize ${col.columnDef.header} (double-click to reset this column)`}
478
+ role="separator"
479
+ aria-orientation="vertical"
480
+ aria-label={`Resize ${col.columnDef.header} column`}
481
+ />
482
+ </div>
294
483
  </th>
295
484
  )
296
485
  })}
@@ -301,22 +490,38 @@ export default function ModelTable({ filtered, onSelectModel, sortColumn, sortDi
301
490
  {rows.map((row, i) => {
302
491
  const m = row.original
303
492
  const rankIdx = [...top3Ids].indexOf(m.modelId)
304
- const rowClass = rankIdx >= 0 ? styles[`rank${rankIdx + 1}`] : ''
493
+ const rowClasses = []
494
+ if (rankIdx >= 0) rowClasses.push(styles[`rank${rankIdx + 1}`])
305
495
  // ๐Ÿ“– Per-row benchmark state comes from the backend. The old global
306
496
  // ๐Ÿ“– heuristic made every unbenchmarked row spin until the whole scan ended.
307
- const isBenchRunning = Boolean(m.isBenchmarking)
308
- const benchRowClass = isBenchRunning ? ` ${styles.benchRow}` : ''
497
+ if (m.isBenchmarking) rowClasses.push(styles.benchRow)
498
+ // ๐Ÿ“– Tool-compat dark-red row when an active tool mode is set and the
499
+ // ๐Ÿ“– model isn't compatible with it. Mirrors the TUI render-table.js
500
+ // ๐Ÿ“– behavior. The picker itself ships in M3; for M1 the visual is wired.
501
+ if (toolMode) {
502
+ const compat = getCompatibleTools(m.providerKey)
503
+ if (!compat.includes(toolMode)) {
504
+ rowClasses.push(styles.incompatible)
505
+ }
506
+ }
309
507
  return (
310
508
  <tr
311
509
  key={row.id}
312
- className={`${rowClass}${benchRowClass}`}
510
+ className={rowClasses.join(' ')}
313
511
  onClick={() => onSelectModel(row.original)}
314
512
  >
315
- {row.getVisibleCells().map(cell => (
316
- <td key={cell.id} className={styles.td}>
317
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
318
- </td>
319
- ))}
513
+ {row.getVisibleCells().map(cell => {
514
+ const sizePx = `${cell.column.getSize()}px`
515
+ return (
516
+ <td
517
+ key={cell.id}
518
+ className={styles.td}
519
+ style={{ width: sizePx, minWidth: sizePx, maxWidth: sizePx }}
520
+ >
521
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
522
+ </td>
523
+ )
524
+ })}
320
525
  </tr>
321
526
  )
322
527
  })}