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
@@ -1,39 +1,179 @@
1
+ /**
2
+ * @file web/src/components/layout/Header.module.css
3
+ * @description Layout refactor (M1): the left sidebar is gone, so the header
4
+ * is now the only navigation rail. It hosts the brand mark, primary nav
5
+ * buttons, search, and right-side actions (⌘K, AI Latency, theme, export).
6
+ *
7
+ * 📖 The model table underneath is 100% of the viewport width — the header
8
+ * uses the same width, and nav items can collapse into a kebab at narrow widths.
9
+ */
10
+
1
11
  .header {
2
12
  position: sticky;
3
13
  top: 0;
4
14
  z-index: 100;
5
- height: 60px;
15
+ height: var(--header-h, 60px);
6
16
  display: flex;
7
17
  align-items: center;
8
18
  justify-content: space-between;
9
- padding: 0 24px;
10
- gap: 20px;
19
+ padding: 0 20px;
20
+ gap: 16px;
11
21
  background: var(--color-bg-card);
12
22
  backdrop-filter: blur(20px) saturate(1.5);
13
23
  -webkit-backdrop-filter: blur(20px) saturate(1.5);
14
24
  border-bottom: 1px solid var(--color-border);
25
+ width: 100%;
26
+ }
27
+
28
+ .left {
29
+ display: flex;
30
+ align-items: center;
31
+ gap: 14px;
32
+ flex-shrink: 0;
33
+ }
34
+
35
+ .center {
36
+ flex: 1;
37
+ max-width: 480px;
38
+ min-width: 0;
15
39
  }
16
- .left { display: flex; align-items: center; gap: 12px; }
17
- .center { flex: 1; max-width: 480px; }
18
- .right { display: flex; align-items: center; gap: 10px; }
19
40
 
20
- .logo { display: flex; align-items: center; gap: 8px; }
21
- .logoIcon { font-size: 22px; }
41
+ .right {
42
+ display: flex;
43
+ align-items: center;
44
+ gap: 8px;
45
+ flex-shrink: 0;
46
+ }
47
+
48
+ /* ─── Brand ─── */
49
+ .logo { display: flex; align-items: center; gap: 6px; }
50
+ .logoIcon {
51
+ color: var(--color-brand);
52
+ font-family: var(--font-mono);
53
+ font-size: 20px;
54
+ font-weight: 900;
55
+ display: flex;
56
+ align-items: center;
57
+ }
22
58
  .logoText {
23
- font-size: 16px; font-weight: 700;
24
- background: linear-gradient(135deg, var(--color-accent), #06b6d4);
25
- -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
59
+ font-size: 14px; font-weight: 700;
60
+ color: var(--color-text);
61
+ display: flex;
62
+ align-items: center;
26
63
  }
64
+ .logoTextHighlight { color: var(--color-brand); }
27
65
  .version {
28
- font-size: 11px; font-weight: 500; font-family: var(--font-mono);
66
+ font-size: 10px; font-weight: 500; font-family: var(--font-mono);
29
67
  color: var(--color-text-dim); background: var(--color-accent-dim);
30
- padding: 2px 8px; border-radius: 999px; border: 1px solid var(--color-border);
68
+ padding: 2px 6px; border-radius: 999px; border: 1px solid var(--color-border);
31
69
  }
32
70
 
71
+ /* ─── Primary nav (replaces the sidebar) ─── */
72
+ .nav {
73
+ display: flex;
74
+ align-items: center;
75
+ gap: 2px;
76
+ margin-left: 8px;
77
+ padding-left: 12px;
78
+ border-left: 1px solid var(--color-border);
79
+ height: 36px;
80
+ }
81
+
82
+ .navBtn {
83
+ display: inline-flex;
84
+ align-items: center;
85
+ gap: 5px;
86
+ height: 30px;
87
+ padding: 0 10px;
88
+ font-size: 12px;
89
+ font-weight: 600;
90
+ font-family: var(--font-sans);
91
+ color: var(--color-text-muted);
92
+ background: transparent;
93
+ border: 1px solid transparent;
94
+ border-radius: 6px;
95
+ cursor: pointer;
96
+ transition: background 150ms, color 150ms, border-color 150ms;
97
+ white-space: nowrap;
98
+ }
99
+ .navBtn:hover {
100
+ background: var(--color-bg-hover);
101
+ color: var(--color-text);
102
+ }
103
+ .navBtnActive {
104
+ background: var(--color-accent-dim);
105
+ color: var(--color-accent);
106
+ border-color: var(--color-border);
107
+ font-weight: 700;
108
+ }
109
+ .navBtnActive:hover {
110
+ background: var(--color-accent-dim);
111
+ color: var(--color-accent);
112
+ }
113
+
114
+ /* 📖 "Coming in M2" badge — small inline label that shows the menu structure
115
+ 📖 is honest even before those milestones land. */
116
+ .comingBadge {
117
+ font-size: 9px;
118
+ font-weight: 700;
119
+ font-family: var(--font-mono);
120
+ color: var(--color-text-dim);
121
+ background: var(--color-surface);
122
+ border: 1px solid var(--color-border);
123
+ padding: 1px 4px;
124
+ border-radius: 3px;
125
+ margin-left: 2px;
126
+ line-height: 1;
127
+ text-transform: uppercase;
128
+ letter-spacing: 0.3px;
129
+ }
130
+
131
+ /* ─── Overflow menu (kebab) ─── */
132
+ .menuWrap { position: relative; }
133
+ .menuTrigger {
134
+ width: 30px;
135
+ padding: 0;
136
+ justify-content: center;
137
+ }
138
+ .menuPopover {
139
+ position: absolute;
140
+ top: calc(100% + 6px);
141
+ right: 0;
142
+ min-width: 200px;
143
+ background: var(--color-bg-elevated);
144
+ border: 1px solid var(--color-border);
145
+ border-radius: 8px;
146
+ padding: 4px;
147
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.32);
148
+ z-index: 200;
149
+ display: flex;
150
+ flex-direction: column;
151
+ gap: 1px;
152
+ }
153
+ .menuItem {
154
+ display: flex;
155
+ align-items: center;
156
+ gap: 8px;
157
+ width: 100%;
158
+ padding: 7px 10px;
159
+ font-size: 12px;
160
+ font-weight: 500;
161
+ color: var(--color-text);
162
+ background: transparent;
163
+ border: none;
164
+ border-radius: 5px;
165
+ cursor: pointer;
166
+ text-align: left;
167
+ transition: background 100ms;
168
+ }
169
+ .menuItem:hover { background: var(--color-bg-hover); }
170
+ .menuItem > span:first-of-type { flex: 1; }
171
+
172
+ /* ─── Search ─── */
33
173
  .searchBar {
34
174
  display: flex; align-items: center; gap: 8px;
35
175
  background: var(--color-surface); border: 1px solid var(--color-border);
36
- border-radius: 10px; padding: 0 12px; height: 36px;
176
+ border-radius: 8px; padding: 0 12px; height: 34px;
37
177
  transition: border-color 150ms, box-shadow 150ms;
38
178
  }
39
179
  .searchBar:focus-within {
@@ -44,14 +184,11 @@
44
184
  .searchInput {
45
185
  flex: 1; background: none; border: none; outline: none;
46
186
  color: var(--color-text); font-size: 13px; font-family: var(--font-sans);
187
+ min-width: 0;
47
188
  }
48
189
  .searchInput::placeholder { color: var(--color-text-dim); }
49
- .kbd {
50
- font-size: 10px; font-family: var(--font-mono); color: var(--color-text-dim);
51
- background: var(--color-bg); border: 1px solid var(--color-border);
52
- padding: 2px 6px; border-radius: 4px; flex-shrink: 0;
53
- }
54
190
 
191
+ /* ─── Right-side buttons ─── */
55
192
  .iconBtn {
56
193
  display: inline-flex; align-items: center; justify-content: center;
57
194
  padding: 6px 8px; border: 1px solid var(--color-border); border-radius: 6px;
@@ -59,15 +196,76 @@
59
196
  cursor: pointer; font-size: 16px; transition: all 150ms;
60
197
  }
61
198
  .iconBtn:hover { background: var(--color-bg-hover); border-color: var(--color-border-hover); }
62
- .primaryBtn {
199
+
200
+ /* 📖 ⌘K button — the only global keyboard shortcut in the Web UI.
201
+ 📖 Tinted accent so users can spot it instantly. */
202
+ .cmdkBtn {
203
+ display: inline-flex; align-items: center; gap: 6px;
204
+ padding: 0 10px; height: 30px;
205
+ border: 1px solid var(--color-border); border-radius: 6px;
206
+ background: var(--color-surface); color: var(--color-text);
207
+ cursor: pointer; font-size: 12px; font-weight: 600;
208
+ font-family: var(--font-mono);
209
+ transition: all 150ms;
210
+ }
211
+ .cmdkBtn:hover {
212
+ border-color: var(--color-accent);
213
+ background: var(--color-bg-hover);
214
+ }
215
+ .cmdkLabel { font-size: 11px; letter-spacing: 0.5px; }
216
+
217
+ /* ─── AI Latency benchmark button (reused from before) ─── */
218
+ .benchmarkBtn {
63
219
  display: inline-flex; align-items: center; gap: 6px;
64
- padding: 6px 14px; font-size: 13px; font-weight: 600;
65
- border: 1px solid var(--color-accent); border-radius: 6px;
66
- background: var(--color-accent); color: #000; cursor: pointer;
67
- font-family: var(--font-sans); transition: all 150ms;
220
+ padding: 0 12px; height: 30px; font-size: 12px; font-weight: 700;
221
+ border: 1px solid var(--color-border); border-radius: 6px;
222
+ background: var(--color-surface); color: var(--color-text);
223
+ cursor: pointer; font-family: var(--font-mono); transition: all 150ms;
224
+ }
225
+ .benchmarkBtn:hover {
226
+ border-color: #00ff88; color: #00ff88; background: rgba(0, 255, 136, 0.08);
227
+ }
228
+ .benchmarkActive {
229
+ border-color: #00ff88 !important; color: #00ff88 !important;
230
+ background: rgba(0, 255, 136, 0.12) !important;
231
+ cursor: not-allowed;
232
+ }
233
+ .benchmarkRunning { display: inline-flex; align-items: center; gap: 6px; }
234
+ @keyframes spin { to { transform: rotate(360deg); } }
235
+ .spinner {
236
+ display: inline-block; width: 10px; height: 10px;
237
+ border: 2px solid rgba(0, 255, 136, 0.3); border-top-color: #00ff88;
238
+ border-radius: 50%; animation: spin 0.8s linear infinite; flex-shrink: 0;
68
239
  }
69
- .primaryBtn:hover { background: var(--color-accent-hover); }
70
240
 
241
+ /* 📖 Light theme: deep green for the benchmark button outline/hover and
242
+ 📖 the spinner ring so they stay visible on the white header bar. */
243
+ :global([data-theme="light"]) .benchmarkBtn:hover {
244
+ border-color: #008f4d; color: #008f4d; background: rgba(0, 143, 77, 0.06);
245
+ }
246
+ :global([data-theme="light"]) .benchmarkActive {
247
+ border-color: #008f4d !important; color: #008f4d !important;
248
+ background: rgba(0, 143, 77, 0.10) !important;
249
+ }
250
+ :global([data-theme="light"]) .spinner {
251
+ border-color: rgba(0, 0, 0, 0.1);
252
+ border-top-color: #008f4d;
253
+ }
254
+
255
+ /* 📖 Responsive — at narrow widths the nav collapses into the kebab menu.
256
+ 📖 We never reintroduce a left sidebar. The table keeps 100% width. */
257
+ @media (max-width: 1024px) {
258
+ .navBtn > span:not(.comingBadge) { display: none; }
259
+ .navBtn { padding: 0 8px; }
260
+ .center { max-width: 320px; }
261
+ }
71
262
  @media (max-width: 768px) {
72
263
  .center { display: none; }
264
+ .logoText { display: none; }
265
+ .cmdkLabel { display: none; }
266
+ .benchmarkBtn > span:not(.benchmarkRunning) { display: none; }
267
+ }
268
+ @media (max-width: 560px) {
269
+ .version { display: none; }
270
+ .navBtn { padding: 0 6px; }
73
271
  }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * @file web/src/components/palette/CommandPalette.jsx
3
+ * @description ⌘K / Ctrl+P command palette — M1 placeholder, M2 ships the full version.
4
+ * 📖 M1 keeps the modal opening, the ⌘K wiring, and a small launcher that exposes
5
+ * 📖 the most-used actions (cycle theme, reset view, navigate views, change ping mode).
6
+ * 📖 M2 will replace this with the TUI-aligned palette that consumes
7
+ * 📖 `src/tui/command-palette.js` via `getCommandPaletteSnapshot()` for full parity.
8
+ *
9
+ * @functions
10
+ * → CommandPalette — main modal component
11
+ */
12
+ import { useEffect, useMemo, useRef, useState } from 'react'
13
+ import { IconSearch, IconCommand, IconBolt, IconArrowRight, IconArrowsExchange } from '@tabler/icons-react'
14
+ import styles from './CommandPalette.module.css'
15
+
16
+ const PING_MODE_CYCLE = ['speed', 'normal', 'slow', 'forced']
17
+
18
+ export default function CommandPalette({
19
+ onClose, onNavigate, onCycleTheme, onResetView,
20
+ onSetPingMode, onToast, onExport,
21
+ currentView, theme, pingMode,
22
+ }) {
23
+ const [query, setQuery] = useState('')
24
+ const [cursor, setCursor] = useState(0)
25
+ const inputRef = useRef(null)
26
+ const listRef = useRef(null)
27
+
28
+ // 📖 Static command list for M1 — these are the universal "no matter what view
29
+ // 📖 I'm in, this is what I want" actions. M2 will replace this with the TUI
30
+ // 📖 registry import.
31
+ const commands = useMemo(() => {
32
+ const items = [
33
+ { id: 'view.dashboard', label: 'Go to Dashboard', icon: IconArrowRight, run: () => onNavigate('dashboard') },
34
+ { id: 'view.settings', label: 'Go to Settings', icon: IconArrowRight, run: () => onNavigate('settings') },
35
+ { id: 'view.analytics', label: 'Go to Analytics', icon: IconArrowRight, run: () => onNavigate('analytics') },
36
+ { id: 'view.recommend', label: 'Open Smart Recommend (coming in M3)', icon: IconArrowRight, disabled: true },
37
+ { id: 'view.router', label: 'Open Router Dashboard (coming in M4)', icon: IconArrowRight, disabled: true },
38
+ { id: 'view.help', label: 'Open Help (coming in M2)', icon: IconArrowRight, disabled: true },
39
+ { id: 'view.changelog', label: 'Open Changelog (coming in M2)', icon: IconArrowRight, disabled: true },
40
+ { id: 'action.cycle-theme', label: `Cycle theme (current: ${theme})`, icon: IconArrowsExchange, run: onCycleTheme },
41
+ { id: 'action.reset-view', label: 'Reset view (filters + sort)', icon: IconArrowsExchange, run: onResetView },
42
+ { id: 'action.ping.speed', label: 'Ping mode → Speed (2s)', icon: IconBolt, run: () => onSetPingMode('speed') },
43
+ { id: 'action.ping.normal', label: 'Ping mode → Normal (10s)', icon: IconBolt, run: () => onSetPingMode('normal') },
44
+ { id: 'action.ping.slow', label: 'Ping mode → Slow (30s)', icon: IconBolt, run: () => onSetPingMode('slow') },
45
+ { id: 'action.ping.forced', label: 'Ping mode → Forced (4s)', icon: IconBolt, run: () => onSetPingMode('forced') },
46
+ { id: 'action.export', label: 'Export models…', icon: IconArrowRight, run: onExport },
47
+ ]
48
+ return items
49
+ }, [onNavigate, onCycleTheme, onResetView, onSetPingMode, onExport, theme])
50
+
51
+ // 📖 Fuzzy filter: substring match (case-insensitive). Good enough for M1's small set.
52
+ const filtered = useMemo(() => {
53
+ const q = query.trim().toLowerCase()
54
+ if (!q) return commands
55
+ return commands.filter((cmd) => cmd.label.toLowerCase().includes(q))
56
+ }, [commands, query])
57
+
58
+ // 📖 Focus the search input on open.
59
+ useEffect(() => {
60
+ inputRef.current?.focus()
61
+ }, [])
62
+
63
+ // 📖 Keyboard handler — Esc, arrows, Enter.
64
+ useEffect(() => {
65
+ const onKey = (e) => {
66
+ if (e.key === 'Escape') { e.preventDefault(); onClose(); return }
67
+ if (e.key === 'ArrowDown') {
68
+ e.preventDefault()
69
+ setCursor((c) => Math.min(filtered.length - 1, c + 1))
70
+ return
71
+ }
72
+ if (e.key === 'ArrowUp') {
73
+ e.preventDefault()
74
+ setCursor((c) => Math.max(0, c - 1))
75
+ return
76
+ }
77
+ if (e.key === 'Enter') {
78
+ e.preventDefault()
79
+ const item = filtered[cursor]
80
+ if (item && !item.disabled) {
81
+ item.run?.()
82
+ onClose()
83
+ } else if (item?.disabled) {
84
+ onToast?.(`${item.label} is not available yet.`, 'info')
85
+ }
86
+ }
87
+ }
88
+ document.addEventListener('keydown', onKey)
89
+ return () => document.removeEventListener('keydown', onKey)
90
+ }, [filtered, cursor, onClose, onToast])
91
+
92
+ // 📖 Reset cursor when query changes.
93
+ useEffect(() => { setCursor(0) }, [query])
94
+
95
+ return (
96
+ <div className={styles.backdrop} onClick={onClose}>
97
+ <div className={styles.modal} onClick={(e) => e.stopPropagation()}>
98
+ <div className={styles.searchRow}>
99
+ <IconSearch size={16} stroke={1.5} className={styles.searchIcon} />
100
+ <input
101
+ ref={inputRef}
102
+ className={styles.input}
103
+ placeholder="Type a command…"
104
+ value={query}
105
+ onChange={(e) => setQuery(e.target.value)}
106
+ autoComplete="off"
107
+ />
108
+ <span className={styles.kbd}><IconCommand size={10} stroke={1.5} />K</span>
109
+ </div>
110
+
111
+ <ul className={styles.list} ref={listRef} role="listbox">
112
+ {filtered.length === 0 && (
113
+ <li className={styles.empty}>No matching command.</li>
114
+ )}
115
+ {filtered.map((cmd, idx) => {
116
+ const Icon = cmd.icon
117
+ return (
118
+ <li
119
+ key={cmd.id}
120
+ className={`${styles.item} ${idx === cursor ? styles.itemActive : ''} ${cmd.disabled ? styles.itemDisabled : ''}`}
121
+ onClick={() => {
122
+ if (cmd.disabled) { onToast?.(`${cmd.label} is not available yet.`, 'info'); return }
123
+ cmd.run?.()
124
+ onClose()
125
+ }}
126
+ onMouseEnter={() => setCursor(idx)}
127
+ role="option"
128
+ aria-selected={idx === cursor}
129
+ >
130
+ <Icon size={14} stroke={1.5} className={styles.itemIcon} />
131
+ <span className={styles.itemLabel}>{cmd.label}</span>
132
+ {idx === cursor && <span className={styles.itemEnter}>↵</span>}
133
+ </li>
134
+ )
135
+ })}
136
+ </ul>
137
+
138
+ <div className={styles.footer}>
139
+ <span><kbd>↑↓</kbd> navigate</span>
140
+ <span><kbd>↵</kbd> select</span>
141
+ <span><kbd>Esc</kbd> close</span>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ )
146
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @file web/src/components/palette/CommandPalette.module.css
3
+ * @description Styles for the ⌘K command palette modal — M1 placeholder version.
4
+ */
5
+
6
+ .backdrop {
7
+ position: fixed;
8
+ inset: 0;
9
+ background: rgba(0, 0, 0, 0.55);
10
+ backdrop-filter: blur(4px);
11
+ -webkit-backdrop-filter: blur(4px);
12
+ z-index: 500;
13
+ display: flex;
14
+ align-items: flex-start;
15
+ justify-content: center;
16
+ padding-top: 12vh;
17
+ }
18
+
19
+ .modal {
20
+ width: min(560px, 92vw);
21
+ background: var(--color-bg-elevated);
22
+ border: 1px solid var(--color-border-hover);
23
+ border-radius: 14px;
24
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
25
+ display: flex;
26
+ flex-direction: column;
27
+ max-height: 70vh;
28
+ overflow: hidden;
29
+ }
30
+
31
+ .searchRow {
32
+ display: flex;
33
+ align-items: center;
34
+ gap: 10px;
35
+ padding: 14px 16px;
36
+ border-bottom: 1px solid var(--color-border);
37
+ }
38
+ .searchIcon { color: var(--color-text-dim); flex-shrink: 0; }
39
+ .input {
40
+ flex: 1;
41
+ background: none;
42
+ border: none;
43
+ outline: none;
44
+ color: var(--color-text);
45
+ font-size: 15px;
46
+ font-family: var(--font-sans);
47
+ }
48
+ .input::placeholder { color: var(--color-text-dim); }
49
+ .kbd {
50
+ font-size: 10px;
51
+ font-family: var(--font-mono);
52
+ color: var(--color-text-dim);
53
+ background: var(--color-surface);
54
+ border: 1px solid var(--color-border);
55
+ padding: 2px 6px;
56
+ border-radius: 4px;
57
+ display: inline-flex;
58
+ align-items: center;
59
+ gap: 2px;
60
+ }
61
+
62
+ .list {
63
+ list-style: none;
64
+ margin: 0;
65
+ padding: 6px;
66
+ overflow-y: auto;
67
+ flex: 1;
68
+ }
69
+
70
+ .item {
71
+ display: flex;
72
+ align-items: center;
73
+ gap: 10px;
74
+ padding: 9px 12px;
75
+ border-radius: 7px;
76
+ cursor: pointer;
77
+ font-size: 13px;
78
+ color: var(--color-text);
79
+ transition: background 80ms;
80
+ user-select: none;
81
+ }
82
+ .itemActive { background: var(--color-accent-dim); }
83
+ .itemDisabled {
84
+ cursor: not-allowed;
85
+ opacity: 0.5;
86
+ }
87
+ .itemDisabled:hover { background: transparent; }
88
+ .itemIcon { color: var(--color-text-muted); flex-shrink: 0; }
89
+ .itemLabel { flex: 1; }
90
+ .itemEnter {
91
+ font-size: 11px;
92
+ color: var(--color-text-dim);
93
+ font-family: var(--font-mono);
94
+ }
95
+
96
+ .empty {
97
+ padding: 20px;
98
+ text-align: center;
99
+ color: var(--color-text-dim);
100
+ font-size: 13px;
101
+ }
102
+
103
+ .footer {
104
+ display: flex;
105
+ gap: 16px;
106
+ padding: 8px 16px;
107
+ border-top: 1px solid var(--color-border);
108
+ font-size: 11px;
109
+ color: var(--color-text-dim);
110
+ background: var(--color-bg-card);
111
+ }
112
+ .footer kbd {
113
+ display: inline-block;
114
+ font-family: var(--font-mono);
115
+ background: var(--color-surface);
116
+ border: 1px solid var(--color-border);
117
+ padding: 1px 5px;
118
+ border-radius: 3px;
119
+ margin-right: 3px;
120
+ font-size: 10px;
121
+ }
@@ -6,6 +6,7 @@
6
6
  * @functions SettingsView → main settings page component
7
7
  */
8
8
  import { useState, useEffect, useCallback } from 'react'
9
+ import { IconSettings, IconPlug, IconCircleCheck, IconKey, IconEye, IconEyeOff, IconCopy, IconTrash } from '@tabler/icons-react'
9
10
  import styles from './SettingsView.module.css'
10
11
  import { maskKey } from '../../utils/format.js'
11
12
 
@@ -160,7 +161,10 @@ export default function SettingsView({ onToast }) {
160
161
  return (
161
162
  <div className={styles.page}>
162
163
  <div className={styles.pageHeader}>
163
- <h1 className={styles.pageTitle}>⚙️ Provider Settings</h1>
164
+ <h1 className={styles.pageTitle}>
165
+ <IconSettings size={24} stroke={1.5} style={{ marginRight: 8, verticalAlign: 'middle' }} />
166
+ Provider Settings
167
+ </h1>
164
168
  <p className={styles.pageSubtitle}>
165
169
  Manage your API keys and provider configurations. Keys are stored locally in{' '}
166
170
  <code>~/.free-coding-models.json</code>
@@ -194,13 +198,23 @@ export default function SettingsView({ onToast }) {
194
198
  return (
195
199
  <div key={key} className={`${styles.card} ${isExpanded ? styles.cardExpanded : ''}`}>
196
200
  <div className={styles.cardHeader} onClick={() => toggleCard(key)}>
197
- <div className={styles.cardIcon}>🔌</div>
201
+ <div className={styles.cardIcon}>
202
+ <IconPlug size={20} stroke={1.5} />
203
+ </div>
198
204
  <div className={styles.cardInfo}>
199
205
  <div className={styles.cardName}>{p.name}</div>
200
206
  <div className={styles.cardMeta}>{p.modelCount} models · {key}</div>
201
207
  </div>
202
208
  <span className={`${styles.cardStatus} ${p.hasKey ? styles.statusConfigured : styles.statusMissing}`}>
203
- {p.hasKey ? '✅ Active' : '🔑 No Key'}
209
+ {p.hasKey ? (
210
+ <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
211
+ <IconCircleCheck size={14} stroke={1.5} /> Active
212
+ </span>
213
+ ) : (
214
+ <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
215
+ <IconKey size={14} stroke={1.5} /> No Key
216
+ </span>
217
+ )}
204
218
  </span>
205
219
  <span className={`${styles.toggleIcon} ${isExpanded ? styles.toggleIconExpanded : ''}`}>▼</span>
206
220
  </div>
@@ -216,10 +230,14 @@ export default function SettingsView({ onToast }) {
216
230
  </span>
217
231
  <div className={styles.keyDisplayActions}>
218
232
  <button className={styles.actionBtn} onClick={() => toggleRevealKey(key)} title={isRevealed ? 'Hide' : 'Reveal'}>
219
- {isRevealed ? '🙈' : '👁️'}
233
+ {isRevealed ? <IconEyeOff size={14} stroke={1.5} /> : <IconEye size={14} stroke={1.5} />}
234
+ </button>
235
+ <button className={styles.actionBtn} onClick={() => copyKey(key)} title="Copy">
236
+ <IconCopy size={14} stroke={1.5} />
237
+ </button>
238
+ <button className={`${styles.actionBtn} ${styles.actionBtnDanger}`} onClick={() => deleteKey(key)} title="Delete Key">
239
+ <IconTrash size={14} stroke={1.5} />
220
240
  </button>
221
- <button className={styles.actionBtn} onClick={() => copyKey(key)} title="Copy">📋</button>
222
- <button className={`${styles.actionBtn} ${styles.actionBtnDanger}`} onClick={() => deleteKey(key)} title="Delete Key">🗑️</button>
223
241
  </div>
224
242
  </div>
225
243
  </div>
@@ -1,6 +1,5 @@
1
1
  .page {
2
2
  padding: 24px;
3
- max-width: 900px;
4
3
  }
5
4
 
6
5
  .pageHeader {