@modelstatus/cli 0.1.85 → 0.1.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.
- package/package.json +7 -2
- package/src/api.js +41 -11
- package/src/changelog-data.js +23 -0
- package/src/ci.js +8 -4
- package/src/detect/core.js +17 -6
- package/src/fix.js +132 -46
- package/src/index.js +172 -38
- package/src/integrations.js +18 -3
- package/src/registry/fetch.js +16 -1
- package/src/sources/aws-lambda.js +9 -2
- package/src/sources/aws.js +7 -1
- package/src/sources/filesystem.js +0 -0
- package/src/sources/github-actions.js +9 -2
- package/src/sources/helm.js +9 -3
- package/src/sources/index.js +45 -3
- package/src/sources/k8s.js +6 -2
- package/src/sources/shell.js +126 -14
- package/src/sources/sql.js +6 -2
- package/src/sources/supabase-edge.js +19 -5
- package/src/sources/vercel.js +64 -6
- package/src/telemetry.js +21 -0
- package/src/tui/app.js +55 -13
- package/src/tui/game/launch.js +14 -2
- package/src/tui/signin.js +43 -7
- package/src/tui/views/account.js +19 -2
- package/src/tui/views/alerts.js +24 -9
- package/src/tui/views/whatsnew.js +82 -28
- package/src/updater.js +160 -39
- package/src/upgrade.js +37 -8
package/src/tui/signin.js
CHANGED
|
@@ -12,6 +12,19 @@ import { h } from "./ui.js";
|
|
|
12
12
|
|
|
13
13
|
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
14
14
|
|
|
15
|
+
// One in-flight device-auth session per apiBase, held OUTSIDE the component.
|
|
16
|
+
// SignIn is remounted on every signed-out Account-tab visit (and after a game
|
|
17
|
+
// round-trip); without this, each mount minted a NEW device code and popped
|
|
18
|
+
// ANOTHER browser tab — and an approval made in the browser after the user
|
|
19
|
+
// switched tabs landed on a code nobody was polling. A remount now resumes the
|
|
20
|
+
// pending session (same code, same URL, no extra tab) and picks the approval up.
|
|
21
|
+
// Sessions clear on approval/denial/expiry/start-failure (and g re-mints one).
|
|
22
|
+
// apiBase -> { device_code, user_code, verification_url, interval, deadline, opened }
|
|
23
|
+
const authSessions = new Map();
|
|
24
|
+
|
|
25
|
+
/** Test hook: forget all pending device-auth sessions. */
|
|
26
|
+
export function _resetAuthSessions() { authSessions.clear(); }
|
|
27
|
+
|
|
15
28
|
export function SignIn({ apiBase, onSuccess, onSkip }) {
|
|
16
29
|
const { exit } = useApp();
|
|
17
30
|
const [phase, setPhase] = React.useState("starting"); // starting | polling | error
|
|
@@ -35,19 +48,38 @@ export function SignIn({ apiBase, onSuccess, onSkip }) {
|
|
|
35
48
|
|
|
36
49
|
const start = async () => {
|
|
37
50
|
try {
|
|
38
|
-
|
|
51
|
+
// Resume the session a previous mount left in flight (tab switch, game
|
|
52
|
+
// round-trip) — same code, same browser tab, and an approval made while
|
|
53
|
+
// we weren't looking gets picked up on the first poll. Mint a fresh one
|
|
54
|
+
// only when none is pending or the old one passed its deadline.
|
|
55
|
+
let s = authSessions.get(apiBase);
|
|
56
|
+
if (!s || Date.now() > s.deadline) {
|
|
57
|
+
const fresh = await client.authStart({ client_name: "mm CLI" });
|
|
58
|
+
s = {
|
|
59
|
+
device_code: fresh.device_code,
|
|
60
|
+
user_code: fresh.user_code,
|
|
61
|
+
verification_url: fresh.verification_url,
|
|
62
|
+
interval: Math.max(1, fresh.interval || 3) * 1000,
|
|
63
|
+
deadline: Date.now() + (fresh.expires_in || 600) * 1000,
|
|
64
|
+
opened: false,
|
|
65
|
+
};
|
|
66
|
+
// Cache even if this mount was cancelled mid-start — the code is
|
|
67
|
+
// already minted, so let the next visit resume it instead of orphaning it.
|
|
68
|
+
authSessions.set(apiBase, s);
|
|
69
|
+
}
|
|
39
70
|
if (cancelled) return;
|
|
40
71
|
setCode(s.user_code);
|
|
41
72
|
setUrl(s.verification_url);
|
|
42
73
|
setPhase("polling");
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
74
|
+
if (!s.opened) {
|
|
75
|
+
s.opened = true;
|
|
76
|
+
openUrl(s.verification_url); // best-effort, ONCE per session (o re-opens)
|
|
77
|
+
}
|
|
47
78
|
|
|
48
79
|
const poll = async () => {
|
|
49
80
|
if (cancelled) return;
|
|
50
|
-
if (Date.now() > deadline) {
|
|
81
|
+
if (Date.now() > s.deadline) {
|
|
82
|
+
authSessions.delete(apiBase);
|
|
51
83
|
setError("The login request expired.");
|
|
52
84
|
setPhase("error");
|
|
53
85
|
return;
|
|
@@ -60,6 +92,7 @@ export function SignIn({ apiBase, onSuccess, onSkip }) {
|
|
|
60
92
|
}
|
|
61
93
|
if (cancelled) return;
|
|
62
94
|
if (res?.status === "approved") {
|
|
95
|
+
authSessions.delete(apiBase);
|
|
63
96
|
const cfg = loadConfig();
|
|
64
97
|
cfg.apiKey = res.api_key;
|
|
65
98
|
cfg.apiBase = apiBase;
|
|
@@ -68,20 +101,23 @@ export function SignIn({ apiBase, onSuccess, onSkip }) {
|
|
|
68
101
|
return;
|
|
69
102
|
}
|
|
70
103
|
if (res?.status === "denied") {
|
|
104
|
+
authSessions.delete(apiBase);
|
|
71
105
|
setError("Authorization was denied.");
|
|
72
106
|
setPhase("error");
|
|
73
107
|
return;
|
|
74
108
|
}
|
|
75
109
|
if (res?.status === "expired") {
|
|
110
|
+
authSessions.delete(apiBase);
|
|
76
111
|
setError("The login request expired. Press q and run mm login again.");
|
|
77
112
|
setPhase("error");
|
|
78
113
|
return;
|
|
79
114
|
}
|
|
80
|
-
pollTimer = setTimeout(poll, interval);
|
|
115
|
+
pollTimer = setTimeout(poll, s.interval);
|
|
81
116
|
};
|
|
82
117
|
poll();
|
|
83
118
|
} catch (e) {
|
|
84
119
|
if (cancelled) return;
|
|
120
|
+
authSessions.delete(apiBase); // a failed start isn't resumable
|
|
85
121
|
setError(e?.message || String(e));
|
|
86
122
|
setPhase("error");
|
|
87
123
|
}
|
package/src/tui/views/account.js
CHANGED
|
@@ -28,6 +28,18 @@ export function AccountView({ client, me, refreshMe, apiBase, ui, active }) {
|
|
|
28
28
|
|
|
29
29
|
React.useEffect(() => ui?.reportStatus?.({ context: `plan: ${me?.plan ?? "…"}` }), [me, ui]);
|
|
30
30
|
|
|
31
|
+
// The upgrade poll below is a self-rescheduling setTimeout chain — without
|
|
32
|
+
// cleanup it outlives the view (keeps hitting /me every 4s for up to 10min,
|
|
33
|
+
// sets state on an unmounted component, and holds the event loop open AFTER
|
|
34
|
+
// quit, hanging the user's shell). Cancel it the moment the view unmounts.
|
|
35
|
+
const pollTimerRef = React.useRef(null);
|
|
36
|
+
const mountedRef = React.useRef(true);
|
|
37
|
+
React.useEffect(() => () => {
|
|
38
|
+
mountedRef.current = false;
|
|
39
|
+
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
|
|
40
|
+
pollTimerRef.current = null;
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
31
43
|
async function upgrade() {
|
|
32
44
|
// Without a loaded account a checkout would just fire at an unreachable
|
|
33
45
|
// endpoint and die with a raw fetch error — say what to do instead.
|
|
@@ -40,9 +52,11 @@ export function AccountView({ client, me, refreshMe, apiBase, ui, active }) {
|
|
|
40
52
|
setStatus("Complete checkout in your browser… (polling /me)");
|
|
41
53
|
const deadline = Date.now() + 10 * 60 * 1000;
|
|
42
54
|
const tick = async () => {
|
|
55
|
+
if (!mountedRef.current) return;
|
|
43
56
|
if (Date.now() > deadline) return setStatus("Timed out — press u to retry.");
|
|
44
57
|
try {
|
|
45
58
|
const m = await client.me();
|
|
59
|
+
if (!mountedRef.current) return;
|
|
46
60
|
if (m?.account?.plan && m.account.plan !== "free") {
|
|
47
61
|
setStatus(null);
|
|
48
62
|
ui.showToast(`upgraded to ${m.account.plan}!`);
|
|
@@ -52,9 +66,12 @@ export function AccountView({ client, me, refreshMe, apiBase, ui, active }) {
|
|
|
52
66
|
} catch {
|
|
53
67
|
/* keep polling */
|
|
54
68
|
}
|
|
55
|
-
|
|
69
|
+
if (!mountedRef.current) return;
|
|
70
|
+
pollTimerRef.current = setTimeout(tick, 4000);
|
|
56
71
|
};
|
|
57
|
-
|
|
72
|
+
// A second u press replaces (not stacks) any poll chain already running.
|
|
73
|
+
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
|
|
74
|
+
pollTimerRef.current = setTimeout(tick, 4000);
|
|
58
75
|
} catch (e) {
|
|
59
76
|
setStatus(e.status === 503 ? "Billing isn't configured on this server." : `checkout failed: ${e.message} — press u to retry.`);
|
|
60
77
|
}
|
package/src/tui/views/alerts.js
CHANGED
|
@@ -125,11 +125,20 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
|
|
|
125
125
|
.patchRule(cur.id, { delivery: DELIVERY[(DELIVERY.indexOf(cur.delivery) + 1) % 3] })
|
|
126
126
|
.then(() => rules.reload())
|
|
127
127
|
.catch((e) => ui.showToast(e.message, "red"));
|
|
128
|
-
if (input === "d")
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
128
|
+
if (input === "d") {
|
|
129
|
+
// One stray keypress shouldn't silently delete a rule — confirm with a
|
|
130
|
+
// single y, same pattern as InventoryView's d (inventory.js).
|
|
131
|
+
return ui.askPrompt(`Delete ${ruleLabel(cur)}? type y`, {
|
|
132
|
+
onSubmit: (v) => {
|
|
133
|
+
if (String(v || "").trim().toLowerCase() !== "y") return ui.showToast("delete cancelled");
|
|
134
|
+
client.deleteRule(cur.id).then(() => {
|
|
135
|
+
ui.showToast("rule deleted");
|
|
136
|
+
setCursor((c) => clampCursor(c, ruleList.length - 1));
|
|
137
|
+
rules.reload();
|
|
138
|
+
}).catch((e) => ui.showToast(e.message, "red"));
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
133
142
|
} else {
|
|
134
143
|
if (input === "n") return addChannel();
|
|
135
144
|
if (input === "t" && cur) return testChannel(cur);
|
|
@@ -150,14 +159,18 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
|
|
|
150
159
|
if (!ruleList.length) {
|
|
151
160
|
body = h(EmptyCard, { title: "No alert rules yet", lines: ["Stay ahead of your model timeline — a heads-up 90, 30, 7, and 1 day before anything you use is deprecated or retired.", "Press n to set the sensible default (your models · in-app + email · those lead times)."], width });
|
|
152
161
|
} else {
|
|
162
|
+
// Window the list around the cursor (same as whatsnew.js) — a fixed
|
|
163
|
+
// slice(0, ROWS) lets the selection walk below the visible page and
|
|
164
|
+
// space/c/d act on a row the user can't see.
|
|
153
165
|
const curIdx = clampCursor(cursor, ruleList.length);
|
|
166
|
+
const start = Math.max(0, Math.min(curIdx - ROWS + 1, ruleList.length - ROWS));
|
|
154
167
|
const fixed = 2 + 26 + 1 + 10 + 1; // glyph + name + gap + delivery + gap
|
|
155
168
|
const rest = Math.max(8, width - 1 - fixed);
|
|
156
169
|
body = h(
|
|
157
170
|
Box,
|
|
158
171
|
{ flexDirection: "column" },
|
|
159
|
-
...ruleList.slice(
|
|
160
|
-
const isCur = i === curIdx;
|
|
172
|
+
...ruleList.slice(start, start + ROWS).map((r, i) => {
|
|
173
|
+
const isCur = start + i === curIdx;
|
|
161
174
|
const enabled = !!r.enabled;
|
|
162
175
|
const leads = r.leadTimes || r.lead_times || [];
|
|
163
176
|
const chanText = [(r.channels || []).join(","), leads.length ? leads.join("/") + "d" : ""].filter(Boolean).join(" · ");
|
|
@@ -182,13 +195,15 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
|
|
|
182
195
|
h(Text, { color: "#d97706" }, " Slack/Discord/SMS/webhook channels need Pro — press 7 → u to upgrade."),
|
|
183
196
|
);
|
|
184
197
|
} else {
|
|
198
|
+
// Windowed around the cursor like the rules list above.
|
|
185
199
|
const curIdx = clampCursor(cursor, chanList.length);
|
|
200
|
+
const start = Math.max(0, Math.min(curIdx - ROWS + 1, chanList.length - ROWS));
|
|
186
201
|
const rest = Math.max(8, width - 1 - 10); // after the 10-wide kind pill
|
|
187
202
|
body = h(
|
|
188
203
|
Box,
|
|
189
204
|
{ flexDirection: "column" },
|
|
190
|
-
...chanList.slice(
|
|
191
|
-
const isCur = i === curIdx;
|
|
205
|
+
...chanList.slice(start, start + ROWS).map((c, i) => {
|
|
206
|
+
const isCur = start + i === curIdx;
|
|
192
207
|
const cells = [
|
|
193
208
|
{ text: cell(c.kind, 10), color: KIND_COLOR[c.kind] || C.FG_DIM, bold: true },
|
|
194
209
|
{ text: cellE(c.label || c.value, rest), color: C.FG },
|
|
@@ -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
|
-
|
|
96
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 (!
|
|
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,
|
|
216
|
-
const start = Math.max(0, Math.min(cur - FROWS + 1,
|
|
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
|
-
...
|
|
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 =
|
|
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,
|
|
256
|
-
const start = Math.max(0, Math.min(cur - RROWS + 1,
|
|
257
|
-
const sel =
|
|
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
|
-
...
|
|
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
|
|
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" }, `${
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
}
|