@modelstatus/cli 0.1.62 → 0.1.64
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 +1 -1
- package/src/fix.js +87 -0
- package/src/index.js +15 -4
- package/src/tui/views/local.js +87 -34
- package/src/tui/views/whatsnew.js +46 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modelstatus/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.64",
|
|
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",
|
package/src/fix.js
CHANGED
|
@@ -16,8 +16,62 @@
|
|
|
16
16
|
* keeps the provider prefix; if it says "gpt-4" it stays bare.
|
|
17
17
|
*/
|
|
18
18
|
import fs from "node:fs";
|
|
19
|
+
import os from "node:os";
|
|
19
20
|
import path from "node:path";
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Follow a replacement chain to the first CURRENT model. Replacements can
|
|
24
|
+
* themselves be dying (davinci → gpt-3-5-turbo [deprecating] → gpt-4o-mini), and
|
|
25
|
+
* rewriting to a dying model just means fixing twice — land on the live one.
|
|
26
|
+
* `getModel(slug)` → model|null; `isCurrent(model)` → bool. Cycle-guarded;
|
|
27
|
+
* unknown slugs end the walk (best known answer wins).
|
|
28
|
+
*/
|
|
29
|
+
export function terminalReplacement(startSlug, getModel, isCurrent) {
|
|
30
|
+
let cur = startSlug;
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
while (cur && !seen.has(cur)) {
|
|
33
|
+
seen.add(cur);
|
|
34
|
+
const m = getModel(cur);
|
|
35
|
+
if (!m || isCurrent(m) || !m.replacement_slug) break;
|
|
36
|
+
cur = m.replacement_slug;
|
|
37
|
+
}
|
|
38
|
+
return cur || startSlug;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* ------------------------------------------------------------ fix history */
|
|
42
|
+
|
|
43
|
+
// MM_FIXES_FILE override keeps tests off the real user history.
|
|
44
|
+
const FIXES_FILE = process.env.MM_FIXES_FILE || path.join(os.homedir(), ".config", "llmstatus", "fixes.json");
|
|
45
|
+
const FIXES_CAP = 300;
|
|
46
|
+
|
|
47
|
+
/** Append applied fixes to the local history (newest first, capped). Read by
|
|
48
|
+
* the TUI's What's New → Fixes section. Best-effort — never throws. */
|
|
49
|
+
export function recordFixes(dir, applied, { source = "tui" } = {}) {
|
|
50
|
+
if (!applied?.length) return;
|
|
51
|
+
try {
|
|
52
|
+
let log = [];
|
|
53
|
+
try {
|
|
54
|
+
const j = JSON.parse(fs.readFileSync(FIXES_FILE, "utf8"));
|
|
55
|
+
if (Array.isArray(j)) log = j;
|
|
56
|
+
} catch { /* first write */ }
|
|
57
|
+
const ts = Date.now();
|
|
58
|
+
const entries = applied.map((p) => ({ ts, dir: path.resolve(dir), file: p.file, line: p.line, from: p.from, to: p.to, source }));
|
|
59
|
+
log = [...entries, ...log].slice(0, FIXES_CAP);
|
|
60
|
+
fs.mkdirSync(path.dirname(FIXES_FILE), { recursive: true, mode: 0o700 });
|
|
61
|
+
fs.writeFileSync(FIXES_FILE, JSON.stringify(log));
|
|
62
|
+
} catch { /* best effort */ }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The recorded fix history, newest first. Never throws. */
|
|
66
|
+
export function readFixes() {
|
|
67
|
+
try {
|
|
68
|
+
const j = JSON.parse(fs.readFileSync(FIXES_FILE, "utf8"));
|
|
69
|
+
return Array.isArray(j) ? j : [];
|
|
70
|
+
} catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
21
75
|
/** The string to write into the file: the replacement slug, styled to match how
|
|
22
76
|
* the old id was written (provider-prefixed vs bare). */
|
|
23
77
|
export function styleReplacement(oldStr, replacementSlug) {
|
|
@@ -70,6 +124,39 @@ export function planFixes(refs, replacementSlug) {
|
|
|
70
124
|
);
|
|
71
125
|
}
|
|
72
126
|
|
|
127
|
+
/**
|
|
128
|
+
* READ-ONLY preview of what applyFixes would do: each plan gains the full line
|
|
129
|
+
* text before/after (the TUI's diff visual). Nothing is written. Plans whose
|
|
130
|
+
* line no longer carries the string come back in `stale`.
|
|
131
|
+
*/
|
|
132
|
+
export function previewFixes(dir, plans) {
|
|
133
|
+
const previews = [];
|
|
134
|
+
const stale = [];
|
|
135
|
+
const cache = new Map(); // file -> lines[] | null
|
|
136
|
+
for (const p of plans) {
|
|
137
|
+
if (!cache.has(p.file)) {
|
|
138
|
+
try {
|
|
139
|
+
cache.set(p.file, fs.readFileSync(path.resolve(dir, p.file), "utf8").split(/\r?\n/));
|
|
140
|
+
} catch {
|
|
141
|
+
cache.set(p.file, null);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const lines = cache.get(p.file);
|
|
145
|
+
const before = lines?.[p.line - 1];
|
|
146
|
+
if (before == null) {
|
|
147
|
+
stale.push({ ...p, error: "file or line missing — rescan" });
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const { out, n } = replaceOnLine(before, p.from, p.to);
|
|
151
|
+
if (n === 0) {
|
|
152
|
+
stale.push({ ...p, error: "string not on that line anymore — rescan" });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
previews.push({ ...p, before, after: out });
|
|
156
|
+
}
|
|
157
|
+
return { previews, stale };
|
|
158
|
+
}
|
|
159
|
+
|
|
73
160
|
/**
|
|
74
161
|
* Apply plans to files under `dir`. Groups by file so multi-ref files are one
|
|
75
162
|
* read+write. Returns { applied, stale, failed } — applied entries carry the
|
package/src/index.js
CHANGED
|
@@ -541,7 +541,7 @@ async function cmdFix(positional, flags) {
|
|
|
541
541
|
const dir = path.resolve(positional[1] || flags.dir || ".");
|
|
542
542
|
const { getRegistry } = await import("./registry/fetch.js");
|
|
543
543
|
const { resolveLocal, computeHealth } = await import("./registry/local.js");
|
|
544
|
-
const { planFixes, applyFixes } = await import("./fix.js");
|
|
544
|
+
const { planFixes, applyFixes, terminalReplacement, recordFixes } = await import("./fix.js");
|
|
545
545
|
const snapshot = await getRegistry({ offline: !!flags.offline, log: (m) => process.stderr.write(`! ${m}\n`) });
|
|
546
546
|
|
|
547
547
|
const candidates = await collectFrom(["filesystem"], { root: dir }, snapshot.detection);
|
|
@@ -562,18 +562,28 @@ async function cmdFix(positional, flags) {
|
|
|
562
562
|
byModel.set(r.model_slug, e);
|
|
563
563
|
}
|
|
564
564
|
|
|
565
|
+
const bySlug = new Map(snapshot.models.map((m) => [m.slug, m]));
|
|
566
|
+
const isCurrent = (m) => computeHealth(m, 90, today) === "ok";
|
|
565
567
|
const all = [];
|
|
566
568
|
for (const { model, health, refs } of byModel.values()) {
|
|
567
|
-
|
|
569
|
+
// Chase the replacement chain — rewriting to a model that's ITSELF dying
|
|
570
|
+
// (davinci → gpt-3-5-turbo → gpt-4o-mini) just means fixing twice.
|
|
571
|
+
const target = terminalReplacement(model.replacement_slug, (slug) => bySlug.get(slug) ?? null, isCurrent);
|
|
572
|
+
for (const p of planFixes(refs, target)) all.push({ ...p, slug: model.slug, health });
|
|
568
573
|
}
|
|
569
574
|
if (!all.length) {
|
|
575
|
+
if (flags.json) return console.log(JSON.stringify({ applied: [], stale: [], failed: [], reason: "nothing to fix" }, null, 2));
|
|
570
576
|
console.log("Nothing to fix — no deprecated/retiring model with a registry replacement found in scannable files.");
|
|
571
577
|
return;
|
|
572
578
|
}
|
|
573
579
|
|
|
574
|
-
|
|
575
|
-
|
|
580
|
+
// --json = machine output: NOTHING but JSON on stdout.
|
|
581
|
+
if (!flags.json) {
|
|
582
|
+
console.log(`mm fix — ${all.length} reference(s) across ${new Set(all.map((p) => p.file)).size} file(s):\n`);
|
|
583
|
+
for (const p of all) console.log(` ${p.file}:${p.line} ${p.from} → ${p.to}`);
|
|
584
|
+
}
|
|
576
585
|
if (flags["dry-run"]) {
|
|
586
|
+
if (flags.json) return console.log(JSON.stringify({ planned: all, dryRun: true }, null, 2));
|
|
577
587
|
console.log("\n(dry run — nothing written. Re-run without --dry-run to apply.)");
|
|
578
588
|
return;
|
|
579
589
|
}
|
|
@@ -587,6 +597,7 @@ async function cmdFix(positional, flags) {
|
|
|
587
597
|
}
|
|
588
598
|
|
|
589
599
|
const res = applyFixes(dir, all);
|
|
600
|
+
recordFixes(dir, res.applied, { source: "cli" });
|
|
590
601
|
if (flags.json) return console.log(JSON.stringify(res, null, 2));
|
|
591
602
|
console.log(`\n✓ rewrote ${res.applied.length} reference(s) in ${new Set(res.applied.map((p) => p.file)).size} file(s).`);
|
|
592
603
|
for (const s of res.stale) console.log(` ! skipped ${s.file}:${s.line} — ${s.error}`);
|
package/src/tui/views/local.js
CHANGED
|
@@ -18,6 +18,7 @@ import { buildUsages, assignProjects } from "../../upload.js";
|
|
|
18
18
|
import { readSnippet } from "../snippet.js";
|
|
19
19
|
import { openLocation } from "../../openUrl.js";
|
|
20
20
|
import { addGlobalIgnore } from "../../sources/filesystem.js";
|
|
21
|
+
import { computeHealth } from "../../registry/local.js";
|
|
21
22
|
|
|
22
23
|
// Ordered by survival priority — KeyBar drops trailing entries on narrow
|
|
23
24
|
// terminals, so the primary verb (u push) and recovery (g rescan) must come
|
|
@@ -80,39 +81,56 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
|
|
|
80
81
|
setPushing(false);
|
|
81
82
|
}
|
|
82
83
|
}
|
|
83
|
-
/**
|
|
84
|
-
*
|
|
84
|
+
/** Fix flow, two beats: `f` opens a PREVIEW (the real diff — old line red,
|
|
85
|
+
* new line green) and `y` on the preview applies it. One ref from the
|
|
86
|
+
* drill-in, or all of them from the list. */
|
|
87
|
+
const [fixPreview, setFixPreview] = React.useState(null); // { previews, stale, repl, what }
|
|
85
88
|
function fixRefs(refs, what) {
|
|
86
|
-
const repl = cur?.model?.replacement_slug;
|
|
87
89
|
if (!cur?.model) return ui?.showToast?.("custom model — no registry replacement known", "#d97706");
|
|
88
90
|
if (cur.health === "ok") return ui?.showToast?.(`${cur.slug} is current — nothing to fix`, "#d97706");
|
|
89
|
-
if (!
|
|
90
|
-
import("../../fix.js").then(({ planFixes,
|
|
91
|
+
if (!cur.model.replacement_slug) return ui?.showToast?.("no replacement in the registry yet", "#d97706");
|
|
92
|
+
import("../../fix.js").then(({ planFixes, previewFixes, terminalReplacement }) => {
|
|
93
|
+
// Chase the replacement chain to the first CURRENT model — rewriting to a
|
|
94
|
+
// model that's itself dying just means pressing f twice.
|
|
95
|
+
const models = scan.snapshot?.models || [];
|
|
96
|
+
const bySlug = new Map(models.map((m) => [m.slug, m]));
|
|
97
|
+
const today = new Date();
|
|
98
|
+
const repl = terminalReplacement(
|
|
99
|
+
cur.model.replacement_slug,
|
|
100
|
+
(slug) => bySlug.get(slug) ?? null,
|
|
101
|
+
(m) => computeHealth(m, 90, today) === "ok",
|
|
102
|
+
);
|
|
91
103
|
const plans = planFixes(refs, repl);
|
|
92
104
|
if (!plans.length) return ui?.showToast?.("no rewritable file refs (integration-sourced?)", "#d97706");
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
105
|
+
const { previews, stale } = previewFixes(dir, plans);
|
|
106
|
+
if (!previews.length) return ui?.showToast?.("files changed since the scan — press g to rescan first", "#dc2626");
|
|
107
|
+
setFixPreview({ previews, stale, repl, what });
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** y on the preview: write the files, log history, patch the scan in place. */
|
|
112
|
+
function applyFixPreview() {
|
|
113
|
+
const fp = fixPreview;
|
|
114
|
+
if (!fp) return;
|
|
115
|
+
setFixPreview(null);
|
|
116
|
+
import("../../fix.js").then(({ applyFixes, styleReplacement, recordFixes }) => {
|
|
117
|
+
const res = applyFixes(dir, fp.previews);
|
|
118
|
+
recordFixes(dir, res.applied, { source: "tui" });
|
|
119
|
+
const files = new Set(res.applied.map((p) => p.file)).size;
|
|
120
|
+
if (res.applied.length) {
|
|
121
|
+
// Update the in-memory scan IN PLACE (no filesystem re-walk — on a big
|
|
122
|
+
// workspace a surprise full rescan reads as "what is it doing?!").
|
|
123
|
+
scan.applyEdit?.((cands) => cands.map((c) => {
|
|
124
|
+
const hit = res.applied.find((p) => p.file === c.source_path && p.line === c.source_line
|
|
125
|
+
&& (p.from === c.model_string || p.from.includes(c.model_string) || c.model_string.includes(p.from)));
|
|
126
|
+
if (!hit) return c;
|
|
127
|
+
const to = styleReplacement(c.model_string, fp.repl);
|
|
128
|
+
return to ? { ...c, model_string: to } : c;
|
|
129
|
+
}));
|
|
130
|
+
ui?.showToast?.(`${GLYPH.check} rewrote ${res.applied.length} ref${res.applied.length === 1 ? "" : "s"} in ${files} file${files === 1 ? "" : "s"} → ${fp.repl}${res.stale.length ? ` · ${res.stale.length} stale skipped` : ""}`);
|
|
131
|
+
} else {
|
|
132
|
+
ui?.showToast?.(res.stale.length ? "files changed since the scan — press g to rescan first" : "nothing rewritten", "#dc2626");
|
|
133
|
+
}
|
|
116
134
|
});
|
|
117
135
|
}
|
|
118
136
|
|
|
@@ -205,23 +223,30 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
|
|
|
205
223
|
// into refs, or an active filter) rather than stepping to the previous tab.
|
|
206
224
|
const setHandlesBack = ui?.setHandlesBack;
|
|
207
225
|
React.useEffect(() => {
|
|
208
|
-
setHandlesBack?.(focus === "refs" || !!search.query);
|
|
226
|
+
setHandlesBack?.(focus === "refs" || !!search.query || !!fixPreview);
|
|
209
227
|
return () => setHandlesBack?.(false);
|
|
210
|
-
}, [setHandlesBack, focus, search.query]);
|
|
228
|
+
}, [setHandlesBack, focus, search.query, fixPreview]);
|
|
211
229
|
|
|
212
230
|
// The refs drill-in swaps the keymap (↵ opens in editor, esc backs out) — the
|
|
213
231
|
// keybar must say so instead of advertising the dead list keys.
|
|
214
232
|
const setKeys = ui?.setKeys;
|
|
215
233
|
React.useEffect(() => {
|
|
216
|
-
setKeys?.(
|
|
217
|
-
? [{ k: "
|
|
218
|
-
:
|
|
234
|
+
setKeys?.(fixPreview
|
|
235
|
+
? [{ k: "y", label: "apply" }, { k: "esc", label: "cancel" }]
|
|
236
|
+
: focus === "refs"
|
|
237
|
+
? [{ k: "↑↓", label: "nav" }, { k: "↵", label: "open in editor" }, { k: "f", label: "fix ref" }, { k: "e", label: "exclude path" }, { k: "esc", label: "back" }]
|
|
238
|
+
: null);
|
|
219
239
|
return () => setKeys?.(null);
|
|
220
|
-
}, [setKeys, focus]);
|
|
240
|
+
}, [setKeys, focus, fixPreview]);
|
|
221
241
|
|
|
222
242
|
useInput(
|
|
223
243
|
(input, key) => {
|
|
224
244
|
if (!active) return;
|
|
245
|
+
if (fixPreview) {
|
|
246
|
+
if (input === "y" || key.return) return applyFixPreview();
|
|
247
|
+
if (key.escape || key.backspace || key.delete || input === "n") { setFixPreview(null); return ui?.showToast?.("fix cancelled"); }
|
|
248
|
+
return; // modal: ignore other keys while the diff is up
|
|
249
|
+
}
|
|
225
250
|
if (search.isSearchingNow()) {
|
|
226
251
|
if (key.escape) { search.clear(); ui?.setCapturing?.(false); return; }
|
|
227
252
|
if (key.return) { search.confirm(); ui?.setCapturing?.(false); return; }
|
|
@@ -265,6 +290,34 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
|
|
|
265
290
|
|
|
266
291
|
if (phase === "error") return h(StateLine, { kind: "error", text: scan.error, hint: "g rescans" });
|
|
267
292
|
|
|
293
|
+
// ----- fix preview (modal): the real diff before anything is written -----
|
|
294
|
+
if (fixPreview) {
|
|
295
|
+
const { previews, stale, repl, what } = fixPreview;
|
|
296
|
+
const perChange = 3; // file:line + the - / + pair
|
|
297
|
+
const maxShow = Math.max(1, Math.floor((height - 3) / perChange));
|
|
298
|
+
const shown = previews.slice(0, maxShow);
|
|
299
|
+
const lineW = Math.max(20, width - 4);
|
|
300
|
+
return h(
|
|
301
|
+
Box,
|
|
302
|
+
{ flexDirection: "column" },
|
|
303
|
+
h(Text, {},
|
|
304
|
+
h(Text, { color: C.ACCENT, bold: true }, ` ${GLYPH.spark} fix preview `),
|
|
305
|
+
h(Text, { color: C.FG_DIM }, `— ${what} → `),
|
|
306
|
+
h(Text, { color: "#16a34a", bold: true }, repl),
|
|
307
|
+
h(Text, { color: C.FG_DIM }, ` (${previews.length} change${previews.length === 1 ? "" : "s"}${stale.length ? ` · ${stale.length} stale skipped` : ""})`),
|
|
308
|
+
),
|
|
309
|
+
h(Text, {}, ""),
|
|
310
|
+
...shown.flatMap((p, i) => [
|
|
311
|
+
h(Text, { key: `f${i}`, color: C.FG_DIM }, ` ${cellE(`${p.file}:${p.line}`, lineW)}`),
|
|
312
|
+
h(Text, { key: `b${i}`, color: "#f87171" }, cellE(` - ${p.before.trimEnd()}`, lineW)),
|
|
313
|
+
h(Text, { key: `a${i}`, color: "#4ade80" }, cellE(` + ${p.after.trimEnd()}`, lineW)),
|
|
314
|
+
]),
|
|
315
|
+
previews.length > maxShow ? h(Text, { color: C.FG_DIM }, ` … ${previews.length - maxShow} more change${previews.length - maxShow === 1 ? "" : "s"} (all applied together)`) : null,
|
|
316
|
+
h(Text, {}, ""),
|
|
317
|
+
h(Text, { color: C.FG_DIM }, " nothing is written until you press y · esc cancels"),
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
268
321
|
// ----- strip (1 line) -----
|
|
269
322
|
const counters = `${fmtNum(filesScanned)} files · ${fmtNum(dirsSeen)} dirs · ${fmtNum(candidateCount)} refs${catalogsSkipped ? ` · ${catalogsSkipped} catalog${catalogsSkipped === 1 ? "" : "s"} skipped` : ""}`;
|
|
270
323
|
const attention = rows.filter((r) => r.health !== "ok").length;
|
|
@@ -10,8 +10,11 @@ import {
|
|
|
10
10
|
} from "../ui.js";
|
|
11
11
|
import { collectFrom } from "../../sources/index.js";
|
|
12
12
|
import { loadConfig, setConfigValue } from "../../config.js";
|
|
13
|
+
import { readFixes } from "../../fix.js";
|
|
14
|
+
import { openLocation } from "../../openUrl.js";
|
|
15
|
+
import path from "node:path";
|
|
13
16
|
|
|
14
|
-
const TABS = ["Registry", "Alerts", "Drift"];
|
|
17
|
+
const TABS = ["Registry", "Alerts", "Drift", "Fixes"];
|
|
15
18
|
|
|
16
19
|
// Per-section keybars — the active actions differ by tab (Registry: mark seen ·
|
|
17
20
|
// Notifications: mark read · Drift: rescan + archive). Published via ui.setKeys()
|
|
@@ -20,6 +23,7 @@ const TAB_KEYS = [
|
|
|
20
23
|
[{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "m", label: "mark all seen" }, { k: "g", label: "refresh" }],
|
|
21
24
|
[{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "o", label: "mark read" }, { k: "g", label: "refresh" }],
|
|
22
25
|
[{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "r", label: "rescan" }, { k: "a", label: "archive" }],
|
|
26
|
+
[{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "o", label: "open in editor" }, { k: "g", label: "refresh" }],
|
|
23
27
|
];
|
|
24
28
|
|
|
25
29
|
// The static fallback must match the default (Registry) section exactly —
|
|
@@ -43,6 +47,8 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
43
47
|
return { events: ev.data || [], models, provs };
|
|
44
48
|
}, []);
|
|
45
49
|
const notif = useAsync(async () => (await client.listNotifications({})).data || [], []);
|
|
50
|
+
const [fixes, setFixes] = React.useState(() => readFixes());
|
|
51
|
+
React.useEffect(() => { if (tab === 3) setFixes(readFixes()); }, [tab]);
|
|
46
52
|
const [drift, setDrift] = React.useState(null);
|
|
47
53
|
// Gate the spinner tick on actual loading — not tab focus — so an idle
|
|
48
54
|
// What's New tab doesn't re-render ~12×/sec forever.
|
|
@@ -93,6 +99,7 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
93
99
|
if (input === "g") {
|
|
94
100
|
if (tab === 0) return reg.reload();
|
|
95
101
|
if (tab === 1) return notif.reload();
|
|
102
|
+
if (tab === 3) return setFixes(readFixes());
|
|
96
103
|
return runDrift();
|
|
97
104
|
}
|
|
98
105
|
|
|
@@ -109,6 +116,12 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
109
116
|
ui.showToast("marked read");
|
|
110
117
|
notif.reload();
|
|
111
118
|
}).catch((e) => ui.showToast(e.message, "red"));
|
|
119
|
+
} else if (tab === 3) {
|
|
120
|
+
const cur = fixes[clampCursor(cursor, fixes.length)];
|
|
121
|
+
if (input === "o" && cur) {
|
|
122
|
+
openLocation(path.resolve(cur.dir, cur.file), cur.line);
|
|
123
|
+
ui.showToast(`opened ${cur.file}:${cur.line}`);
|
|
124
|
+
}
|
|
112
125
|
} else if (tab === 2) {
|
|
113
126
|
if (input === "r") return runDrift();
|
|
114
127
|
const gone = drift?.gone || [];
|
|
@@ -131,9 +144,10 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
131
144
|
let context;
|
|
132
145
|
if (tab === 0) context = reg.error ? "offline" : `${reg.data?.events.length || 0} changes`;
|
|
133
146
|
else if (tab === 1) context = notif.error ? "offline" : `${(notif.data || []).length} alerts`;
|
|
147
|
+
else if (tab === 3) context = `${fixes.length} fix${fixes.length === 1 ? "" : "es"}`;
|
|
134
148
|
else context = drift && !drift.loading && !drift.error ? `+${drift.added.length} / -${drift.gone.length}` : "drift";
|
|
135
149
|
ui?.reportStatus?.({ context });
|
|
136
|
-
}, [tab, reg.data, reg.error, notif.data, notif.error, drift, ui]);
|
|
150
|
+
}, [tab, reg.data, reg.error, notif.data, notif.error, drift, fixes, ui]);
|
|
137
151
|
|
|
138
152
|
let body;
|
|
139
153
|
if (tab === 0) {
|
|
@@ -188,6 +202,36 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
188
202
|
}),
|
|
189
203
|
);
|
|
190
204
|
}
|
|
205
|
+
} else if (tab === 3) {
|
|
206
|
+
if (!fixes.length) {
|
|
207
|
+
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.");
|
|
208
|
+
} else {
|
|
209
|
+
// Windowed like the Alerts section so ↑↓ reaches every row.
|
|
210
|
+
const cur = clampCursor(cursor, fixes.length);
|
|
211
|
+
const start = Math.max(0, Math.min(cur - ROWS + 1, fixes.length - ROWS));
|
|
212
|
+
const ago = (ts) => {
|
|
213
|
+
const m = Math.max(0, Math.round((Date.now() - ts) / 60000));
|
|
214
|
+
if (m < 1) return "now";
|
|
215
|
+
if (m < 60) return `${m}m ago`;
|
|
216
|
+
const hr = Math.round(m / 60);
|
|
217
|
+
if (hr < 24) return `${hr}h ago`;
|
|
218
|
+
return `${Math.round(hr / 24)}d ago`;
|
|
219
|
+
};
|
|
220
|
+
body = h(
|
|
221
|
+
Box,
|
|
222
|
+
{ flexDirection: "column" },
|
|
223
|
+
...fixes.slice(start, start + ROWS).map((f, i) => {
|
|
224
|
+
const cells = [
|
|
225
|
+
{ text: `${GLYPH.check} `, color: "#16a34a" },
|
|
226
|
+
{ text: cellE(`${path.basename(f.dir)} · ${f.file}:${f.line}`, 38), color: C.FG },
|
|
227
|
+
{ text: " ", color: C.FG },
|
|
228
|
+
{ text: cellE(`${f.from} → ${f.to}`, Math.max(16, width - 58)), color: C.FG_DIM },
|
|
229
|
+
{ text: cellE(ago(f.ts), 8), color: C.FG_FAINT },
|
|
230
|
+
];
|
|
231
|
+
return h(ListRow, { key: `f${start + i}`, active: start + i === cur, cells, width });
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
191
235
|
} else {
|
|
192
236
|
if (!drift) body = h(Text, { color: C.FG_DIM }, ` Press r to scan ${dir} and compare against tracked usages.`);
|
|
193
237
|
else if (drift.loading) body = h(StateLine, { kind: "loading", spin, text: "scanning for drift…" });
|