@modelstatus/cli 0.1.63 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelstatus/cli",
3
- "version": "0.1.63",
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
@@ -124,6 +124,39 @@ export function planFixes(refs, replacementSlug) {
124
124
  );
125
125
  }
126
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
+
127
160
  /**
128
161
  * Apply plans to files under `dir`. Groups by file so multi-ref files are one
129
162
  * read+write. Returns { applied, stale, failed } — applied entries carry the
@@ -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
- /** Rewrite refs of the focused model to its registry replacement (in place,
85
- * boundary-safe, y-confirmed). One ref from the drill-in, or all of them. */
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, applyFixes, styleReplacement, terminalReplacement, recordFixes }) => {
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
- ui?.askPrompt?.(`Rewrite ${what} ${repl}? type y`, {
104
- onSubmit: (v) => {
105
- if (String(v || "").trim().toLowerCase() !== "y") return ui?.showToast?.("fix cancelled");
106
- const res = applyFixes(dir, plans);
107
- recordFixes(dir, res.applied, { source: "tui" });
108
- const files = new Set(res.applied.map((p) => p.file)).size;
109
- if (res.applied.length) {
110
- // Update the in-memory scan IN PLACE (no filesystem re-walk — on a big
111
- // workspace a surprise full rescan reads as "what is it doing?!").
112
- // Any candidate at a rewritten file:line whose string overlaps the
113
- // applied rewrite re-styles to the replacement; g still re-verifies.
114
- scan.applyEdit?.((cands) => cands.map((c) => {
115
- const hit = res.applied.find((p) => p.file === c.source_path && p.line === c.source_line
116
- && (p.from === c.model_string || p.from.includes(c.model_string) || c.model_string.includes(p.from)));
117
- if (!hit) return c;
118
- const to = styleReplacement(c.model_string, repl);
119
- return to ? { ...c, model_string: to } : c;
120
- }));
121
- ui?.showToast?.(`${GLYPH.check} rewrote ${res.applied.length} ref${res.applied.length === 1 ? "" : "s"} in ${files} file${files === 1 ? "" : "s"} → ${repl}${res.stale.length ? ` · ${res.stale.length} stale skipped` : ""}`);
122
- } else {
123
- ui?.showToast?.(res.stale.length ? "files changed since the scan — press g to rescan first" : "nothing rewritten", "#dc2626");
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?.(focus === "refs"
228
- ? [{ k: "↑↓", label: "nav" }, { k: "↵", label: "open in editor" }, { k: "f", label: "fix ref" }, { k: "e", label: "exclude path" }, { k: "esc", label: "back" }]
229
- : null);
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;