@modelstatus/cli 0.1.85 → 0.1.86

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelstatus/cli",
3
- "version": "0.1.85",
3
+ "version": "0.1.86",
4
4
  "description": "Track which AI models you use, where, and never get surprised by a retirement. Free offline model-health for any repo (mm status), browser sign-in for cloud inventory + alerts.",
5
5
  "keywords": [
6
6
  "llm",
@@ -1,6 +1,15 @@
1
1
  /* GENERATED by scripts/gen-changelog.mjs from apps/web/lib/changelog.json — do not edit.
2
2
  * Release notes baked into the binary (in-TUI + the on-load what's-new card). */
3
3
  export const CHANGELOG = [
4
+ {
5
+ "version": "0.1.86",
6
+ "date": "2026-06-16",
7
+ "title": "Search the What's New feed",
8
+ "items": [
9
+ "The What's New tab now has `/` search, like Scan and Inventory. Filter the registry feed, alerts, fixes, and releases by typing — e.g. `deprecated` to see just the deprecations, or a model or provider name to jump to a specific change.",
10
+ "Press `/` to start filtering, `esc` to clear; the count of matches shows as you type."
11
+ ]
12
+ },
4
13
  {
5
14
  "version": "0.1.85",
6
15
  "date": "2026-06-14",
@@ -7,6 +7,7 @@ import React from "react";
7
7
  import { Box, Text, useInput } from "ink";
8
8
  import {
9
9
  h, C, GLYPH, SubTabs, ListRow, StateLine, cellE, clampCursor, SPINNER, useTick, useAsync,
10
+ useSearch, SearchBar,
10
11
  } from "../ui.js";
11
12
  import { collectFrom } from "../../sources/index.js";
12
13
  import { loadConfig, setConfigValue } from "../../config.js";
@@ -21,11 +22,11 @@ const TABS = ["Registry", "Alerts", "Drift", "Fixes", "Releases"];
21
22
  // Notifications: mark read · Drift: rescan + archive). Published via ui.setKeys()
22
23
  // so the keybar never advertises a key that's dead on the current section.
23
24
  const TAB_KEYS = [
24
- [{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "m", label: "mark all seen" }, { k: "g", label: "refresh" }],
25
- [{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "o", label: "mark read" }, { k: "g", label: "refresh" }],
26
- [{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "r", label: "rescan" }, { k: "a", label: "archive" }],
27
- [{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "o", label: "open in editor" }, { k: "g", label: "refresh" }],
28
- [{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }],
25
+ [{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "/", label: "search" }, { k: "m", label: "mark all seen" }, { k: "g", label: "refresh" }],
26
+ [{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "/", label: "search" }, { k: "o", label: "mark read" }, { k: "g", label: "refresh" }],
27
+ [{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "/", label: "search" }, { k: "r", label: "rescan" }, { k: "a", label: "archive" }],
28
+ [{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "/", label: "search" }, { k: "o", label: "open in editor" }, { k: "g", label: "refresh" }],
29
+ [{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "/", label: "search" }],
29
30
  ];
30
31
 
31
32
  // The static fallback must match the default (Registry) section exactly —
@@ -35,8 +36,12 @@ export const meta = { keys: TAB_KEYS[0] };
35
36
  export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14 }) {
36
37
  const [tab, setTab] = React.useState(0);
37
38
  const [cursor, setCursor] = React.useState(0);
39
+ const search = useSearch();
38
40
  const lastSeen = loadConfig().lastEventsSeenAt || null;
39
41
  React.useEffect(() => { ui?.setKeys?.(TAB_KEYS[tab]); }, [tab, ui]);
42
+ // Re-window from the top whenever the filter changes so the cursor never sits
43
+ // below the (now shorter) visible list.
44
+ React.useEffect(() => { setCursor(0); }, [search.query]);
40
45
 
41
46
  const reg = useAsync(async () => {
42
47
  const [ev, m, p] = await Promise.all([
@@ -89,11 +94,46 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
89
94
  }
90
95
  }
91
96
 
97
+ // One `/` filter, applied per-section over the fields each row actually shows
98
+ // (so e.g. typing "deprecated" on Registry narrows to model_deprecated events,
99
+ // "claude" to a provider's models). Computed once here and reused by both the
100
+ // key handler — so m/o/a act on the row you can SEE — and the render below.
101
+ const q = search.query.toLowerCase().trim();
102
+ const regEvents = reg.data?.events || [];
103
+ const regName = (e) => (e.model_id ? reg.data?.models.get(e.model_id) : reg.data?.provs.get(e.provider_id)) || "";
104
+ const regFiltered = q ? regEvents.filter((e) => `${e.event_type} ${regName(e)} ${String(e.published_at || "").slice(0, 10)}`.toLowerCase().includes(q)) : regEvents;
105
+ const notifAll = notif.data || [];
106
+ const notifFiltered = q ? notifAll.filter((n) => `${n.title} ${String(n.when || "").slice(0, 10)}`.toLowerCase().includes(q)) : notifAll;
107
+ const fixesFiltered = q ? fixes.filter((f) => `${path.basename(f.dir)} ${f.file} ${f.from} ${f.to}`.toLowerCase().includes(q)) : fixes;
108
+ const relFiltered = q ? CHANGELOG.filter((e) => `v${e.version} ${e.date} ${e.title} ${(e.items || []).join(" ")}`.toLowerCase().includes(q)) : CHANGELOG;
109
+ const driftAdded = q && drift?.added ? drift.added.filter((c) => `${c.display || c.model_string} ${c.location_label || ""}`.toLowerCase().includes(q)) : drift?.added || [];
110
+ const driftGone = q && drift?.gone ? drift.gone.filter((u) => `${u.model_display || u.custom_model_name || ""} ${u.source_path || ""}`.toLowerCase().includes(q)) : drift?.gone || [];
111
+
92
112
  useInput(
93
113
  (input, key) => {
94
114
  if (!active) return;
95
- if (key.leftArrow) return (setTab((t) => (t + TABS.length - 1) % TABS.length), setCursor(0));
96
- if (key.rightArrow) return (setTab((t) => (t + 1) % TABS.length), setCursor(0));
115
+ // While typing a filter, keys feed the query not the section commands.
116
+ // No trailing return: ↑↓ still scroll the filtered list as you refine it
117
+ // (matches the Scan/Inventory search feel). Mirrors the useSearch ref guard.
118
+ if (search.isSearchingNow()) {
119
+ if (key.escape) { search.clear(); ui?.setCapturing?.(false); return; }
120
+ if (key.return) { search.confirm(); ui?.setCapturing?.(false); return; }
121
+ if (key.backspace || key.delete) return search.backspace();
122
+ if (input && !key.ctrl && !key.meta && !key.leftArrow && !key.rightArrow) return search.type(input);
123
+ }
124
+ // "/" opens the filter (any trailing chars typed in the same burst seed it).
125
+ if (typeof input === "string" && input.startsWith("/")) {
126
+ search.open();
127
+ ui?.setCapturing?.(true);
128
+ const rest = input.slice(1);
129
+ if (rest) search.type(rest);
130
+ return;
131
+ }
132
+ if (key.leftArrow) return (setTab((t) => (t + TABS.length - 1) % TABS.length), setCursor(0), search.clear());
133
+ if (key.rightArrow) return (setTab((t) => (t + 1) % TABS.length), setCursor(0), search.clear());
134
+ // esc / backspace drops a confirmed filter (when not mid-typing — that's
135
+ // handled above) before falling through to the section commands.
136
+ if ((key.escape || key.backspace || key.delete) && search.query) return search.clear();
97
137
  if (key.downArrow || input === "j") return setCursor((c) => c + 1);
98
138
  if (key.upArrow || input === "k") return setCursor((c) => Math.max(0, c - 1));
99
139
  // g refreshes whichever section is showing — the universal retry across tabs
@@ -111,7 +151,7 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
111
151
  ui.showToast("marked all as seen");
112
152
  }
113
153
  } else if (tab === 1) {
114
- const list = notif.data || [];
154
+ const list = notifFiltered;
115
155
  const cur = list[clampCursor(cursor, list.length)];
116
156
  if (input === "o" && cur)
117
157
  client.readNotification(cur.id).then(() => {
@@ -119,14 +159,14 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
119
159
  notif.reload();
120
160
  }).catch((e) => ui.showToast(e.message, "red"));
121
161
  } else if (tab === 3) {
122
- const cur = fixes[clampCursor(cursor, fixes.length)];
162
+ const cur = fixesFiltered[clampCursor(cursor, fixesFiltered.length)];
123
163
  if (input === "o" && cur) {
124
164
  openLocation(path.resolve(cur.dir, cur.file), cur.line);
125
165
  ui.showToast(`opened ${cur.file}:${cur.line}`);
126
166
  }
127
167
  } else if (tab === 2) {
128
168
  if (input === "r") return runDrift();
129
- const gone = drift?.gone || [];
169
+ const gone = driftGone;
130
170
  // Same visible-rows clamp as the render — archive only what's on screen.
131
171
  const cur = gone[clampCursor(cursor, Math.min(gone.length, 7))];
132
172
  if (input === "a" && cur)
@@ -157,8 +197,8 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
157
197
  if (reg.loading) body = h(StateLine, { kind: "loading", spin, text: "loading registry changes…" });
158
198
  else if (reg.error) body = h(StateLine, { kind: "error", text: `couldn't load registry changes — ${reg.error}`, hint: "g retries" });
159
199
  else {
160
- const events = reg.data.events;
161
- if (!events.length) body = h(Text, { color: C.FG_DIM }, " No registry changes recorded yet.");
200
+ const events = regFiltered;
201
+ if (!events.length) body = h(Text, { color: C.FG_DIM }, q ? ` No changes match "${search.query}".` : " No registry changes recorded yet.");
162
202
  else {
163
203
  const start = clampCursor(cursor, events.length);
164
204
  body = h(
@@ -181,10 +221,10 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
181
221
  }
182
222
  }
183
223
  } else if (tab === 1) {
184
- const list = notif.data || [];
224
+ const list = notifFiltered;
185
225
  if (notif.loading) body = h(StateLine, { kind: "loading", spin, text: "loading alerts…" });
186
226
  else if (notif.error) body = h(StateLine, { kind: "error", text: `couldn't load alerts — ${notif.error}`, hint: "g retries" });
187
- else if (!list.length) body = h(Text, { color: C.FG_DIM }, " No alerts yet. Press 6 to set up alert rules.");
227
+ else if (!list.length) body = h(Text, { color: C.FG_DIM }, q ? ` No alerts match "${search.query}".` : " No alerts yet. Press 6 to set up alert rules.");
188
228
  else {
189
229
  // Window the list around the cursor so ↑↓ can reach every row — a fixed
190
230
  // slice(0, ROWS) lets the selection walk below the visible page and `o`
@@ -206,14 +246,15 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
206
246
  );
207
247
  }
208
248
  } else if (tab === 3) {
209
- if (!fixes.length) {
210
- body = h(Text, { color: C.FG_DIM }, " No fixes applied yet. Press f on the Here tab (or run mm fix) to rewrite dying model ids.");
249
+ if (!fixesFiltered.length) {
250
+ body = h(Text, { color: C.FG_DIM }, q ? ` No fixes match "${search.query}".` : " No fixes applied yet. Press f on the Here tab (or run mm fix) to rewrite dying model ids.");
211
251
  } else {
212
252
  // Windowed like the Alerts section so ↑↓ reaches every row — minus 4 rows
213
253
  // reserved for the selected fix's diff underneath.
254
+ const fixesV = fixesFiltered;
214
255
  const FROWS = Math.max(3, ROWS - 4);
215
- const cur = clampCursor(cursor, fixes.length);
216
- const start = Math.max(0, Math.min(cur - FROWS + 1, fixes.length - FROWS));
256
+ const cur = clampCursor(cursor, fixesV.length);
257
+ const start = Math.max(0, Math.min(cur - FROWS + 1, fixesV.length - FROWS));
217
258
  const ago = (ts) => {
218
259
  const m = Math.max(0, Math.round((Date.now() - ts) / 60000));
219
260
  if (m < 1) return "now";
@@ -225,7 +266,7 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
225
266
  body = h(
226
267
  Box,
227
268
  { flexDirection: "column" },
228
- ...fixes.slice(start, start + FROWS).map((f, i) => {
269
+ ...fixesV.slice(start, start + FROWS).map((f, i) => {
229
270
  const cells = [
230
271
  { text: `${GLYPH.check} `, color: "#16a34a" },
231
272
  { text: cellE(`${path.basename(f.dir)} · ${f.file}:${f.line}`, 38), color: C.FG },
@@ -238,7 +279,7 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
238
279
  // The selected fix's diff — same red/green visual as the f preview + the fix PR.
239
280
  h(Text, { key: "drule" }, ""),
240
281
  ...(() => {
241
- const sel = fixes[cur];
282
+ const sel = fixesV[cur];
242
283
  if (!sel) return [];
243
284
  if (!sel.before || !sel.after) return [h(Text, { key: "dnone", color: C.FG_DIM }, " (diff not recorded for this entry — older fix)")];
244
285
  const w = Math.max(20, width - 4);
@@ -251,14 +292,17 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
251
292
  );
252
293
  }
253
294
  } else if (tab === 4) {
295
+ const relV = relFiltered;
296
+ if (!relV.length) { body = h(Text, { color: C.FG_DIM }, ` No releases match "${search.query}".`); }
297
+ else {
254
298
  const RROWS = Math.max(3, ROWS - 5); // reserve rows for the selected entry's bullets
255
- const cur = clampCursor(cursor, CHANGELOG.length);
256
- const start = Math.max(0, Math.min(cur - RROWS + 1, CHANGELOG.length - RROWS));
257
- const sel = CHANGELOG[cur];
299
+ const cur = clampCursor(cursor, relV.length);
300
+ const start = Math.max(0, Math.min(cur - RROWS + 1, relV.length - RROWS));
301
+ const sel = relV[cur];
258
302
  body = h(
259
303
  Box,
260
304
  { flexDirection: "column" },
261
- ...CHANGELOG.slice(start, start + RROWS).map((e, i) => {
305
+ ...relV.slice(start, start + RROWS).map((e, i) => {
262
306
  const cells = [
263
307
  { text: cellE(`v${e.version}`, 16), color: C.ACCENT },
264
308
  { text: cellE(e.date, 12), color: C.FG_FAINT },
@@ -272,12 +316,14 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
272
316
  h(Text, { key: `rb${i}`, color: C.FG_DIM }, cellE(` • ${it.replace(/\`/g, "")}`, Math.max(24, width - 2))))
273
317
  : []),
274
318
  );
319
+ }
275
320
  } else {
276
321
  if (!drift) body = h(Text, { color: C.FG_DIM }, ` Press r to scan ${dir} and compare against tracked usages.`);
277
322
  else if (drift.loading) body = h(StateLine, { kind: "loading", spin, text: "scanning for drift…" });
278
323
  else if (drift.error) body = h(StateLine, { kind: "error", text: `drift scan failed — ${drift.error}`, hint: "r rescans" });
279
324
  else {
280
- const gone = drift.gone || [];
325
+ const added = driftAdded;
326
+ const gone = driftGone;
281
327
  // Clamp the archive cursor to the VISIBLE rows — `a` must never act on a
282
328
  // row below the 7 shown.
283
329
  const curGone = clampCursor(cursor, Math.min(gone.length, 7));
@@ -287,12 +333,13 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
287
333
  h(
288
334
  Text,
289
335
  {},
290
- h(Text, { color: "#16a34a" }, `${drift.added.length} new in code`),
336
+ h(Text, { color: "#16a34a" }, `${added.length} new in code`),
291
337
  h(Text, { color: C.FG_DIM }, ` · ${drift.present} still present · `),
292
338
  h(Text, { color: "#dc2626" }, `${gone.length} gone from code`),
339
+ q ? h(Text, { color: C.FG_FAINT }, ` · filter "${search.query}"`) : null,
293
340
  ),
294
341
  h(Text, {}, ""),
295
- ...drift.added.slice(0, 5).map((c, i) => {
342
+ ...added.slice(0, 5).map((c, i) => {
296
343
  const cells = [
297
344
  { text: "+ ", color: "#16a34a" },
298
345
  { text: cellE(c.display || c.model_string, 24), color: "#16a34a" },
@@ -301,7 +348,7 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
301
348
  ];
302
349
  return h(ListRow, { key: "a" + i, active: false, cells, width });
303
350
  }),
304
- drift.added.length > 5 ? h(Text, { color: C.FG_DIM }, ` … ${drift.added.length - 5} more new`) : null,
351
+ added.length > 5 ? h(Text, { color: C.FG_DIM }, ` … ${added.length - 5} more new`) : null,
305
352
  ...gone.slice(0, 7).map((u, i) => {
306
353
  const cells = [
307
354
  { text: "- ", color: "#dc2626" },
@@ -316,11 +363,18 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
316
363
  }
317
364
  }
318
365
 
366
+ const visibleCount = tab === 0 ? regFiltered.length
367
+ : tab === 1 ? notifFiltered.length
368
+ : tab === 2 ? driftAdded.length + driftGone.length
369
+ : tab === 3 ? fixesFiltered.length
370
+ : relFiltered.length;
371
+
319
372
  return h(
320
373
  Box,
321
374
  { flexDirection: "column" },
322
375
  h(SubTabs, { idx: tab, tabs: TABS }),
323
376
  h(Text, {}, ""),
324
377
  body,
378
+ search.active ? h(SearchBar, { searching: search.searching, query: search.query, count: visibleCount }) : null,
325
379
  );
326
380
  }