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
@@ -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
+ }
@@ -1,11 +1,16 @@
1
1
  /**
2
2
  * @file web/src/components/dashboard/FilterBar.jsx
3
- * @description Filter controls for tier, status, provider, ping interval, and live indicator.
4
- * 📖 Shows "Next ping in Xs" countdown matching TUI behavior. Ping mode selector (Speed/Normal/Slow/Forced).
3
+ * @description Filter controls for the model table TUI parity chips.
4
+ * 📖 M1 parity: tier (T) / status / provider (D) / verdict (V) / health (H) / visibility (E)
5
+ * 📖 + custom text filter chip with "X" clear + reset view button (N).
6
+ * 📖 Each chip is a cycling button matching the TUI single-key behavior.
7
+ * 📖 The "Next ping in Xs" countdown still shows the live status.
5
8
  */
6
- import { useState, useEffect } from 'react'
9
+ import { useState, useEffect, useMemo } from 'react'
10
+ import { IconRefresh, IconX, IconFilter, IconCircleCheck, IconAlertTriangle, IconActivity, IconEye, IconStar } from '@tabler/icons-react'
7
11
  import styles from './FilterBar.module.css'
8
12
 
13
+ // 📖 Chip sets match the TUI cycles 1:1 (see useFilter.js). Keep these in sync.
9
14
  const TIERS = [
10
15
  { key: 'all', label: 'All' },
11
16
  { key: 'S+', label: 'S+' },
@@ -19,10 +24,36 @@ const TIERS = [
19
24
  ]
20
25
  const STATUSES = [
21
26
  { key: 'all', label: 'All' },
22
- { key: 'up', label: 'Online' },
23
- { key: 'down', label: 'Offline' },
27
+ { key: 'up', label: 'Up' },
28
+ { key: 'down', label: 'Down' },
24
29
  { key: 'pending', label: 'Pending' },
25
30
  ]
31
+ const VERDICTS = [
32
+ { key: 'all', label: 'All' },
33
+ { key: 'Perfect', label: 'Perfect' },
34
+ { key: 'Normal', label: 'Normal' },
35
+ { key: 'Spiky', label: 'Spiky' },
36
+ { key: 'Slow', label: 'Slow' },
37
+ { key: 'Overloaded', label: 'Overloaded' },
38
+ { key: 'Down', label: 'Down' },
39
+ { key: 'Unstable', label: 'Unstable' },
40
+ { key: 'Pending', label: 'Pending' },
41
+ ]
42
+ const HEALTHS = [
43
+ { key: 'all', label: 'All' },
44
+ { key: 'up', label: 'Up' },
45
+ { key: 'timeout', label: 'Timeout' },
46
+ { key: 'down', label: 'Down' },
47
+ { key: 'pending', label: 'Pending' },
48
+ { key: 'noauth', label: 'No key' },
49
+ { key: 'auth_error', label: 'Auth err' },
50
+ ]
51
+ // 📖 TUI's E key cycle. The Web mirrors the same 3-state machine.
52
+ const VISIBILITY_MODES = [
53
+ { key: 'normal', label: 'All models', hint: 'Show everything' },
54
+ { key: 'configured', label: 'Configured only', hint: 'Hide models with no key or auth errors' },
55
+ { key: 'usable', label: 'Usable only', hint: 'Only Health UP + good verdict' },
56
+ ]
26
57
 
27
58
  const PING_MODES = [
28
59
  { key: 'speed', label: '⚡ Speed', interval: '2s', color: '#00ff88' },
@@ -41,10 +72,36 @@ function formatCountdown(ms) {
41
72
  return `${m}m${rem > 0 ? rem + 's' : ''}`
42
73
  }
43
74
 
75
+ function ChipRow({ items, value, onChange, label }) {
76
+ return (
77
+ <div className={styles.group}>
78
+ {label && <label className={styles.filterLabel}>{label}</label>}
79
+ <div className={styles.chipRow}>
80
+ {items.map((item) => (
81
+ <button
82
+ key={item.key}
83
+ className={`${styles.chip} ${value === item.key ? styles.chipActive : ''}`}
84
+ onClick={() => onChange(item.key)}
85
+ title={item.hint || item.label}
86
+ >
87
+ {item.label}
88
+ </button>
89
+ ))}
90
+ </div>
91
+ </div>
92
+ )
93
+ }
94
+
44
95
  export default function FilterBar({
45
96
  filterTier, setFilterTier,
46
97
  filterStatus, setFilterStatus,
47
98
  filterProvider, setFilterProvider,
99
+ filterVerdict, setFilterVerdict,
100
+ filterHealth, setFilterHealth,
101
+ visibilityMode, setVisibilityMode,
102
+ customTextFilter, setCustomTextFilter,
103
+ searchQuery,
104
+ onResetView,
48
105
  providers,
49
106
  pingMode, setPingMode,
50
107
  nextPingAt,
@@ -55,7 +112,6 @@ export default function FilterBar({
55
112
  }) {
56
113
  const [countdown, setCountdown] = useState(null)
57
114
 
58
- // Update countdown every second when nextPingAt is set
59
115
  useEffect(() => {
60
116
  if (nextPingAt == null) return
61
117
  const tick = () => {
@@ -69,11 +125,28 @@ export default function FilterBar({
69
125
 
70
126
  const countdownDisplay = countdown !== null ? formatCountdown(countdown) : null
71
127
 
72
- // Global benchmark progress percentage
128
+ // 📖 Custom text filter chip — sticks to the right of the search bar, with
129
+ // 📖 an "X" to clear (TUI's `X` key behavior).
130
+ const customFilterActive = Boolean(customTextFilter && customTextFilter.trim().length > 0)
131
+
73
132
  const benchmarkPct = globalBenchmarkRunning && globalBenchmarkTotal > 0
74
133
  ? Math.round((globalBenchmarkCompleted / globalBenchmarkTotal) * 100)
75
134
  : 0
76
135
 
136
+ // 📖 Count of active non-default filters to show on the Reset button.
137
+ const activeFilterCount = useMemo(() => {
138
+ let n = 0
139
+ if (filterTier !== 'all') n++
140
+ if (filterStatus !== 'all') n++
141
+ if (filterProvider !== 'all') n++
142
+ if (filterVerdict !== 'all') n++
143
+ if (filterHealth !== 'all') n++
144
+ if (visibilityMode !== 'normal') n++
145
+ if (customFilterActive) n++
146
+ if (searchQuery && searchQuery.trim().length > 0) n++
147
+ return n
148
+ }, [filterTier, filterStatus, filterProvider, filterVerdict, filterHealth, visibilityMode, customFilterActive, searchQuery])
149
+
77
150
  return (
78
151
  <section className={styles.filters}>
79
152
  {/* ── Global benchmark progress bar (Ctrl+U) ── */}
@@ -90,54 +163,79 @@ export default function FilterBar({
90
163
  <span className={styles.benchmarkPct}>{benchmarkPct}%</span>
91
164
  </div>
92
165
  )}
166
+
167
+ <ChipRow label="Tier" items={TIERS} value={filterTier} onChange={setFilterTier} />
168
+ <ChipRow label="Status" items={STATUSES} value={filterStatus} onChange={setFilterStatus} />
169
+ <ChipRow label="Verdict" items={VERDICTS} value={filterVerdict} onChange={setFilterVerdict} />
170
+ <ChipRow label="Health" items={HEALTHS} value={filterHealth} onChange={setFilterHealth} />
171
+
93
172
  <div className={styles.group}>
94
- <label className={styles.filterLabel}>Tier</label>
95
- <div className={styles.tierRow}>
96
- {TIERS.map(t => (
97
- <button
98
- key={t.key}
99
- className={`${styles.tierBtn} ${filterTier === t.key ? styles.active : ''}`}
100
- onClick={() => setFilterTier(t.key)}
101
- >
102
- {t.label}
103
- </button>
104
- ))}
105
- </div>
106
- </div>
107
- <div className={styles.group}>
108
- <label className={styles.filterLabel}>Status</label>
109
- <div className={styles.tierRow}>
110
- {STATUSES.map(s => (
111
- <button
112
- key={s.key}
113
- className={`${styles.tierBtn} ${filterStatus === s.key ? styles.active : ''}`}
114
- onClick={() => setFilterStatus(s.key)}
115
- >
116
- {s.label}
117
- </button>
173
+ <label className={styles.filterLabel}>Visibility</label>
174
+ <select
175
+ className={styles.select}
176
+ value={visibilityMode}
177
+ onChange={(e) => setVisibilityMode(e.target.value)}
178
+ title={VISIBILITY_MODES.find((v) => v.key === visibilityMode)?.hint}
179
+ >
180
+ {VISIBILITY_MODES.map((v) => (
181
+ <option key={v.key} value={v.key}>{v.label}</option>
118
182
  ))}
119
- </div>
183
+ </select>
120
184
  </div>
185
+
121
186
  <div className={styles.group}>
122
187
  <label className={styles.filterLabel}>Provider</label>
123
188
  <select
124
189
  className={styles.providerSelect}
125
190
  value={filterProvider}
126
- onChange={e => setFilterProvider(e.target.value)}
191
+ onChange={(e) => setFilterProvider(e.target.value)}
127
192
  >
128
193
  <option value="all">All Providers</option>
129
- {providers.map(p => (
194
+ {providers.map((p) => (
130
195
  <option key={p.key} value={p.key}>{p.name} ({p.count})</option>
131
196
  ))}
132
197
  </select>
133
198
  </div>
199
+
134
200
  <div className={styles.spacer} />
135
201
 
202
+ {/* ── Custom text filter chip (TUI's Ctrl+P "Apply text filter") ── */}
203
+ {customFilterActive && (
204
+ <div className={styles.group}>
205
+ <label className={styles.filterLabel}>Text</label>
206
+ <div className={styles.customFilterChip} title="Click X to clear (TUI: X key)">
207
+ <span className={styles.customFilterIcon}><IconFilter size={12} stroke={1.5} /></span>
208
+ <span className={styles.customFilterLabel}>{customTextFilter}</span>
209
+ <button
210
+ className={styles.customFilterClear}
211
+ onClick={() => setCustomTextFilter(null)}
212
+ title="Clear custom text filter (TUI: X)"
213
+ aria-label="Clear custom text filter"
214
+ >
215
+ <IconX size={12} stroke={2} />
216
+ </button>
217
+ </div>
218
+ </div>
219
+ )}
220
+
221
+ {/* ── Reset view (TUI: N) — only visible when filters are active ── */}
222
+ {activeFilterCount > 0 && (
223
+ <button
224
+ className={styles.resetBtn}
225
+ onClick={onResetView}
226
+ title={`Reset ${activeFilterCount} active filter${activeFilterCount === 1 ? '' : 's'} (TUI: N)`}
227
+ >
228
+ <IconRefresh size={12} stroke={1.5} />
229
+ <span>Reset</span>
230
+ <span className={styles.resetBadge}>{activeFilterCount}</span>
231
+ </button>
232
+ )}
233
+
136
234
  {/* ── Ping interval selector ── */}
137
235
  <div className={styles.group}>
138
236
  <label className={styles.filterLabel}>Ping</label>
139
237
  <div className={styles.pingRow}>
140
- {PING_MODES.map(m => (
238
+ {PING_MODES.map((m) => (
141
239
  <button
142
240
  key={m.key}
143
241
  className={`${styles.pingBtn} ${pingMode === m.key ? styles.pingBtnActive : ''}`}
@@ -151,25 +249,20 @@ export default function FilterBar({
151
249
  </div>
152
250
  </div>
153
251
 
154
- {/* ── Next ping countdown + LIVE ── */}
252
+ {/* ── Next ping countdown (TUI parity: always show the delay) ──
253
+ 📖 The TUI footer always renders `next : Xs` regardless of whether
254
+ 📖 a ping is in flight — users care about the delay until the next
255
+ 📖 cycle, not the "is pinging right now" boolean. We follow the same
256
+ 📖 model: a single countdown line, with a small pulsing dot when
257
+ 📖 `isPinging` is true so the LIVE state is still visible without
258
+ 📖 resorting to the "Pinging…" text. */}
155
259
  <div className={styles.group}>
156
- {isPinging ? (
157
- <div className={styles.nextPing} title="Pinging now…">
158
- <span className={styles.pingingDot} />
159
- <span className={styles.pingingText}>Pinging…</span>
160
- </div>
161
- ) : countdownDisplay ? (
162
- <div className={styles.nextPing} title="Next ping countdown">
163
- <span className={styles.nextPingLabel}>Next</span>
164
- <span className={styles.nextPingTime}>{countdownDisplay}</span>
165
- </div>
166
- ) : (
167
- <div className={styles.live}>
168
- <span className={styles.liveDot} />
169
- <span>LIVE</span>
170
- </div>
171
- )}
260
+ <div className={styles.nextPing} title="Next ping countdown">
261
+ <span className={styles.nextPingLabel}>next ping in</span>
262
+ <span className={styles.nextPingTime}>{countdownDisplay ?? '—'}</span>
263
+ {isPinging && <span className={styles.pingingDot} aria-hidden="true" />}
264
+ </div>
172
265
  </div>
173
266
  </section>
174
267
  )
175
- }
268
+ }