free-coding-models 0.5.85 β†’ 0.5.87

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 (164) hide show
  1. package/README.md +110 -881
  2. package/bin/free-coding-models.js +4 -1
  3. package/changelog/v0.5.86.md +10 -0
  4. package/changelog/v0.5.87.md +48 -0
  5. package/package.json +8 -5
  6. package/sources.js +4 -4
  7. package/src/core/cache.js +3 -1
  8. package/src/core/config.js +43 -5
  9. package/src/core/endpoint-installer.js +23 -12
  10. package/src/core/installed-models-manager.js +43 -20
  11. package/src/core/kilo-config.js +51 -5
  12. package/src/core/kilo.js +5 -3
  13. package/src/core/legacy-proxy-cleanup.js +22 -5
  14. package/src/core/model-merger.js +4 -0
  15. package/src/core/models-drift.js +9 -0
  16. package/src/core/opencode-config.js +57 -5
  17. package/src/core/opencode.js +67 -41
  18. package/src/core/playground.js +7 -0
  19. package/src/core/probe-cache.js +35 -4
  20. package/src/core/provider-key-tester.js +10 -2
  21. package/src/core/provider-metadata.js +10 -0
  22. package/src/core/provider-quota-fetchers.js +14 -7
  23. package/src/core/router-daemon.js +208 -26
  24. package/src/core/runtime-telemetry.js +27 -1
  25. package/src/core/shared-helpers.js +12 -1
  26. package/src/core/shell-env.js +3 -1
  27. package/src/core/telemetry.js +18 -5
  28. package/src/core/tool-launchers.js +9 -6
  29. package/src/core/updater.js +28 -2
  30. package/src/core/utils.js +76 -71
  31. package/src/tui/app.js +47 -16
  32. package/src/tui/command-palette.js +22 -2
  33. package/src/tui/key-handler.js +123 -179
  34. package/src/tui/overlays.js +35 -75
  35. package/src/tui/render-helpers.js +62 -4
  36. package/src/tui/render-table.js +46 -5
  37. package/src/tui/theme.js +36 -5
  38. package/src/tui/tui-filters.js +28 -8
  39. package/src/tui/tui-state.js +22 -6
  40. package/web/dist/assets/index-BpTpPS0g.js +44 -0
  41. package/web/dist/assets/index-CAzFIt8P.css +1 -0
  42. package/web/dist/index.html +5 -9
  43. package/web/index.html +3 -7
  44. package/web/server.js +97 -23
  45. package/web/vite.config.js +8 -0
  46. package/web/app.legacy.js +0 -900
  47. package/web/dist/assets/index-C5-Ywvv3.css +0 -1
  48. package/web/dist/assets/index-DWUely84.js +0 -40
  49. package/web/public/favicon.ico +0 -0
  50. package/web/public/favicons/apple-touch-icon.png +0 -0
  51. package/web/public/favicons/browserconfig.xml +0 -12
  52. package/web/public/favicons/favicon-16x16.png +0 -0
  53. package/web/public/favicons/favicon-192x192.png +0 -0
  54. package/web/public/favicons/favicon-32x32.png +0 -0
  55. package/web/public/favicons/favicon-48x48.png +0 -0
  56. package/web/public/favicons/favicon-512x512.png +0 -0
  57. package/web/public/favicons/favicon-96x96.png +0 -0
  58. package/web/public/favicons/favicon.ico +0 -0
  59. package/web/public/favicons/mstile-150x150.png +0 -0
  60. package/web/public/favicons/mstile-310x150.png +0 -0
  61. package/web/public/favicons/mstile-310x310.png +0 -0
  62. package/web/public/favicons/mstile-512x512.png +0 -0
  63. package/web/public/favicons/mstile-70x70.png +0 -0
  64. package/web/public/favicons/site.webmanifest +0 -25
  65. package/web/src/App.jsx +0 -521
  66. package/web/src/components/analytics/AnalyticsView.jsx +0 -147
  67. package/web/src/components/analytics/AnalyticsView.module.css +0 -190
  68. package/web/src/components/analytics/TokenUsagePanel.jsx +0 -105
  69. package/web/src/components/analytics/TokenUsagePanel.module.css +0 -126
  70. package/web/src/components/atoms/AILatencyCell.jsx +0 -38
  71. package/web/src/components/atoms/AILatencyCell.module.css +0 -51
  72. package/web/src/components/atoms/HealthCell.jsx +0 -143
  73. package/web/src/components/atoms/HealthCell.module.css +0 -60
  74. package/web/src/components/atoms/LastPingCell.jsx +0 -35
  75. package/web/src/components/atoms/LastPingCell.module.css +0 -45
  76. package/web/src/components/atoms/MoodCell.jsx +0 -25
  77. package/web/src/components/atoms/MoodCell.module.css +0 -6
  78. package/web/src/components/atoms/NoKeyIcon.jsx +0 -65
  79. package/web/src/components/atoms/NoKeyIcon.module.css +0 -30
  80. package/web/src/components/atoms/ProviderLogo.jsx +0 -187
  81. package/web/src/components/atoms/ProviderLogo.module.css +0 -103
  82. package/web/src/components/atoms/RankCell.jsx +0 -9
  83. package/web/src/components/atoms/RankCell.module.css +0 -9
  84. package/web/src/components/atoms/Sparkline.jsx +0 -63
  85. package/web/src/components/atoms/StabilityCell.jsx +0 -18
  86. package/web/src/components/atoms/StabilityCell.module.css +0 -13
  87. package/web/src/components/atoms/StatusDot.jsx +0 -13
  88. package/web/src/components/atoms/StatusDot.module.css +0 -29
  89. package/web/src/components/atoms/TPSCell.jsx +0 -36
  90. package/web/src/components/atoms/TPSCell.module.css +0 -44
  91. package/web/src/components/atoms/TierBadge.jsx +0 -10
  92. package/web/src/components/atoms/TierBadge.module.css +0 -29
  93. package/web/src/components/atoms/Toast.jsx +0 -25
  94. package/web/src/components/atoms/Toast.module.css +0 -41
  95. package/web/src/components/atoms/ToastContainer.jsx +0 -16
  96. package/web/src/components/atoms/ToastContainer.module.css +0 -10
  97. package/web/src/components/atoms/VerdictBadge.jsx +0 -36
  98. package/web/src/components/atoms/VerdictBadge.module.css +0 -40
  99. package/web/src/components/changelog/ChangelogView.jsx +0 -135
  100. package/web/src/components/changelog/ChangelogView.module.css +0 -159
  101. package/web/src/components/dashboard/DetailPanel.jsx +0 -217
  102. package/web/src/components/dashboard/DetailPanel.module.css +0 -260
  103. package/web/src/components/dashboard/ExpandedDetailRow.jsx +0 -361
  104. package/web/src/components/dashboard/ExpandedDetailRow.module.css +0 -356
  105. package/web/src/components/dashboard/ExportModal.jsx +0 -84
  106. package/web/src/components/dashboard/ExportModal.module.css +0 -99
  107. package/web/src/components/dashboard/FilterBar.jsx +0 -338
  108. package/web/src/components/dashboard/FilterBar.module.css +0 -342
  109. package/web/src/components/dashboard/ModelTable.jsx +0 -672
  110. package/web/src/components/dashboard/ModelTable.module.css +0 -446
  111. package/web/src/components/dashboard/ProviderDropdown.jsx +0 -156
  112. package/web/src/components/dashboard/ProviderDropdown.module.css +0 -248
  113. package/web/src/components/help/HelpView.jsx +0 -201
  114. package/web/src/components/help/HelpView.module.css +0 -156
  115. package/web/src/components/install/InstallEndpointsView.jsx +0 -309
  116. package/web/src/components/install/InstallEndpointsView.module.css +0 -427
  117. package/web/src/components/installed/InstalledModelsView.jsx +0 -89
  118. package/web/src/components/installed/InstalledModelsView.module.css +0 -200
  119. package/web/src/components/launch/IncompatibleFallbackModal.jsx +0 -69
  120. package/web/src/components/launch/LaunchButton.jsx +0 -30
  121. package/web/src/components/launch/LaunchButton.module.css +0 -35
  122. package/web/src/components/launch/LaunchModal.module.css +0 -125
  123. package/web/src/components/layout/Footer.jsx +0 -19
  124. package/web/src/components/layout/Footer.module.css +0 -10
  125. package/web/src/components/layout/Header.jsx +0 -265
  126. package/web/src/components/layout/Header.module.css +0 -357
  127. package/web/src/components/palette/CommandPalette.jsx +0 -301
  128. package/web/src/components/palette/CommandPalette.module.css +0 -121
  129. package/web/src/components/playground/PlaygroundChat.jsx +0 -527
  130. package/web/src/components/playground/PlaygroundChat.module.css +0 -382
  131. package/web/src/components/playground/PlaygroundView.jsx +0 -371
  132. package/web/src/components/playground/PlaygroundView.module.css +0 -504
  133. package/web/src/components/recommend/RecommendView.jsx +0 -121
  134. package/web/src/components/recommend/RecommendView.module.css +0 -57
  135. package/web/src/components/router/RouterView.jsx +0 -1212
  136. package/web/src/components/router/RouterView.module.css +0 -1289
  137. package/web/src/components/settings/SettingsView.jsx +0 -555
  138. package/web/src/components/settings/SettingsView.module.css +0 -550
  139. package/web/src/components/tools/ToolPicker.jsx +0 -86
  140. package/web/src/components/tools/ToolPicker.module.css +0 -97
  141. package/web/src/components/update/UpdateChip.jsx +0 -104
  142. package/web/src/components/update/UpdateChip.module.css +0 -146
  143. package/web/src/global.css +0 -298
  144. package/web/src/hooks/urlState.constants.js +0 -28
  145. package/web/src/hooks/useChangelog.js +0 -51
  146. package/web/src/hooks/useColumnSizing.js +0 -211
  147. package/web/src/hooks/useFavorites.js +0 -150
  148. package/web/src/hooks/useFilter.js +0 -310
  149. package/web/src/hooks/useInstalledModels.js +0 -42
  150. package/web/src/hooks/useRecommend.js +0 -67
  151. package/web/src/hooks/useRouterDashboard.js +0 -133
  152. package/web/src/hooks/useSSE.js +0 -73
  153. package/web/src/hooks/useSocket.js +0 -203
  154. package/web/src/hooks/useTheme.js +0 -83
  155. package/web/src/hooks/useTokenUsage.js +0 -103
  156. package/web/src/hooks/useToolMode.js +0 -77
  157. package/web/src/hooks/useUpdateChecker.js +0 -91
  158. package/web/src/hooks/useUrlState.js +0 -170
  159. package/web/src/main.jsx +0 -15
  160. package/web/src/utils/download.js +0 -15
  161. package/web/src/utils/format.js +0 -42
  162. package/web/src/utils/m3.js +0 -57
  163. package/web/src/utils/ranks.js +0 -37
  164. package/web/styles.legacy.css +0 -963
@@ -1,357 +0,0 @@
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
-
11
- .header {
12
- position: sticky;
13
- top: 0;
14
- z-index: 100;
15
- height: var(--header-h, 60px);
16
- display: flex;
17
- align-items: center;
18
- justify-content: space-between;
19
- padding: 0 20px;
20
- gap: 16px;
21
- background: var(--color-bg-card);
22
- backdrop-filter: blur(20px) saturate(1.5);
23
- -webkit-backdrop-filter: blur(20px) saturate(1.5);
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;
39
- }
40
-
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: 8px; }
50
- .logoIconSvg {
51
- color: var(--color-brand);
52
- display: flex;
53
- align-items: center;
54
- flex-shrink: 0;
55
- }
56
- .logoText {
57
- font-size: 16px; font-weight: 750;
58
- font-family: var(--font-mono);
59
- color: var(--color-text);
60
- display: flex;
61
- align-items: center;
62
- letter-spacing: -0.2px;
63
- }
64
- .logoTextHighlight { color: var(--color-brand); }
65
- .version {
66
- font-size: 12px; font-weight: 500; font-family: var(--font-mono);
67
- color: var(--color-text-muted); background: var(--color-surface);
68
- padding: 2px 6px; border-radius: 999px; border: 1px solid var(--color-border);
69
- }
70
-
71
- /* ─── Primary nav (replaces the sidebar) ─── */
72
- .nav {
73
- display: flex;
74
- align-items: center;
75
- gap: 8px;
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: 6px;
86
- height: 30px;
87
- padding: 0 12px;
88
- font-size: 13px;
89
- font-weight: 600;
90
- font-family: var(--font-mono); /* Monospace font looks extremely high-tech */
91
- text-transform: uppercase;
92
- letter-spacing: 0.5px;
93
- color: var(--color-text-muted);
94
- background: var(--color-surface);
95
- border: 1px solid var(--color-border);
96
- border-radius: 6px;
97
- cursor: pointer;
98
- transition: all 150ms var(--ease-out);
99
- white-space: nowrap;
100
- }
101
- .navBtn:hover {
102
- background: var(--color-bg-hover);
103
- color: var(--color-text);
104
- border-color: var(--color-border-hover);
105
- }
106
- .navBtnActive {
107
- background: var(--color-accent);
108
- color: var(--color-bg);
109
- border-color: var(--color-accent);
110
- font-weight: 800;
111
- box-shadow: 0 0 12px var(--color-accent-glow);
112
- }
113
- .navBtnActive:hover {
114
- background: var(--color-accent-hover);
115
- color: var(--color-bg);
116
- border-color: var(--color-accent-hover);
117
- }
118
-
119
- /* πŸ“– "Coming in M2" badge β€” small inline label that shows the menu structure
120
- πŸ“– is honest even before those milestones land. */
121
- .comingBadge {
122
- font-size: 10px;
123
- font-weight: 700;
124
- font-family: var(--font-mono);
125
- color: var(--color-text-dim);
126
- background: var(--color-surface);
127
- border: 1px solid var(--color-border);
128
- padding: 1px 4px;
129
- border-radius: 3px;
130
- margin-left: 2px;
131
- line-height: 1;
132
- text-transform: uppercase;
133
- letter-spacing: 0.3px;
134
- }
135
-
136
- /* ─── Overflow menu (kebab) ─── */
137
- .menuWrap { position: relative; }
138
- .menuTrigger {
139
- width: 30px;
140
- padding: 0;
141
- justify-content: center;
142
- }
143
- .menuPopover {
144
- position: absolute;
145
- top: calc(100% + 6px);
146
- right: 0;
147
- min-width: 200px;
148
- background: var(--color-bg-elevated);
149
- border: 1px solid var(--color-border);
150
- border-radius: 8px;
151
- padding: 4px;
152
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.32);
153
- z-index: 200;
154
- display: flex;
155
- flex-direction: column;
156
- gap: 1px;
157
- }
158
- .menuItem {
159
- display: flex;
160
- align-items: center;
161
- gap: 8px;
162
- width: 100%;
163
- padding: 7px 10px;
164
- font-size: 14px;
165
- font-weight: 500;
166
- color: var(--color-text);
167
- background: transparent;
168
- border: none;
169
- border-radius: 5px;
170
- cursor: pointer;
171
- text-align: left;
172
- transition: background 100ms;
173
- }
174
- .menuItem:hover { background: var(--color-bg-hover); }
175
- .menuItemActive {
176
- background: var(--color-accent-dim) !important;
177
- color: var(--color-accent) !important;
178
- font-weight: 700;
179
- }
180
- .menuItem > span:first-of-type { flex: 1; }
181
-
182
- /* ─── Search ─── */
183
- .searchBar {
184
- display: flex; align-items: center; gap: 8px;
185
- background: var(--color-surface); border: 1px solid var(--color-border);
186
- border-radius: 8px; padding: 0 12px; height: 34px;
187
- transition: border-color 150ms, box-shadow 150ms;
188
- }
189
- .searchBar:focus-within {
190
- border-color: var(--color-accent);
191
- box-shadow: 0 0 0 3px var(--color-accent-glow);
192
- }
193
- .searchIcon { font-size: 16px; flex-shrink: 0; }
194
- .searchInput {
195
- flex: 1; background: none; border: none; outline: none;
196
- color: var(--color-text); font-size: 15px; font-family: var(--font-sans);
197
- min-width: 0;
198
- }
199
- .searchInput::placeholder { color: var(--color-text-dim); }
200
-
201
- /* ─── Right-side buttons ─── */
202
- .iconBtn {
203
- display: inline-flex; align-items: center; justify-content: center;
204
- padding: 6px 8px; border: 1px solid var(--color-border); border-radius: 6px;
205
- background: var(--color-surface); color: var(--color-text);
206
- cursor: pointer; font-size: 18px; transition: all 150ms;
207
- }
208
- .iconBtn:hover { background: var(--color-bg-hover); border-color: var(--color-border-hover); }
209
-
210
- /* πŸ“– ⌘K button β€” the only global keyboard shortcut in the Web UI.
211
- πŸ“– Tinted accent so users can spot it instantly. */
212
- .cmdkBtn {
213
- display: inline-flex; align-items: center; gap: 6px;
214
- padding: 0 10px; height: 30px;
215
- border: 1px solid var(--color-border); border-radius: 6px;
216
- background: var(--color-surface); color: var(--color-text);
217
- cursor: pointer; font-size: 14px; font-weight: 600;
218
- font-family: var(--font-mono);
219
- transition: all 150ms;
220
- }
221
- .cmdkBtn:hover {
222
- border-color: var(--color-accent);
223
- background: var(--color-bg-hover);
224
- }
225
- .cmdkLabel { font-size: 13px; letter-spacing: 0.5px; }
226
-
227
- /* ─── AI Latency benchmark button (reused from before) ─── */
228
- .benchmarkBtn {
229
- display: inline-flex; align-items: center; gap: 6px;
230
- padding: 0 12px; height: 30px; font-size: 14px; font-weight: 700;
231
- border: 1px solid var(--color-border); border-radius: 6px;
232
- background: var(--color-surface); color: var(--color-text);
233
- cursor: pointer; font-family: var(--font-mono); transition: all 150ms;
234
- }
235
- .benchmarkBtn:hover {
236
- border-color: #00ff88; color: #00ff88; background: rgba(0, 255, 136, 0.08);
237
- }
238
- .benchmarkActive {
239
- border-color: #00ff88 !important; color: #00ff88 !important;
240
- background: rgba(0, 255, 136, 0.12) !important;
241
- cursor: not-allowed;
242
- }
243
- .benchmarkRunning { display: inline-flex; align-items: center; gap: 6px; }
244
- @keyframes spin { to { transform: rotate(360deg); } }
245
- .spinner {
246
- display: inline-block; width: 10px; height: 10px;
247
- border: 2px solid rgba(0, 255, 136, 0.3); border-top-color: #00ff88;
248
- border-radius: 50%; animation: spin 0.8s linear infinite; flex-shrink: 0;
249
- }
250
-
251
- /* πŸ“– Light theme: deep green for the benchmark button outline/hover and
252
- πŸ“– the spinner ring so they stay visible on the white header bar. */
253
- :global([data-theme="light"]) .benchmarkBtn:hover {
254
- border-color: #008f4d; color: #008f4d; background: rgba(0, 143, 77, 0.06);
255
- }
256
- :global([data-theme="light"]) .benchmarkActive {
257
- border-color: #008f4d !important; color: #008f4d !important;
258
- background: rgba(0, 143, 77, 0.10) !important;
259
- }
260
- :global([data-theme="light"]) .spinner {
261
- border-color: rgba(0, 0, 0, 0.1);
262
- border-top-color: #008f4d;
263
- }
264
-
265
- /* πŸ“– Responsive β€” at narrow widths the nav collapses into the kebab menu.
266
- πŸ“– We never reintroduce a left sidebar. The table keeps 100% width. */
267
- @media (max-width: 1024px) {
268
- .navBtn > span:not(.comingBadge) { display: none; }
269
- .navBtn { padding: 0 8px; }
270
- .center { max-width: 320px; }
271
- }
272
- @media (max-width: 768px) {
273
- .center { display: none; }
274
- .logoText { display: none; }
275
- .cmdkLabel { display: none; }
276
- .benchmarkBtn > span:not(.benchmarkRunning) { display: none; }
277
- .nav { display: none; }
278
- .hamburgerBtn { display: flex; }
279
- }
280
- @media (max-width: 560px) {
281
- .version { display: none; }
282
- }
283
-
284
- /* ─── Hamburger + mobile nav (M5) ─── */
285
- .hamburgerBtn {
286
- display: none;
287
- align-items: center;
288
- justify-content: center;
289
- width: 34px;
290
- height: 34px;
291
- background: var(--color-surface);
292
- border: 1px solid var(--color-border);
293
- border-radius: var(--radius-md);
294
- color: var(--color-text-muted);
295
- cursor: pointer;
296
- margin-left: 8px;
297
- &:hover { color: var(--color-text); background: var(--color-bg-hover); }
298
- }
299
-
300
- .mobileNav {
301
- position: absolute;
302
- top: var(--header-h, 60px);
303
- left: 0;
304
- right: 0;
305
- background: var(--color-bg-elevated);
306
- border-bottom: 1px solid var(--color-border);
307
- padding: 8px 16px 16px;
308
- display: flex;
309
- flex-direction: column;
310
- gap: 2px;
311
- z-index: 150;
312
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
313
- }
314
-
315
- .mobileNavItem {
316
- display: flex;
317
- align-items: center;
318
- gap: 10px;
319
- padding: 10px 12px;
320
- font-size: 15px;
321
- font-weight: 600;
322
- color: var(--color-text-muted);
323
- background: transparent;
324
- border: none;
325
- border-radius: var(--radius-md);
326
- cursor: pointer;
327
- text-align: left;
328
- width: 100%;
329
- &:hover { background: var(--color-bg-hover); color: var(--color-text); }
330
- }
331
-
332
- .mobileNavActive {
333
- color: var(--color-accent);
334
- background: var(--color-accent-dim);
335
- }
336
-
337
- .mobileNavDivider {
338
- height: 1px;
339
- background: var(--color-border);
340
- margin: 6px 0;
341
- }
342
-
343
- /* ─── Keyboard Focus & Micro-interaction Active States ─── */
344
- .navBtn:focus-visible,
345
- .iconBtn:focus-visible,
346
- .cmdkBtn:focus-visible,
347
- .benchmarkBtn:focus-visible {
348
- outline: 2px solid var(--color-accent);
349
- outline-offset: 2px;
350
- }
351
-
352
- .navBtn:active,
353
- .iconBtn:active,
354
- .cmdkBtn:active,
355
- .benchmarkBtn:active {
356
- transform: scale(0.96);
357
- }
@@ -1,301 +0,0 @@
1
- /**
2
- * @file web/src/components/palette/CommandPalette.jsx
3
- * @description ⌘K / Ctrl+P command palette β€” M2 full version, fed by the TUI registry.
4
- * πŸ“– M1 shipped a placeholder; this version pulls entries from
5
- * πŸ“– `src/tui/command-palette.js` (the TUI's source of truth) so the Web and
6
- * πŸ“– TUI palettes always show the same set of commands. Web-only commands
7
- * πŸ“– (open modal pages, set the theme, open the help / changelog / etc.) are
8
- * πŸ“– appended on top of the TUI list.
9
- *
10
- * πŸ“– The palette is a flat searchable list β€” the TUI's hierarchical category
11
- * πŸ“– tree is preserved as a leading "section" label per entry. We fuzzy-search
12
- * πŸ“– using the TUI's `filterCommandPaletteEntries()` so ranking is identical
13
- * πŸ“– across both surfaces.
14
- *
15
- * @functions
16
- * β†’ CommandPalette β†’ main modal component
17
- */
18
- import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
19
- import { IconSearch, IconCommand, IconBolt, IconArrowsExchange, IconExternalLink } from '@tabler/icons-react'
20
- import { buildCommandPaletteEntries, filterCommandPaletteEntries } from '../../../../src/tui/command-palette.js'
21
- import styles from './CommandPalette.module.css'
22
-
23
- const SECTION_META = {
24
- // TUI categories β†’ emoji + label
25
- 'filter': { icon: 'πŸ”', label: 'Filter' },
26
- 'sort': { icon: 'πŸ“Ά', label: 'Sort' },
27
- 'action': { icon: 'βš™οΈ', label: 'Action' },
28
- 'page': { icon: 'πŸ“„', label: 'Page' },
29
- 'update': { icon: '⬆️', label: 'Update' },
30
- // TUI 'tool' sub-categories
31
- 'tool': { icon: '🧰', label: 'Tool' },
32
- }
33
-
34
- const PING_MODE_CYCLE = ['speed', 'normal', 'slow', 'forced']
35
-
36
- // πŸ“– Web-only entries (the Web has modals, not TUI overlays, so the palette
37
- // πŸ“– has a few commands the TUI doesn't). They are appended to the TUI registry
38
- // πŸ“– after a `Web` separator so the two surfaces stay easy to compare.
39
- function buildWebEntries(deps) {
40
- const { onCycleTheme, onResetView, onSetPingMode, theme, pingMode, modelsCount, onExport, onNavigate } = deps
41
- const web = [
42
- // Pages
43
- { id: 'page.help', section: 'page', label: 'Open Help', keywords: ['help', 'shortcuts', 'reference'], run: () => onNavigate?.('help') },
44
- { id: 'page.changelog', section: 'page', label: 'Open Changelog', keywords: ['changelog', 'release', 'history', 'version'], run: () => onNavigate?.('changelog') },
45
- { id: 'page.playground', section: 'page', label: 'Open Playground', keywords: ['playground', 'chat', 'try', 'prompt', 'router'], run: () => onNavigate?.('playground') },
46
- { id: 'page.install-endpoints', section: 'page', label: 'Open Install Endpoints', keywords: ['install', 'endpoint', 'tool', 'configure'], run: () => onNavigate?.('install-endpoints') },
47
- { id: 'page.installed-models', section: 'page', label: 'Open Installed Models', keywords: ['installed', 'models', 'tools'], run: () => onNavigate?.('installed-models') },
48
-
49
- // Theme
50
- { id: 'action.theme.cycle', section: 'action', label: `Cycle theme (current: ${theme})`, keywords: ['theme', 'dark', 'light', 'auto'], run: onCycleTheme },
51
-
52
- // Reset
53
- { id: 'action.reset.view', section: 'action', label: 'Reset view (filters + sort)', keywords: ['reset', 'view', 'clear', 'filters', 'sort'], run: onResetView },
54
-
55
- // Ping mode (the TUI has these too; Web keeps them for parity)
56
- { id: 'action.ping.speed', section: 'action', label: 'Ping mode β†’ Speed (2s)', keywords: ['ping', 'mode', 'speed', 'fast', '2s'], run: () => onSetPingMode?.('speed') },
57
- { id: 'action.ping.normal', section: 'action', label: 'Ping mode β†’ Normal (10s)', keywords: ['ping', 'mode', 'normal', '10s', 'default'], run: () => onSetPingMode?.('normal') },
58
- { id: 'action.ping.slow', section: 'action', label: 'Ping mode β†’ Slow (30s)', keywords: ['ping', 'mode', 'slow', '30s', 'idle'], run: () => onSetPingMode?.('slow') },
59
- { id: 'action.ping.forced', section: 'action', label: 'Ping mode β†’ Forced (4s)', keywords: ['ping', 'mode', 'forced', '4s'], run: () => onSetPingMode?.('forced') },
60
-
61
- // Export
62
- { id: 'action.export', section: 'action', label: 'Export models…', keywords: ['export', 'download', 'json', 'csv', 'clipboard'], run: onExport },
63
- ]
64
- // TUI palette entries with a 'page' type β†’ route through the same Web pages.
65
- return web
66
- }
67
-
68
- export default function CommandPalette({
69
- onClose, onNavigate, onCycleTheme, onResetView,
70
- onSetPingMode, onOpenHelp, onOpenChangelog, onOpenCommandPalette,
71
- onToast, onExport, currentView, theme, pingMode, models,
72
- updateAvailable, latestVersion, onRunUpdate,
73
- }) {
74
- const [query, setQuery] = useState('')
75
- const [cursor, setCursor] = useState(0)
76
- const inputRef = useRef(null)
77
- const listRef = useRef(null)
78
-
79
- // πŸ“– Build the combined list once per render. TUI registry first (so
80
- // πŸ“– its section labels come through), then Web-only entries.
81
- const allCommands = useMemo(() => {
82
- const tuiEntries = buildCommandPaletteEntries(models || []).map((entry) => {
83
- // πŸ“– The TUI palette doesn't carry a 'section' field; derive one from
84
- // πŸ“– the entry id so the Web list can show a category label.
85
- let section = 'action'
86
- if (entry.id.startsWith('filter-')) section = 'filter'
87
- else if (entry.id.startsWith('sort-')) section = 'sort'
88
- else if (entry.id.startsWith('action-')) section = 'action'
89
- else if (entry.id.startsWith('action-set-tool-')) section = 'tool'
90
- else if (entry.id.startsWith('page-') || entry.id === 'open-settings' || entry.id === 'open-help' || entry.id === 'open-changelog') section = 'page'
91
- return { ...entry, section }
92
- })
93
- const webEntries = buildWebEntries({
94
- onCycleTheme, onResetView, onSetPingMode,
95
- theme, pingMode, modelsCount: models?.length ?? 0, onExport, onNavigate,
96
- })
97
- // πŸ“– Update banner entry (only when an update is available) β€” mirrors
98
- // πŸ“– the TUI palette's "auto-prepended when newer version known" rule.
99
- const updateEntries = []
100
- if (updateAvailable && latestVersion) {
101
- updateEntries.push({
102
- id: 'action.update.run', section: 'update',
103
- label: `⬆️ UPDATE NOW β€” v${latestVersion} available (recommended!)`,
104
- keywords: ['update', 'upgrade', 'version', 'install', 'new'],
105
- run: () => onRunUpdate?.(),
106
- })
107
- }
108
- return [...updateEntries, ...tuiEntries, ...webEntries]
109
- }, [models, theme, pingMode, onCycleTheme, onResetView, onSetPingMode, onOpenChangelog, onOpenHelp, onExport, updateAvailable, latestVersion, onRunUpdate])
110
-
111
- // πŸ“– Filter using the TUI's exact fuzzy rank so the two palettes are 1:1.
112
- // πŸ“– Empty query β†’ return everything; non-empty β†’ return ranked matches.
113
- const filtered = useMemo(() => {
114
- if (!query.trim()) return allCommands
115
- return filterCommandPaletteEntries(allCommands, query)
116
- }, [allCommands, query])
117
-
118
- // πŸ“– Group filtered commands by section so the list reads top-down in
119
- // πŸ“– the same order as the TUI's hierarchical view.
120
- const grouped = useMemo(() => {
121
- const groups = new Map()
122
- for (const cmd of filtered) {
123
- if (!groups.has(cmd.section)) groups.set(cmd.section, [])
124
- groups.get(cmd.section).push(cmd)
125
- }
126
- return Array.from(groups.entries())
127
- }, [filtered])
128
-
129
- // πŸ“– Build a flat list for keyboard navigation. Cursor is an index into
130
- // πŸ“– this flat list, not the grouped structure.
131
- const flatList = useMemo(() => grouped.flatMap(([, items]) => items), [grouped])
132
-
133
- useEffect(() => {
134
- inputRef.current?.focus()
135
- }, [])
136
-
137
- // πŸ“– Clamp cursor whenever the result set changes.
138
- useEffect(() => {
139
- if (cursor >= flatList.length) setCursor(0)
140
- }, [flatList, cursor])
141
-
142
- // πŸ“– Keyboard handler: Esc, arrows, Enter, Tab (expand/collapse on TUI;
143
- // πŸ“– the Web list is flat so Tab cycles focus to the next button β€” and
144
- // πŸ“– Enter runs the highlighted command).
145
- useEffect(() => {
146
- const onKey = (e) => {
147
- if (e.key === 'Escape') { e.preventDefault(); onClose(); return }
148
- if (e.key === 'ArrowDown') {
149
- e.preventDefault()
150
- setCursor((c) => Math.min(flatList.length - 1, c + 1))
151
- return
152
- }
153
- if (e.key === 'ArrowUp') {
154
- e.preventDefault()
155
- setCursor((c) => Math.max(0, c - 1))
156
- return
157
- }
158
- if (e.key === 'Enter') {
159
- e.preventDefault()
160
- const item = flatList[cursor]
161
- if (!item) return
162
- if (item.disabled) { onToast?.(`${item.label} is not available yet.`, 'info'); return }
163
- runCommand(item)
164
- }
165
- }
166
- document.addEventListener('keydown', onKey)
167
- return () => document.removeEventListener('keydown', onKey)
168
- // eslint-disable-next-line react-hooks/exhaustive-deps
169
- }, [flatList, cursor, onClose, onToast])
170
-
171
- // πŸ“– When a command runs, the palette closes. The handler lives in a
172
- // πŸ“– useCallback so the keyboard handler doesn't recreate on every render.
173
- const runCommand = useCallback((item) => {
174
- // πŸ“– TUI registry entries carry the action in their own `id` form. We
175
- // πŸ“– translate the most common ones to the Web's React callbacks.
176
- // πŸ“– For entries that don't have a Web equivalent (e.g. tool cycle, which
177
- // πŸ“– ships in M3), we route the user to the right header menu.
178
- const id = item.id
179
- if (id === 'open-settings') { onNavigate?.('settings'); onClose(); return }
180
- if (id === 'open-help') { onNavigate?.('help'); onClose(); return }
181
- if (id === 'open-changelog') { onNavigate?.('changelog'); onClose(); return }
182
- if (id === 'open-recommend') { onNavigate?.('recommend'); onClose(); return }
183
- if (id === 'open-router-dashboard') { onNavigate?.('router'); onClose(); return }
184
- if (id === 'open-installed-models') { onNavigate?.('installed-models'); onClose(); return }
185
- if (id === 'open-install-endpoints') { onNavigate?.('install-endpoints'); onClose(); return }
186
- if (id === 'action-update-now') { onRunUpdate?.(); onClose(); return }
187
- if (id === 'action-cycle-theme') { onCycleTheme?.(); onClose(); return }
188
- if (id === 'action-reset-view') { onResetView?.(); onClose(); return }
189
- if (id === 'action-cycle-ping-mode') {
190
- const idx = PING_MODE_CYCLE.indexOf(pingMode)
191
- onSetPingMode?.(PING_MODE_CYCLE[(idx + 1) % PING_MODE_CYCLE.length])
192
- onClose()
193
- return
194
- }
195
- if (id === 'action-cycle-tool-mode') { onToast?.('Tool mode picker arrives in M3', 'info'); onClose(); return }
196
- if (id === 'action-toggle-favorite') { onToast?.('Press F on a model row in the table.', 'info'); onClose(); return }
197
- if (id === 'action-toggle-favorite-mode') { onToast?.('Set the favorites display mode in Settings (M2).', 'info'); onClose(); return }
198
- if (id === 'action-export') { onExport?.(); onClose(); return }
199
- if (id === 'action-benchmark-row') { onToast?.('Click any AI Lat. cell to benchmark the highlighted row.', 'info'); onClose(); return }
200
-
201
- // πŸ“– Filter / sort / ping mode commands are dispatched to the same Web
202
- // πŸ“– callbacks the TUI palette uses (cycle / set). For per-model filter
203
- // πŸ“– entries the user typed, we just apply a text filter.
204
- if (id === 'filter-provider-cycle') { onToast?.('Use the Provider dropdown in the FilterBar.', 'info'); onClose(); return }
205
- if (id.startsWith('filter-tier-')) { onToast?.('Tier filter β€” use the Tier chip row in the FilterBar.', 'info'); onClose(); return }
206
- if (id.startsWith('filter-provider-')) { onToast?.('Provider filter β€” use the Provider dropdown.', 'info'); onClose(); return }
207
- if (id === 'filter-configured-toggle') { onToast?.('Use the Visibility dropdown in the FilterBar.', 'info'); onClose(); return }
208
- if (id.startsWith('sort-')) { onToast?.('Sort β€” click the column header in the table.', 'info'); onClose(); return }
209
- if (id === 'action-set-ping-speed') { onSetPingMode?.('speed'); onClose(); return }
210
- if (id === 'action-set-ping-normal') { onSetPingMode?.('normal'); onClose(); return }
211
- if (id === 'action-set-ping-slow') { onSetPingMode?.('slow'); onClose(); return }
212
- if (id === 'action-set-ping-forced') { onSetPingMode?.('forced'); onClose(); return }
213
-
214
- // πŸ“– Fallback for any TUI command the Web hasn't wired yet.
215
- if (typeof item.run === 'function') {
216
- item.run()
217
- onClose()
218
- return
219
- }
220
- onToast?.(`${item.label} is not wired on the Web yet.`, 'info')
221
- onClose()
222
- }, [onNavigate, onClose, onToast, onCycleTheme, onResetView, onSetPingMode, onOpenHelp, onOpenChangelog, onRunUpdate, onExport, pingMode])
223
-
224
- // πŸ“– Keep the focused item in view as the cursor moves. Mirrors the TUI
225
- // πŸ“– palette's "follow the selection" behavior.
226
- useEffect(() => {
227
- if (!listRef.current) return
228
- const el = listRef.current.querySelector(`[data-cmd-index="${cursor}"]`)
229
- if (el) el.scrollIntoView({ block: 'nearest' })
230
- }, [cursor])
231
-
232
- return (
233
- <div className={styles.backdrop} onClick={onClose}>
234
- <div className={styles.modal} onClick={(e) => e.stopPropagation()}>
235
- <div className={styles.searchRow}>
236
- <IconSearch size={16} stroke={1.5} className={styles.searchIcon} />
237
- <input
238
- ref={inputRef}
239
- className={styles.input}
240
- placeholder="Type a command… (filters, sorts, tools, theme, ping, export)"
241
- value={query}
242
- onChange={(e) => setQuery(e.target.value)}
243
- autoComplete="off"
244
- />
245
- <span className={styles.kbd}><IconCommand size={10} stroke={1.5} />K</span>
246
- </div>
247
-
248
- <div className={styles.list} ref={listRef} role="listbox">
249
- {flatList.length === 0 && (
250
- <div className={styles.empty}>No matching command.</div>
251
- )}
252
- {grouped.map(([section, items]) => {
253
- const meta = SECTION_META[section] || { icon: 'β€’', label: section }
254
- return (
255
- <div key={section} className={styles.section}>
256
- <div className={styles.sectionHeader}>
257
- <span className={styles.sectionIcon}>{meta.icon}</span>
258
- <span>{meta.label}</span>
259
- </div>
260
- {items.map((cmd) => {
261
- // πŸ“– Flat index across all groups so the keyboard cursor is stable.
262
- const flatIdx = flatList.indexOf(cmd)
263
- const isActive = flatIdx === cursor
264
- return (
265
- <button
266
- key={cmd.id}
267
- data-cmd-index={flatIdx}
268
- className={`${styles.item} ${isActive ? styles.itemActive : ''} ${cmd.disabled ? styles.itemDisabled : ''}`}
269
- onClick={() => runCommand(cmd)}
270
- onMouseEnter={() => setCursor(flatIdx)}
271
- role="option"
272
- aria-selected={isActive}
273
- aria-disabled={cmd.disabled ? 'true' : undefined}
274
- >
275
- <span className={styles.itemLabel}>
276
- {cmd.label}
277
- {cmd.shortcut && <span className={styles.shortcut}>{cmd.shortcut}</span>}
278
- </span>
279
- {cmd.description && <span className={styles.itemDesc}>{cmd.description}</span>}
280
- {isActive && <span className={styles.itemEnter}>↡</span>}
281
- </button>
282
- )
283
- })}
284
- </div>
285
- )
286
- })}
287
- </div>
288
-
289
- <div className={styles.footer}>
290
- <span><kbd>↑↓</kbd> navigate</span>
291
- <span><kbd>↡</kbd> select</span>
292
- <span><kbd>Esc</kbd> close</span>
293
- <span className={styles.footerRight}>
294
- Powered by the TUI command registry β€” 1:1 parity
295
- <IconExternalLink size={10} stroke={1.5} style={{ marginLeft: 4 }} />
296
- </span>
297
- </div>
298
- </div>
299
- </div>
300
- )
301
- }