@modelstatus/cli 0.1.63 → 0.1.65
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 +38 -3
- package/src/tui/views/local.js +75 -33
- package/src/tui/views/whatsnew.js +18 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modelstatus/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.65",
|
|
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
|
@@ -55,7 +55,8 @@ export function recordFixes(dir, applied, { source = "tui" } = {}) {
|
|
|
55
55
|
if (Array.isArray(j)) log = j;
|
|
56
56
|
} catch { /* first write */ }
|
|
57
57
|
const ts = Date.now();
|
|
58
|
-
const
|
|
58
|
+
const clip = (t) => (typeof t === "string" && t.length > 240 ? t.slice(0, 237) + "…" : t);
|
|
59
|
+
const entries = applied.map((p) => ({ ts, dir: path.resolve(dir), file: p.file, line: p.line, from: p.from, to: p.to, before: clip(p.before), after: clip(p.after), source }));
|
|
59
60
|
log = [...entries, ...log].slice(0, FIXES_CAP);
|
|
60
61
|
fs.mkdirSync(path.dirname(FIXES_FILE), { recursive: true, mode: 0o700 });
|
|
61
62
|
fs.writeFileSync(FIXES_FILE, JSON.stringify(log));
|
|
@@ -124,6 +125,39 @@ export function planFixes(refs, replacementSlug) {
|
|
|
124
125
|
);
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
/**
|
|
129
|
+
* READ-ONLY preview of what applyFixes would do: each plan gains the full line
|
|
130
|
+
* text before/after (the TUI's diff visual). Nothing is written. Plans whose
|
|
131
|
+
* line no longer carries the string come back in `stale`.
|
|
132
|
+
*/
|
|
133
|
+
export function previewFixes(dir, plans) {
|
|
134
|
+
const previews = [];
|
|
135
|
+
const stale = [];
|
|
136
|
+
const cache = new Map(); // file -> lines[] | null
|
|
137
|
+
for (const p of plans) {
|
|
138
|
+
if (!cache.has(p.file)) {
|
|
139
|
+
try {
|
|
140
|
+
cache.set(p.file, fs.readFileSync(path.resolve(dir, p.file), "utf8").split(/\r?\n/));
|
|
141
|
+
} catch {
|
|
142
|
+
cache.set(p.file, null);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const lines = cache.get(p.file);
|
|
146
|
+
const before = lines?.[p.line - 1];
|
|
147
|
+
if (before == null) {
|
|
148
|
+
stale.push({ ...p, error: "file or line missing — rescan" });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const { out, n } = replaceOnLine(before, p.from, p.to);
|
|
152
|
+
if (n === 0) {
|
|
153
|
+
stale.push({ ...p, error: "string not on that line anymore — rescan" });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
previews.push({ ...p, before, after: out });
|
|
157
|
+
}
|
|
158
|
+
return { previews, stale };
|
|
159
|
+
}
|
|
160
|
+
|
|
127
161
|
/**
|
|
128
162
|
* Apply plans to files under `dir`. Groups by file so multi-ref files are one
|
|
129
163
|
* read+write. Returns { applied, stale, failed } — applied entries carry the
|
|
@@ -157,14 +191,15 @@ export function applyFixes(dir, plans) {
|
|
|
157
191
|
stale.push({ ...p, error: "line out of range — rescan" });
|
|
158
192
|
continue;
|
|
159
193
|
}
|
|
160
|
-
const
|
|
194
|
+
const before = lines[idx];
|
|
195
|
+
const { out, n } = replaceOnLine(before, p.from, p.to);
|
|
161
196
|
if (n === 0) {
|
|
162
197
|
stale.push({ ...p, error: "string not on that line anymore — rescan" });
|
|
163
198
|
continue;
|
|
164
199
|
}
|
|
165
200
|
lines[idx] = out;
|
|
166
201
|
dirty = true;
|
|
167
|
-
applied.push({ ...p, count: n });
|
|
202
|
+
applied.push({ ...p, count: n, before, after: out });
|
|
168
203
|
}
|
|
169
204
|
if (dirty) {
|
|
170
205
|
try {
|
package/src/tui/views/local.js
CHANGED
|
@@ -81,13 +81,15 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
|
|
|
81
81
|
setPushing(false);
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
-
/**
|
|
85
|
-
*
|
|
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 }
|
|
86
88
|
function fixRefs(refs, what) {
|
|
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
91
|
if (!cur.model.replacement_slug) return ui?.showToast?.("no replacement in the registry yet", "#d97706");
|
|
90
|
-
import("../../fix.js").then(({ planFixes,
|
|
92
|
+
import("../../fix.js").then(({ planFixes, previewFixes, terminalReplacement }) => {
|
|
91
93
|
// Chase the replacement chain to the first CURRENT model — rewriting to a
|
|
92
94
|
// model that's itself dying just means pressing f twice.
|
|
93
95
|
const models = scan.snapshot?.models || [];
|
|
@@ -100,30 +102,35 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
|
|
|
100
102
|
);
|
|
101
103
|
const plans = planFixes(refs, repl);
|
|
102
104
|
if (!plans.length) return ui?.showToast?.("no rewritable file refs (integration-sourced?)", "#d97706");
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
}
|
|
127
134
|
});
|
|
128
135
|
}
|
|
129
136
|
|
|
@@ -216,23 +223,30 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
|
|
|
216
223
|
// into refs, or an active filter) rather than stepping to the previous tab.
|
|
217
224
|
const setHandlesBack = ui?.setHandlesBack;
|
|
218
225
|
React.useEffect(() => {
|
|
219
|
-
setHandlesBack?.(focus === "refs" || !!search.query);
|
|
226
|
+
setHandlesBack?.(focus === "refs" || !!search.query || !!fixPreview);
|
|
220
227
|
return () => setHandlesBack?.(false);
|
|
221
|
-
}, [setHandlesBack, focus, search.query]);
|
|
228
|
+
}, [setHandlesBack, focus, search.query, fixPreview]);
|
|
222
229
|
|
|
223
230
|
// The refs drill-in swaps the keymap (↵ opens in editor, esc backs out) — the
|
|
224
231
|
// keybar must say so instead of advertising the dead list keys.
|
|
225
232
|
const setKeys = ui?.setKeys;
|
|
226
233
|
React.useEffect(() => {
|
|
227
|
-
setKeys?.(
|
|
228
|
-
? [{ k: "
|
|
229
|
-
:
|
|
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);
|
|
230
239
|
return () => setKeys?.(null);
|
|
231
|
-
}, [setKeys, focus]);
|
|
240
|
+
}, [setKeys, focus, fixPreview]);
|
|
232
241
|
|
|
233
242
|
useInput(
|
|
234
243
|
(input, key) => {
|
|
235
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
|
+
}
|
|
236
250
|
if (search.isSearchingNow()) {
|
|
237
251
|
if (key.escape) { search.clear(); ui?.setCapturing?.(false); return; }
|
|
238
252
|
if (key.return) { search.confirm(); ui?.setCapturing?.(false); return; }
|
|
@@ -276,6 +290,34 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
|
|
|
276
290
|
|
|
277
291
|
if (phase === "error") return h(StateLine, { kind: "error", text: scan.error, hint: "g rescans" });
|
|
278
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
|
+
|
|
279
321
|
// ----- strip (1 line) -----
|
|
280
322
|
const counters = `${fmtNum(filesScanned)} files · ${fmtNum(dirsSeen)} dirs · ${fmtNum(candidateCount)} refs${catalogsSkipped ? ` · ${catalogsSkipped} catalog${catalogsSkipped === 1 ? "" : "s"} skipped` : ""}`;
|
|
281
323
|
const attention = rows.filter((r) => r.health !== "ok").length;
|
|
@@ -206,9 +206,11 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
206
206
|
if (!fixes.length) {
|
|
207
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
208
|
} else {
|
|
209
|
-
// Windowed like the Alerts section so ↑↓ reaches every row
|
|
209
|
+
// Windowed like the Alerts section so ↑↓ reaches every row — minus 4 rows
|
|
210
|
+
// reserved for the selected fix's diff underneath.
|
|
211
|
+
const FROWS = Math.max(3, ROWS - 4);
|
|
210
212
|
const cur = clampCursor(cursor, fixes.length);
|
|
211
|
-
const start = Math.max(0, Math.min(cur -
|
|
213
|
+
const start = Math.max(0, Math.min(cur - FROWS + 1, fixes.length - FROWS));
|
|
212
214
|
const ago = (ts) => {
|
|
213
215
|
const m = Math.max(0, Math.round((Date.now() - ts) / 60000));
|
|
214
216
|
if (m < 1) return "now";
|
|
@@ -220,7 +222,7 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
220
222
|
body = h(
|
|
221
223
|
Box,
|
|
222
224
|
{ flexDirection: "column" },
|
|
223
|
-
...fixes.slice(start, start +
|
|
225
|
+
...fixes.slice(start, start + FROWS).map((f, i) => {
|
|
224
226
|
const cells = [
|
|
225
227
|
{ text: `${GLYPH.check} `, color: "#16a34a" },
|
|
226
228
|
{ text: cellE(`${path.basename(f.dir)} · ${f.file}:${f.line}`, 38), color: C.FG },
|
|
@@ -230,6 +232,19 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
230
232
|
];
|
|
231
233
|
return h(ListRow, { key: `f${start + i}`, active: start + i === cur, cells, width });
|
|
232
234
|
}),
|
|
235
|
+
// The selected fix's diff — same red/green visual as the f preview + the fix PR.
|
|
236
|
+
h(Text, { key: "drule" }, ""),
|
|
237
|
+
...(() => {
|
|
238
|
+
const sel = fixes[cur];
|
|
239
|
+
if (!sel) return [];
|
|
240
|
+
if (!sel.before || !sel.after) return [h(Text, { key: "dnone", color: C.FG_DIM }, " (diff not recorded for this entry — older fix)")];
|
|
241
|
+
const w = Math.max(20, width - 4);
|
|
242
|
+
return [
|
|
243
|
+
h(Text, { key: "dh", color: C.FG_DIM }, ` ${cellE(`${sel.file}:${sel.line}`, w)}`),
|
|
244
|
+
h(Text, { key: "dd", color: "#f87171" }, cellE(` - ${sel.before.trimEnd()}`, w)),
|
|
245
|
+
h(Text, { key: "da", color: "#4ade80" }, cellE(` + ${sel.after.trimEnd()}`, w)),
|
|
246
|
+
];
|
|
247
|
+
})(),
|
|
233
248
|
);
|
|
234
249
|
}
|
|
235
250
|
} else {
|