@modelstatus/cli 0.1.59 → 0.1.61

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.59",
3
+ "version": "0.1.61",
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 ADDED
@@ -0,0 +1,127 @@
1
+ /* Auto-fix: rewrite model ids in source files to their registry replacement.
2
+ *
3
+ * The scan already knows every reference's file:line + the exact matched string,
4
+ * and the registry knows each dying model's replacement_slug — so a fix is a
5
+ * boundary-safe string substitution at a known location. Used by the TUI Here
6
+ * tab (`f` fix one ref / all refs of a model) and `mm fix` (whole repo).
7
+ *
8
+ * Safety rules:
9
+ * - Replace ONLY on the recorded line. If the string is no longer there (file
10
+ * changed since the scan), the ref is reported "stale" and skipped — never
11
+ * guess. A rescan refreshes the locations.
12
+ * - Boundary-aware matching: "gpt-4" must never rewrite inside "gpt-4o" /
13
+ * "gpt-4-turbo". A model-id character ([A-Za-z0-9._-]) on either side of the
14
+ * match blocks it.
15
+ * - Style-preserving replacement: if the code says "openai/gpt-4" the new id
16
+ * keeps the provider prefix; if it says "gpt-4" it stays bare.
17
+ */
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+
21
+ /** The string to write into the file: the replacement slug, styled to match how
22
+ * the old id was written (provider-prefixed vs bare). */
23
+ export function styleReplacement(oldStr, replacementSlug) {
24
+ if (!replacementSlug) return null;
25
+ const i = replacementSlug.indexOf("/");
26
+ const bare = i >= 0 ? replacementSlug.slice(i + 1) : replacementSlug;
27
+ return oldStr.includes("/") ? replacementSlug : bare;
28
+ }
29
+
30
+ const BOUND = "[A-Za-z0-9._-]";
31
+ const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
32
+
33
+ /** Boundary-safe replace of every occurrence of `from` in one line of text.
34
+ * Returns { out, n } — the rewritten line and how many occurrences changed. */
35
+ export function replaceOnLine(lineText, from, to) {
36
+ const re = new RegExp(`(?<!${BOUND})${escRe(from)}(?!${BOUND})`, "g");
37
+ let n = 0;
38
+ const out = lineText.replace(re, () => {
39
+ n += 1;
40
+ return to;
41
+ });
42
+ return { out, n };
43
+ }
44
+
45
+ /**
46
+ * Build fix plans from scan refs. `refs` = [{ model_string, source_path,
47
+ * source_line }] (the shape the scanner emits); `replacementSlug` is the
48
+ * registry replacement for the model those refs belong to. Refs without a real
49
+ * file path (integration scheme labels like vercel://) are skipped — only
50
+ * filesystem refs are rewritable.
51
+ */
52
+ export function planFixes(refs, replacementSlug) {
53
+ const plans = [];
54
+ const seen = new Set();
55
+ for (const r of refs || []) {
56
+ if (!r?.source_path || !r.source_line || !r.model_string) continue;
57
+ const to = styleReplacement(r.model_string, replacementSlug);
58
+ if (!to || to === r.model_string) continue;
59
+ const key = `${r.source_path}:${r.source_line}:${r.model_string}`;
60
+ if (seen.has(key)) continue;
61
+ seen.add(key);
62
+ plans.push({ file: r.source_path, line: r.source_line, from: r.model_string, to });
63
+ }
64
+ // The detector can emit BOTH "gpt-4" and "openai/gpt-4" for the same spot. At
65
+ // one file:line, keep only the most specific plan (drop a `from` that is a
66
+ // substring of a sibling's) — the longer rewrite covers the shorter one, and
67
+ // the duplicate would otherwise report a confusing "stale" skip.
68
+ return plans.filter(
69
+ (p) => !plans.some((q) => q !== p && q.file === p.file && q.line === p.line && q.from.includes(p.from) && q.from !== p.from),
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Apply plans to files under `dir`. Groups by file so multi-ref files are one
75
+ * read+write. Returns { applied, stale, failed } — applied entries carry the
76
+ * occurrence count actually rewritten on that line.
77
+ */
78
+ export function applyFixes(dir, plans) {
79
+ const byFile = new Map();
80
+ for (const p of plans) {
81
+ const arr = byFile.get(p.file) || [];
82
+ arr.push(p);
83
+ byFile.set(p.file, arr);
84
+ }
85
+ const applied = [];
86
+ const stale = [];
87
+ const failed = [];
88
+ for (const [file, filePlans] of byFile) {
89
+ const abs = path.resolve(dir, file);
90
+ let text;
91
+ try {
92
+ text = fs.readFileSync(abs, "utf8");
93
+ } catch (e) {
94
+ for (const p of filePlans) failed.push({ ...p, error: e.code === "ENOENT" ? "file not found" : e.message });
95
+ continue;
96
+ }
97
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
98
+ const lines = text.split(/\r?\n/);
99
+ let dirty = false;
100
+ for (const p of filePlans) {
101
+ const idx = p.line - 1;
102
+ if (idx < 0 || idx >= lines.length) {
103
+ stale.push({ ...p, error: "line out of range — rescan" });
104
+ continue;
105
+ }
106
+ const { out, n } = replaceOnLine(lines[idx], p.from, p.to);
107
+ if (n === 0) {
108
+ stale.push({ ...p, error: "string not on that line anymore — rescan" });
109
+ continue;
110
+ }
111
+ lines[idx] = out;
112
+ dirty = true;
113
+ applied.push({ ...p, count: n });
114
+ }
115
+ if (dirty) {
116
+ try {
117
+ fs.writeFileSync(abs, lines.join(eol));
118
+ } catch (e) {
119
+ // Roll the bookkeeping back: everything in this file actually failed.
120
+ for (let i = applied.length - 1; i >= 0; i--) {
121
+ if (applied[i].file === file) failed.push({ ...applied.splice(i, 1)[0], error: e.message });
122
+ }
123
+ }
124
+ }
125
+ }
126
+ return { applied, stale, failed };
127
+ }
package/src/index.js CHANGED
@@ -88,7 +88,7 @@ function parseArgs(argv) {
88
88
  const flags = {};
89
89
  const positional = [];
90
90
  const valueFlags = new Set([
91
- "api", "key", "project", "dir", "fail-on", "diff", "json-out",
91
+ "api", "key", "project", "dir", "fail-on", "diff", "json-out", "model",
92
92
  "sources", "region", "namespace", "kube-context", "db", "sql-table", "env",
93
93
  // Per-integration scope flags (non-secret): consumed by the 4 live sources.
94
94
  "vercel-project", "vercel-team", "gh-repo", "supabase-ref",
@@ -532,6 +532,68 @@ async function cmdClear(_positional, flags) {
532
532
  console.log(`✓ Cleared ${res.usages ?? 0} usage(s)${res.projects ? ` + ${res.projects} project(s)` : ""}. Your inventory is clean — rescan to repopulate.`);
533
533
  }
534
534
 
535
+ /** `mm fix [dir]` — rewrite dying model ids to their registry replacement,
536
+ * in-place, and print the change list. The scan knows file:line + the exact
537
+ * string; the registry knows the replacement. --dry-run previews; --yes skips
538
+ * the confirm; --model <slug> limits to one model. Only filesystem refs are
539
+ * rewritable (vercel:// etc. are skipped). */
540
+ async function cmdFix(positional, flags) {
541
+ const dir = path.resolve(positional[1] || flags.dir || ".");
542
+ const { getRegistry } = await import("./registry/fetch.js");
543
+ const { resolveLocal, computeHealth } = await import("./registry/local.js");
544
+ const { planFixes, applyFixes } = await import("./fix.js");
545
+ const snapshot = await getRegistry({ offline: !!flags.offline, log: (m) => process.stderr.write(`! ${m}\n`) });
546
+
547
+ const candidates = await collectFrom(["filesystem"], { root: dir }, snapshot.detection);
548
+ const resolved = resolveLocal(snapshot, [...new Set(candidates.map((c) => c.model_string))]);
549
+ const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
550
+ const today = new Date();
551
+
552
+ // Group rewritable refs by model; keep only dying models WITH a replacement.
553
+ const byModel = new Map(); // slug -> { model, health, refs }
554
+ for (const c of candidates) {
555
+ const r = byStr.get(c.model_string.toLowerCase());
556
+ if (!r?.model_slug || !r.model?.replacement_slug) continue;
557
+ const health = computeHealth(r.model, 90, today);
558
+ if (health === "ok") continue;
559
+ if (flags.model && r.model_slug !== flags.model) continue;
560
+ const e = byModel.get(r.model_slug) || { model: r.model, health, refs: [] };
561
+ e.refs.push(c);
562
+ byModel.set(r.model_slug, e);
563
+ }
564
+
565
+ const all = [];
566
+ for (const { model, health, refs } of byModel.values()) {
567
+ for (const p of planFixes(refs, model.replacement_slug)) all.push({ ...p, slug: model.slug, health });
568
+ }
569
+ if (!all.length) {
570
+ console.log("Nothing to fix — no deprecated/retiring model with a registry replacement found in scannable files.");
571
+ return;
572
+ }
573
+
574
+ console.log(`mm fix — ${all.length} reference(s) across ${new Set(all.map((p) => p.file)).size} file(s):\n`);
575
+ for (const p of all) console.log(` ${p.file}:${p.line} ${p.from} → ${p.to}`);
576
+ if (flags["dry-run"]) {
577
+ console.log("\n(dry run — nothing written. Re-run without --dry-run to apply.)");
578
+ return;
579
+ }
580
+ if (!flags.yes) {
581
+ if (!process.stdin.isTTY) {
582
+ console.error("\nRefusing to rewrite files without confirmation. Re-run with --yes (or --dry-run to preview).");
583
+ process.exit(1);
584
+ }
585
+ const ok = await confirm(`\nRewrite these ${all.length} reference(s) in place? [y/N] `);
586
+ if (!ok) return console.log("Aborted — nothing changed.");
587
+ }
588
+
589
+ const res = applyFixes(dir, all);
590
+ if (flags.json) return console.log(JSON.stringify(res, null, 2));
591
+ console.log(`\n✓ rewrote ${res.applied.length} reference(s) in ${new Set(res.applied.map((p) => p.file)).size} file(s).`);
592
+ for (const s of res.stale) console.log(` ! skipped ${s.file}:${s.line} — ${s.error}`);
593
+ for (const f of res.failed) console.log(` × failed ${f.file}:${f.line} — ${f.error}`);
594
+ if (res.applied.length) console.log("\nRe-run your tests, then `mm status` to confirm everything reads current.");
595
+ }
596
+
535
597
  /** List detection sources and whether each can run right now. Live integrations
536
598
  * also show their on/off toggle (the `int` column) so toggled state is visible
537
599
  * here too. */
@@ -687,6 +749,7 @@ Usage:
687
749
  mm signup Create an account in the browser, then poll
688
750
  mm logout Forget the saved API key
689
751
  mm scan [dir] Scan for model usage; interactive TUI, or --ci/--json for pipelines
752
+ mm fix [dir] Rewrite dying model ids to their replacement, in place (--dry-run previews; --model <slug> limits; --yes skips the confirm)
690
753
  mm ci [dir] CI gate: fail the build on deprecated/retiring models (GitHub annotations)
691
754
  (--diff <base> limits findings to files changed vs base; auto on PRs via GITHUB_BASE_REF)
692
755
  mm sources List detection sources and whether each can run here
@@ -800,6 +863,7 @@ async function main() {
800
863
  else if (cmd === "signup") await cmdSignup(positional, flags);
801
864
  else if (cmd === "logout") cmdLogout();
802
865
  else if (cmd === "scan") await cmdScan(positional, flags);
866
+ else if (cmd === "fix") await cmdFix(positional, flags);
803
867
  else if (cmd === "ci") await cmdCi(positional, flags);
804
868
  else if (cmd === "status") await cmdStatus(positional, flags);
805
869
  else if (cmd === "sources") await cmdSources(positional, flags);
package/src/tui/ui.js CHANGED
@@ -159,8 +159,12 @@ export function relativeTime(retiresDate, today = new Date()) {
159
159
  /** env → { text, color }. */
160
160
  export function envTag(env) {
161
161
  const e = String(env || "");
162
- const color = e === "prod" ? C.ACCENT : e === "staging" ? "#a78bfa" : C.FG_FAINT;
163
- return { text: e, color };
162
+ // Fit the 5-char env column without mid-word chops ("unknown" "unkno").
163
+ // Unknown renders BLANK: it's the default, not information — only a real env
164
+ // should draw the eye.
165
+ const SHORT = { unknown: "", production: "prod", staging: "stage", development: "dev" };
166
+ const color = e === "prod" || e === "production" ? C.ACCENT : e === "staging" ? "#a78bfa" : C.FG_FAINT;
167
+ return { text: SHORT[e] ?? e, color };
164
168
  }
165
169
 
166
170
  // ===========================================================================
@@ -360,9 +364,16 @@ export function KeyBar({ keys, width }) {
360
364
  // ===========================================================================
361
365
 
362
366
  /** kind ∈ loading|scanning|done|error → leading glyph + colored text, one row. */
363
- export function StateLine({ kind, text, spin }) {
367
+ export function StateLine({ kind, text, spin, hint }) {
368
+ // `hint` = dim recovery affordance appended after an error ("g retries") so a
369
+ // failure line always says how to get unstuck, in one consistent style.
364
370
  if (kind === "done") return h(Text, {}, h(Text, { color: "#16a34a" }, ` ${GLYPH.check} `), h(Text, { color: C.FG_DIM }, text));
365
- if (kind === "error") return h(Text, {}, h(Text, { color: "#dc2626" }, ` ${GLYPH.cross} `), h(Text, { color: "#dc2626" }, text));
371
+ if (kind === "error")
372
+ return h(Text, {},
373
+ h(Text, { color: "#dc2626" }, ` ${GLYPH.cross} `),
374
+ h(Text, { color: "#dc2626" }, text),
375
+ hint ? h(Text, { color: C.FG_DIM }, ` · ${hint}`) : null,
376
+ );
366
377
  // loading / scanning
367
378
  return h(Text, {}, h(Text, { color: C.ACCENT }, ` ${spin ?? SPINNER[0]} `), h(Text, { color: C.FG_DIM }, text));
368
379
  }
@@ -28,7 +28,10 @@ export function AccountView({ client, me, refreshMe, apiBase, ui, active }) {
28
28
  React.useEffect(() => ui?.reportStatus?.({ context: `plan: ${me?.plan ?? "…"}` }), [me, ui]);
29
29
 
30
30
  async function upgrade() {
31
- if (me?.plan && me.plan !== "free") return ui.showToast(`already on ${me.plan}`, "yellow");
31
+ // Without a loaded account a checkout would just fire at an unreachable
32
+ // endpoint and die with a raw fetch error — say what to do instead.
33
+ if (!me?.plan) return ui.showToast("account not loaded — press g to refresh first", "yellow");
34
+ if (me.plan !== "free") return ui.showToast(`already on ${me.plan}`, "yellow");
32
35
  setStatus("Starting checkout…");
33
36
  try {
34
37
  const { url } = await client.checkout();
@@ -52,7 +55,7 @@ export function AccountView({ client, me, refreshMe, apiBase, ui, active }) {
52
55
  };
53
56
  setTimeout(tick, 4000);
54
57
  } catch (e) {
55
- setStatus(e.status === 503 ? "Billing isn't configured on this server." : e.message);
58
+ setStatus(e.status === 503 ? "Billing isn't configured on this server." : `checkout failed: ${e.message} — press u to retry.`);
56
59
  }
57
60
  }
58
61
 
@@ -67,11 +70,16 @@ export function AccountView({ client, me, refreshMe, apiBase, ui, active }) {
67
70
 
68
71
  const plan = me?.plan ?? "…";
69
72
  const isFree = plan === "free";
73
+ // An UNLOADED account is not Pro — never show the PRO badge (or the thank-you
74
+ // line below) until /me actually resolves; offline must read as offline.
75
+ const unknown = me?.plan == null;
70
76
  const endpoint = apiBase || cfg.apiBase || "https://llmstatus.ai";
71
77
 
72
- const badge = isFree
73
- ? h(Text, { color: C.FG_FAINT }, " free ")
74
- : h(Text, { backgroundColor: BG_ON ? "#16a34a" : undefined, color: C.ACCENT_INK, bold: true }, " PRO ");
78
+ const badge = unknown
79
+ ? h(Text, { color: C.FG_FAINT }, " ")
80
+ : isFree
81
+ ? h(Text, { color: C.FG_FAINT }, " free ")
82
+ : h(Text, { backgroundColor: BG_ON ? "#16a34a" : undefined, color: C.ACCENT_INK, bold: true }, " PRO ");
75
83
 
76
84
  return h(
77
85
  Box,
@@ -90,17 +98,19 @@ export function AccountView({ client, me, refreshMe, apiBase, ui, active }) {
90
98
  h(Row, { label: "retiring", value: `${me?.retiring_window_days ?? 90} day window` }),
91
99
  h(Row, { label: "endpoint", value: endpoint }),
92
100
  h(Row, { label: "key", value: keyPrefix }),
93
- h(Text, { color: C.FG_FAINT }, `config: ${configFilePath}`),
101
+ h(Row, { label: "config", value: configFilePath, color: C.FG_FAINT }),
94
102
  ),
95
103
  h(Text, {}, ""),
96
- isFree
97
- ? h(
98
- Box,
99
- { flexDirection: "column" },
100
- h(Text, { color: "#d97706" }, `Free plan · up to ${FREE_LIMITS.projects} project, ${FREE_LIMITS.usages} usages, email+in-app alerts.`),
101
- h(Text, { color: C.FG_DIM }, "Press u to upgrade to Pro ($5/yr): unlimited usages + Slack/Discord/SMS/webhook alerts."),
102
- )
103
- : h(Text, { color: "#16a34a" }, `${GLYPH.check} Pro features unlocked. Thanks for supporting LLM Status!`),
104
+ unknown
105
+ ? h(Text, {}, h(Text, { color: "#dc2626" }, `${GLYPH.cross} couldn't load account`), h(Text, { color: C.FG_DIM }, " · g retries"))
106
+ : isFree
107
+ ? h(
108
+ Box,
109
+ { flexDirection: "column" },
110
+ h(Text, { color: "#d97706" }, `Free plan · up to ${FREE_LIMITS.projects} project, ${FREE_LIMITS.usages} usages, email+in-app alerts.`),
111
+ h(Text, { color: C.FG_DIM }, "Press u to upgrade to Pro ($5/yr): unlimited usages + Slack/Discord/SMS/webhook alerts."),
112
+ )
113
+ : h(Text, { color: "#16a34a" }, `${GLYPH.check} Pro features unlocked. Thanks for supporting LLM Status!`),
104
114
  status ? h(Text, { color: C.ACCENT, marginTop: 1 }, status) : null,
105
115
  );
106
116
  }
@@ -2,7 +2,9 @@ import React from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
3
  import { h, C, GLYPH, useAsync, clampCursor } from "../ui.js";
4
4
 
5
- const ENV_ORDER = ["prod", "staging", "dev", "unknown"];
5
+ // No "unknown" here: on a MANUAL add the user knows the env — the unknown
6
+ // bucket is something the scanner infers, never a deliberate choice.
7
+ const ENV_ORDER = ["prod", "staging", "dev"];
6
8
 
7
9
  export const meta = {
8
10
  keys: [
@@ -67,9 +69,12 @@ export function AddView({ client, ui, active }) {
67
69
  (input, key) => {
68
70
  if (!active || busy) return;
69
71
  if (input === "m") return ui.askPrompt("Model string", { initial: modelStr, onSubmit: setModel });
70
- if (input === "p") return setProjectIdx((i) => (projects.length ? (i + 1) % projects.length : 0));
72
+ // With no projects loaded (offline / failed fetch), p retries the fetch
73
+ // instead of being a silent no-op.
74
+ if (input === "p") return projects.length ? setProjectIdx((i) => (i + 1) % projects.length) : projQ.reload();
71
75
  if (input === "e") return setEnvIdx((i) => (i + 1) % ENV_ORDER.length);
72
- if (key.return || input === "s") return submit();
76
+ // enter is the one advertised save key no undocumented "s" alias to trip on.
77
+ if (key.return) return submit();
73
78
  },
74
79
  { isActive: active },
75
80
  );
@@ -98,9 +103,13 @@ export function AddView({ client, ui, active }) {
98
103
  h(Text, {}, ""),
99
104
  field("model ", modelStr, "(press m to type one)"),
100
105
  resolveLine,
101
- field("project", project ? project.name : "", "(new: Manual)"),
106
+ field("project", project ? project.name : "", '(p to pick · else saves to a new "Manual" project)'),
107
+ projQ.error
108
+ ? h(Text, { color: "#dc2626" }, ` ${GLYPH.cross} couldn't load projects — ${projQ.error}`, h(Text, { color: C.FG_DIM }, " · p retries"))
109
+ : null,
102
110
  field("env ", ENV_ORDER[envIdx], ""),
103
111
  h(Text, {}, ""),
104
- h(Text, { color: C.FG_FAINT }, "m model · p project · e env · ↵ save (auto-matched to the registry, else tracked as custom)"),
112
+ // Keys live in the keybar below this line carries only what the keybar can't.
113
+ h(Text, { color: C.FG_FAINT }, "models are auto-matched to the registry; unmatched names are tracked as custom"),
105
114
  );
106
115
  }
@@ -27,8 +27,8 @@ const KIND_COLOR = {
27
27
  // follows the active sub-tab (published via ui.setKeys) rather than advertising
28
28
  // Rules keys while you're on Channels.
29
29
  const ALERTS_TAB_KEYS = [
30
- [{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "n", label: "new rule" }, { k: "space", label: "enable" }, { k: "c", label: "cadence" }, { k: "d", label: "delete" }, { k: "g", label: "refresh" }],
31
- [{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "n", label: "add channel" }, { k: "t", label: "test" }, { k: "g", label: "refresh" }],
30
+ [{ k: "↑↓", label: "nav" }, { k: "n", label: "new rule" }, { k: "space", label: "toggle" }, { k: "g", label: "refresh" }, { k: "c", label: "cadence" }, { k: "d", label: "delete" }],
31
+ [{ k: "↑↓", label: "nav" }, { k: "n", label: "add channel" }, { k: "t", label: "test" }, { k: "g", label: "refresh" }],
32
32
  ];
33
33
 
34
34
  export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
@@ -49,8 +49,8 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
49
49
  const tick = useTick(80, loading);
50
50
 
51
51
  React.useEffect(
52
- () => ui?.reportStatus?.({ context: `${ruleList.length} rules · ${chanList.length} chan` }),
53
- [ruleList, chanList, ui],
52
+ () => ui?.reportStatus?.({ context: rules.error || channels.error ? "offline" : `${ruleList.length} rules · ${chanList.length} chan` }),
53
+ [ruleList, chanList, rules.error, channels.error, ui],
54
54
  );
55
55
 
56
56
  async function newRule() {
@@ -85,7 +85,7 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
85
85
  ui.showToast(`${k} channel added`);
86
86
  channels.reload();
87
87
  } catch (e) {
88
- if (e.status === 402) ui.showToast("Channels are a Pro feature — press 6 → u to upgrade", "yellow");
88
+ if (e.status === 402) ui.showToast("Channels are a Pro feature — press 7 → u to upgrade", "yellow");
89
89
  else ui.showToast(e.message, "red");
90
90
  }
91
91
  },
@@ -145,7 +145,7 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
145
145
  body = h(StateLine, { kind: "loading", spin: SPINNER[tick % SPINNER.length], text: tab === 0 ? "loading rules…" : "loading channels…" });
146
146
  } else if (err) {
147
147
  // Don't let a network/API error masquerade as "no rules" (a false empty).
148
- body = h(StateLine, { kind: "error", text: `couldn't load ${tab === 0 ? "rules" : "channels"} — ${err}. Press g to retry.` });
148
+ body = h(StateLine, { kind: "error", text: `couldn't load ${tab === 0 ? "rules" : "channels"} — ${err}`, hint: "g retries" });
149
149
  } else if (tab === 0) {
150
150
  if (!ruleList.length) {
151
151
  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 });
@@ -179,7 +179,7 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
179
179
  Box,
180
180
  { flexDirection: "column" },
181
181
  h(EmptyCard, { title: "No channels", lines: ["Press n to add one (Pro)."], width }),
182
- h(Text, { color: "#d97706" }, " Slack/Discord/SMS/webhook channels need Pro — press 6 → u to upgrade."),
182
+ h(Text, { color: "#d97706" }, " Slack/Discord/SMS/webhook channels need Pro — press 7 → u to upgrade."),
183
183
  );
184
184
  } else {
185
185
  const curIdx = clampCursor(cursor, chanList.length);
@@ -200,7 +200,7 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
200
200
  }
201
201
 
202
202
  const hint = tab === 0
203
- ? " Rules watch the registry deprecations · retirements · replacements · new models — and alert your channels."
203
+ ? " Rules watch the registry for lifecycle changes and alert your channels."
204
204
  : " Channels deliver alerts: Slack/Discord/SMS/webhook are Pro; in-app + email are always on.";
205
205
  return h(
206
206
  Box,
@@ -216,7 +216,7 @@ export const meta = {
216
216
  { k: "←→", label: "tab" },
217
217
  { k: "↑↓", label: "nav" },
218
218
  { k: "n", label: "new" },
219
- { k: "space", label: "enable" },
219
+ { k: "space", label: "toggle" },
220
220
  { k: "c", label: "cadence" },
221
221
  { k: "t", label: "test" },
222
222
  { k: "d", label: "delete" },
@@ -156,13 +156,13 @@ export function IntegrationsView({ client, me, ui, active, width = 78, height =
156
156
  // Column budget: rail(1) + toggle(2) + label + gap(1) + kind(10) + status(rest) + env(8).
157
157
  const ENV_W = 8;
158
158
  const KIND_W = 10;
159
- const LABEL_W = 30;
159
+ const LABEL_W = 35;
160
160
  const restW = Math.max(8, width - 1 - 2 - LABEL_W - 1 - KIND_W - ENV_W);
161
161
 
162
162
  // Status string per row: not-installed › off/available › on › on·authorized / auth failed.
163
163
  function statusFor(r) {
164
164
  if (!r.hasCmd) return { text: `${r.meta.requiresCmd} not installed`, color: C.FG_FAINT };
165
- if (r.probe && !r.probe.connected) return { text: "auth failed", color: "#dc2626" };
165
+ if (r.probe && !r.probe.connected) return { text: `auth failed${r.probe.reason ? ` · ${r.probe.reason}` : ""}`, color: "#dc2626" };
166
166
  if (r.probe && r.probe.connected) {
167
167
  const who = r.probe.account ? ` · ${r.probe.account}` : "";
168
168
  return { text: `authorized${who}`, color: "#16a34a" };
@@ -190,7 +190,7 @@ export function IntegrationsView({ client, me, ui, active, width = 78, height =
190
190
  const isCur = i === curIdx;
191
191
  const st = statusFor(r);
192
192
  const spin = probing === r.id ? `${SPINNER[tick % SPINNER.length]} ` : "";
193
- const et = envTagSeg(r.env);
193
+ const et = r.env === "unknown" ? { text: "env ?", color: C.FG_FAINT } : envTagSeg(r.env);
194
194
  const cells = [
195
195
  { text: `${r.enabled ? GLYPH.check : GLYPH.dot} `, color: r.enabled ? C.ACCENT : C.FG_FAINT },
196
196
  { text: cellE(r.meta.label, LABEL_W), color: isCur ? C.FG_STRONG : r.enabled ? C.FG : C.FG_FAINT, bold: isCur },
@@ -204,7 +204,7 @@ export function IntegrationsView({ client, me, ui, active, width = 78, height =
204
204
  );
205
205
  }
206
206
 
207
- const hint = " Toggle which live deployments scan by default · space on/off · t authorize · e env. Secrets stay on your machine.";
207
+ const hint = " Toggle which live deployments mm scan checks by default. Secrets stay on your machine.";
208
208
  return h(
209
209
  Box,
210
210
  { flexDirection: "column" },
@@ -218,7 +218,7 @@ export const meta = {
218
218
  { k: "↑↓", label: "nav" },
219
219
  { k: "space", label: "toggle" },
220
220
  { k: "e", label: "env" },
221
- { k: "t", label: "test" },
221
+ { k: "t", label: "test auth" },
222
222
  { k: "g", label: "refresh" },
223
223
  ],
224
224
  };
@@ -14,18 +14,21 @@ import { sourceOf, SOURCE_META, sourceGlyph } from "../source-meta.js";
14
14
 
15
15
  const ENV_ORDER = ["prod", "staging", "dev", "unknown"];
16
16
 
17
+ // Ordered by survival priority — KeyBar truncates the tail on narrow terminals,
18
+ // so recovery (g) and the populate verbs (r/n) must outlive the per-row
19
+ // secondaries, and destructive keys come last.
17
20
  export const meta = {
18
21
  keys: [
19
22
  { k: "↑↓", label: "nav" },
20
23
  { k: "/", label: "search" },
24
+ { k: "g", label: "refresh" },
25
+ { k: "r", label: "rescan" },
26
+ { k: "n", label: "new" },
21
27
  { k: "e", label: "env" },
22
- { k: "t", label: "tag untagged" },
28
+ { k: "t", label: "tag" },
23
29
  { k: "c", label: "critical" },
24
30
  { k: "d", label: "delete" },
25
31
  { k: "C", label: "clear all" },
26
- { k: "r", label: "rescan" },
27
- { k: "n", label: "new" },
28
- { k: "g", label: "refresh" },
29
32
  ],
30
33
  };
31
34
 
@@ -84,12 +87,18 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
84
87
  return abs ? readSnippet(abs, cur.source_line, matchStr) : null;
85
88
  }, [cur?.source_path, cur?.source_line, matchStr, dir]); // eslint-disable-line react-hooks/exhaustive-deps
86
89
 
87
- // Push health legend + tracked context up to the shell status bar.
90
+ // Push health legend + tracked context up to the shell status bar. While the
91
+ // fetch is failed/loading the truth is UNKNOWN — suppress the zeroed counts
92
+ // and say so, rather than asserting "0 tracked".
88
93
  React.useEffect(() => {
94
+ if (q.error || q.loading) {
95
+ ui?.reportStatus?.({ context: q.error ? "offline" : "loading…" });
96
+ return;
97
+ }
89
98
  const counts = { ok: 0, deprecating: 0, retiring: 0, retired: 0, custom: 0 };
90
99
  for (const u of usages) if (counts[u.health] != null) counts[u.health] += 1;
91
100
  ui?.reportStatus?.({ counts, context: query ? `${filtered.length} of ${usages.length}` : `${usages.length} tracked` });
92
- }, [usages, ui, query, filtered.length]);
101
+ }, [usages, ui, query, filtered.length, q.error, q.loading]);
93
102
 
94
103
  async function patch(u, body, label) {
95
104
  try {
@@ -161,28 +170,39 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
161
170
  return patch(cur, { environment: ENV_ORDER[(i + 1) % ENV_ORDER.length] }, "env updated");
162
171
  }
163
172
  if (input === "c") return patch(cur, { is_critical: !cur.is_critical }, "critical toggled");
164
- if (input === "d")
165
- return client
166
- .deleteUsage(cur.id)
167
- .then(() => {
168
- ui.showToast("deleted");
169
- setCursor((c) => clampCursor(c, filtered.length - 1));
170
- q.reload();
171
- })
172
- .catch((e) => ui.showToast(e.message, "red"));
173
+ if (input === "d") {
174
+ // One stray keypress shouldn't silently delete a cloud-synced usage —
175
+ // confirm like C clear-all does (proportionally lighter: a single y).
176
+ const name = cur.model_display || cur.custom_model_name || "this usage";
177
+ return ui.askPrompt(`Delete ${name}? type y`, {
178
+ onSubmit: (v) => {
179
+ if (String(v || "").trim().toLowerCase() !== "y") return ui.showToast("delete cancelled");
180
+ client
181
+ .deleteUsage(cur.id)
182
+ .then(() => {
183
+ ui.showToast("deleted");
184
+ setCursor((c) => clampCursor(c, filtered.length - 1));
185
+ q.reload();
186
+ })
187
+ .catch((e) => ui.showToast(e.message, "red"));
188
+ },
189
+ });
190
+ }
173
191
  },
174
192
  { isActive: active },
175
193
  );
176
194
 
177
195
  if (q.loading) return h(StateLine, { kind: "loading", spin, text: "loading inventory…" });
178
- if (q.error) return h(StateLine, { kind: "error", text: q.error });
196
+ if (q.error) return h(StateLine, { kind: "error", text: `couldn't load inventory — ${q.error}`, hint: "g retries" });
179
197
  if (!usages.length)
180
198
  return h(EmptyCard, {
181
199
  icon: GLYPH.spark,
182
200
  title: "Let's find your AI models",
183
201
  lines: [
184
- "Press 4 Scan to auto-detect every model used in this repo.",
185
- "Or press 5 Add to enter one by name takes about 30 seconds.",
202
+ // Name this tab's OWN keys (r/n in the keybar below), and be honest that
203
+ // the cloud inventory fills from the Scan tab's upload, not the scan alone.
204
+ "Press r to scan this repo, then u on the Scan tab to upload what it finds.",
205
+ "Or press n to add one by name — takes about 30 seconds.",
186
206
  ],
187
207
  width,
188
208
  });
@@ -19,15 +19,19 @@ import { readSnippet } from "../snippet.js";
19
19
  import { openLocation } from "../../openUrl.js";
20
20
  import { addGlobalIgnore } from "../../sources/filesystem.js";
21
21
 
22
+ // Ordered by survival priority — KeyBar drops trailing entries on narrow
23
+ // terminals, so the primary verb (u push) and recovery (g rescan) must come
24
+ // before the secondaries. "p pause" lives in the in-strip hint while a scan
25
+ // runs (the only time it works), never here.
22
26
  export const meta = {
23
27
  keys: [
24
- { k: "↑↓", label: "scroll" },
28
+ { k: "↑↓", label: "nav" },
25
29
  { k: "↵", label: "refs" },
26
- { k: "e", label: "exclude" },
27
- { k: "/", label: "search" },
28
- { k: "p", label: "pause" },
30
+ { k: "f", label: "fix → repl" },
31
+ { k: "u", label: "push → Inv" },
29
32
  { k: "g", label: "rescan" },
30
- { k: "u", label: "push all → inventory" },
33
+ { k: "/", label: "search" },
34
+ { k: "e", label: "exclude" },
31
35
  ],
32
36
  };
33
37
 
@@ -76,6 +80,33 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
76
80
  setPushing(false);
77
81
  }
78
82
  }
83
+ /** Rewrite refs of the focused model to its registry replacement (in place,
84
+ * boundary-safe, y-confirmed). One ref from the drill-in, or all of them. */
85
+ function fixRefs(refs, what) {
86
+ const repl = cur?.model?.replacement_slug;
87
+ if (!cur?.model) return ui?.showToast?.("custom model — no registry replacement known", "#d97706");
88
+ if (cur.health === "ok") return ui?.showToast?.(`${cur.slug} is current — nothing to fix`, "#d97706");
89
+ if (!repl) return ui?.showToast?.("no replacement in the registry yet", "#d97706");
90
+ import("../../fix.js").then(({ planFixes, applyFixes }) => {
91
+ const plans = planFixes(refs, repl);
92
+ if (!plans.length) return ui?.showToast?.("no rewritable file refs (integration-sourced?)", "#d97706");
93
+ ui?.askPrompt?.(`Rewrite ${what} → ${repl}? type y`, {
94
+ onSubmit: (v) => {
95
+ if (String(v || "").trim().toLowerCase() !== "y") return ui?.showToast?.("fix cancelled");
96
+ const res = applyFixes(dir, plans);
97
+ const files = new Set(res.applied.map((p) => p.file)).size;
98
+ if (res.applied.length) {
99
+ 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` : ""}`);
100
+ justReloadedRef.current = true;
101
+ scan.reload(); // re-scan so the list reflects the rewritten files
102
+ } else {
103
+ ui?.showToast?.(res.stale.length ? "files changed since the scan — press g to rescan first" : "nothing rewritten", "#dc2626");
104
+ }
105
+ },
106
+ });
107
+ });
108
+ }
109
+
79
110
  const tick = useTick(80, running || pushing);
80
111
  const spin = SPINNER[tick % SPINNER.length];
81
112
  const search = useSearch();
@@ -169,6 +200,16 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
169
200
  return () => setHandlesBack?.(false);
170
201
  }, [setHandlesBack, focus, search.query]);
171
202
 
203
+ // The refs drill-in swaps the keymap (↵ opens in editor, esc backs out) — the
204
+ // keybar must say so instead of advertising the dead list keys.
205
+ const setKeys = ui?.setKeys;
206
+ React.useEffect(() => {
207
+ setKeys?.(focus === "refs"
208
+ ? [{ k: "↑↓", label: "nav" }, { k: "↵", label: "open in editor" }, { k: "f", label: "fix ref" }, { k: "e", label: "exclude path" }, { k: "esc", label: "back" }]
209
+ : null);
210
+ return () => setKeys?.(null);
211
+ }, [setKeys, focus]);
212
+
172
213
  useInput(
173
214
  (input, key) => {
174
215
  if (!active) return;
@@ -187,6 +228,7 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
187
228
  if (key.upArrow || input === "k") { refIdxRef.current = clampCursor(refIdxRef.current - 1, drefs.length); return setRefIdx(refIdxRef.current); }
188
229
  if (key.return || input === "o") return openRef(drefs[clampCursor(refIdxRef.current, drefs.length)]);
189
230
  if (input === "e") return excludeRef(drefs[clampCursor(refIdxRef.current, drefs.length)]);
231
+ if (input === "f") return fixRefs([drefs[clampCursor(refIdxRef.current, drefs.length)]], "this reference");
190
232
  return;
191
233
  }
192
234
  if (typeof input === "string" && input.startsWith("/")) {
@@ -204,6 +246,7 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
204
246
  if (key.downArrow || input === "j") return nav.down();
205
247
  if (key.upArrow || input === "k") return nav.up();
206
248
  if (input === "e") return excludeRef(drefs[0]); // exclude the highlighted model's location (editable)
249
+ if (input === "f") return fixRefs(cur?.refs || [], `all ${cur?.count ?? 0} references`);
207
250
  if (input === "p" && running) return scan.togglePause();
208
251
  if (input === "g") { justReloadedRef.current = true; return scan.reload(); }
209
252
  if (input === "u" && !pushing) return pushToInventory();
@@ -211,7 +254,7 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
211
254
  { isActive: active },
212
255
  );
213
256
 
214
- if (phase === "error") return h(StateLine, { kind: "error", text: scan.error });
257
+ if (phase === "error") return h(StateLine, { kind: "error", text: scan.error, hint: "g rescans" });
215
258
 
216
259
  // ----- strip (1 line) -----
217
260
  const counters = `${fmtNum(filesScanned)} files · ${fmtNum(dirsSeen)} dirs · ${fmtNum(candidateCount)} refs${catalogsSkipped ? ` · ${catalogsSkipped} catalog${catalogsSkipped === 1 ? "" : "s"} skipped` : ""}`;
@@ -224,14 +267,15 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
224
267
  else if (phase === "scanning")
225
268
  strip = h(Box, {}, h(Text, { color: C.ACCENT }, ` ${spin} `), h(SweepBar, { tick }), h(Text, { color: C.FG }, ` ${counters}`), h(Text, { color: C.FG_FAINT }, " · p pause"));
226
269
  else
270
+ // Lead with the verdict (what needs attention), then the scan housekeeping —
271
+ // the warning is the one thing the user came for; stats are context.
227
272
  strip = h(
228
273
  Box,
229
274
  {},
230
- h(Text, { color: "#16a34a" }, ` ${GLYPH.check} `),
231
- h(Text, { color: C.FG_DIM }, fromCache ? `loaded last scan${scannedAt ? ` (${agoText(Date.now() - scannedAt)})` : ""} · ${fmtNum(candidateCount)} refs · g rescan` : `scanned just now · ${counters} · g rescan`),
232
275
  attention > 0
233
- ? h(Text, { color: "#d97706" }, ` ${GLYPH.warn} ${attention} need attention`)
234
- : h(Text, { color: "#16a34a" }, " all current"),
276
+ ? h(Text, { color: "#d97706" }, ` ${GLYPH.warn} ${attention} need attention`)
277
+ : h(Text, { color: "#16a34a" }, ` ${GLYPH.check} all current`),
278
+ h(Text, { color: C.FG_DIM }, fromCache ? ` scanned ${scannedAt ? agoText(Date.now() - scannedAt) : "earlier"} · ${fmtNum(candidateCount)} refs · g rescan` : ` scanned just now · ${counters} · g rescan`),
235
279
  );
236
280
 
237
281
  // ----- list rows (full width) -----
@@ -36,15 +36,18 @@ import { track } from "../../telemetry.js";
36
36
  import { openUrl, openLocation } from "../../openUrl.js";
37
37
  import { boardSize, MIN_W, MIN_H } from "../game/dk-core.js";
38
38
 
39
+ // Ordered by survival priority — KeyBar truncates the tail on narrow terminals,
40
+ // and `u upload` is this tab's entire purpose: it must never be the key that
41
+ // gets hidden behind the "…".
39
42
  export const meta = {
40
43
  keys: [
41
44
  { k: "↑↓", label: "nav" },
42
45
  { k: "space", label: "toggle" },
46
+ { k: "u", label: "upload" },
43
47
  { k: "a/x", label: "all/none" },
44
48
  { k: "↵", label: "refs" },
45
49
  { k: "/", label: "search" },
46
50
  { k: "g", label: "rescan" },
47
- { k: "u", label: "upload all" },
48
51
  { k: "P", label: "play 🦍" }, // launches anytime (also the global Shift-P)
49
52
  { k: "N", label: "new project" },
50
53
  ],
@@ -162,7 +165,10 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
162
165
 
163
166
  React.useEffect(() => {
164
167
  const counts = countHealth(items);
165
- const ctx = `${project ? project.name + " · " : ""}${selCount}/${items.length} sel`;
168
+ // "p <project>" self-documents the otherwise-invisible p key (cycles the
169
+ // upload target) — important because the adjacent Here tab used to teach
170
+ // people that p means pause.
171
+ const ctx = `p ${project ? project.name : "auto"} · ${selCount}/${items.length} sel`;
166
172
  ui?.reportStatus?.({ counts, context: ctx });
167
173
  }, [items, selCount, project, ui]);
168
174
 
@@ -174,6 +180,16 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
174
180
  return () => setHandlesBack?.(false);
175
181
  }, [setHandlesBack, focus, search.query]);
176
182
 
183
+ // The refs drill-in swaps the keymap — advertise the real keys, not the dead
184
+ // list ones (same pattern as the Here tab).
185
+ const setKeys = ui?.setKeys;
186
+ React.useEffect(() => {
187
+ setKeys?.(focus === "refs"
188
+ ? [{ k: "↑↓", label: "nav" }, { k: "↵", label: "open in editor" }, { k: "e", label: "exclude path" }, { k: "esc", label: "back" }]
189
+ : null);
190
+ return () => setKeys?.(null);
191
+ }, [setKeys, focus]);
192
+
177
193
  function openRef(r) {
178
194
  if (!r) return;
179
195
  const abs = path.resolve(dir, r.source_path || r.location_label || "");
@@ -285,7 +301,7 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
285
301
  { isActive: active },
286
302
  );
287
303
 
288
- if (scan.phase === "error") return h(StateLine, { kind: "error", text: scan.error });
304
+ if (scan.phase === "error") return h(StateLine, { kind: "error", text: scan.error, hint: "g rescans" });
289
305
  if (running && !items.length)
290
306
  return h(
291
307
  Box,
@@ -13,24 +13,19 @@ import { loadConfig, setConfigValue } from "../../config.js";
13
13
 
14
14
  const TABS = ["Registry", "Alerts", "Drift"];
15
15
 
16
- export const meta = {
17
- keys: [
18
- { k: "←→", label: "section" },
19
- { k: "↑↓", label: "scroll" },
20
- { k: "m", label: "mark seen" },
21
- { k: "r", label: "drift" },
22
- ],
23
- };
24
-
25
16
  // Per-section keybars — the active actions differ by tab (Registry: mark seen ·
26
17
  // Notifications: mark read · Drift: rescan + archive). Published via ui.setKeys()
27
18
  // so the keybar never advertises a key that's dead on the current section.
28
19
  const TAB_KEYS = [
29
- [{ k: "←→", label: "section" }, { k: "↑↓", label: "scroll" }, { k: "m", label: "mark all seen" }],
30
- [{ k: "←→", label: "section" }, { k: "↑↓", label: "scroll" }, { k: "o", label: "mark read" }],
31
- [{ k: "←→", label: "section" }, { k: "↑↓", label: "scroll" }, { k: "r", label: "rescan" }, { k: "a", label: "archive" }],
20
+ [{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "m", label: "mark all seen" }, { k: "g", label: "refresh" }],
21
+ [{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "o", label: "mark read" }, { k: "g", label: "refresh" }],
22
+ [{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "r", label: "rescan" }, { k: "a", label: "archive" }],
32
23
  ];
33
24
 
25
+ // The static fallback must match the default (Registry) section exactly —
26
+ // advertising `m`/`r` together implies both work everywhere, and they don't.
27
+ export const meta = { keys: TAB_KEYS[0] };
28
+
34
29
  export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14 }) {
35
30
  const [tab, setTab] = React.useState(0);
36
31
  const [cursor, setCursor] = React.useState(0);
@@ -93,6 +88,13 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
93
88
  if (key.rightArrow) return (setTab((t) => (t + 1) % TABS.length), setCursor(0));
94
89
  if (key.downArrow || input === "j") return setCursor((c) => c + 1);
95
90
  if (key.upArrow || input === "k") return setCursor((c) => Math.max(0, c - 1));
91
+ // g refreshes whichever section is showing — the universal retry across tabs
92
+ // (and the escape hatch when a fetch fails).
93
+ if (input === "g") {
94
+ if (tab === 0) return reg.reload();
95
+ if (tab === 1) return notif.reload();
96
+ return runDrift();
97
+ }
96
98
 
97
99
  if (tab === 0) {
98
100
  if (input === "m") {
@@ -110,7 +112,8 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
110
112
  } else if (tab === 2) {
111
113
  if (input === "r") return runDrift();
112
114
  const gone = drift?.gone || [];
113
- const cur = gone[clampCursor(cursor, gone.length)];
115
+ // Same visible-rows clamp as the render — archive only what's on screen.
116
+ const cur = gone[clampCursor(cursor, Math.min(gone.length, 7))];
114
117
  if (input === "a" && cur)
115
118
  client.deleteUsage(cur.id).then(() => {
116
119
  ui.showToast("archived");
@@ -123,17 +126,19 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
123
126
 
124
127
  // Push per-tab context up to the shell status bar.
125
128
  React.useEffect(() => {
129
+ // On a failed fetch the count is UNKNOWN, not zero — say "offline", don't
130
+ // assert "0 changes".
126
131
  let context;
127
- if (tab === 0) context = `${reg.data?.events.length || 0} changes`;
128
- else if (tab === 1) context = `${(notif.data || []).length} alerts`;
132
+ if (tab === 0) context = reg.error ? "offline" : `${reg.data?.events.length || 0} changes`;
133
+ else if (tab === 1) context = notif.error ? "offline" : `${(notif.data || []).length} alerts`;
129
134
  else context = drift && !drift.loading && !drift.error ? `+${drift.added.length} / -${drift.gone.length}` : "drift";
130
135
  ui?.reportStatus?.({ context });
131
- }, [tab, reg.data, notif.data, drift, ui]);
136
+ }, [tab, reg.data, reg.error, notif.data, notif.error, drift, ui]);
132
137
 
133
138
  let body;
134
139
  if (tab === 0) {
135
140
  if (reg.loading) body = h(StateLine, { kind: "loading", spin, text: "loading registry changes…" });
136
- else if (reg.error) body = h(StateLine, { kind: "error", text: reg.error });
141
+ else if (reg.error) body = h(StateLine, { kind: "error", text: `couldn't load registry changes — ${reg.error}`, hint: "g retries" });
137
142
  else {
138
143
  const events = reg.data.events;
139
144
  if (!events.length) body = h(Text, { color: C.FG_DIM }, " No registry changes recorded yet.");
@@ -161,31 +166,37 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
161
166
  } else if (tab === 1) {
162
167
  const list = notif.data || [];
163
168
  if (notif.loading) body = h(StateLine, { kind: "loading", spin, text: "loading alerts…" });
164
- else if (notif.error) body = h(StateLine, { kind: "error", text: notif.error });
165
- else if (!list.length) body = h(Text, { color: C.FG_DIM }, " No alerts yet. Configure rules in the Alerts view.");
169
+ else if (notif.error) body = h(StateLine, { kind: "error", text: `couldn't load alerts — ${notif.error}`, hint: "g retries" });
170
+ else if (!list.length) body = h(Text, { color: C.FG_DIM }, " No alerts yet. Press 6 to set up alert rules.");
166
171
  else {
172
+ // Window the list around the cursor so ↑↓ can reach every row — a fixed
173
+ // slice(0, ROWS) lets the selection walk below the visible page and `o`
174
+ // act on a row the user can't see.
167
175
  const cur = clampCursor(cursor, list.length);
176
+ const start = Math.max(0, Math.min(cur - ROWS + 1, list.length - ROWS));
168
177
  body = h(
169
178
  Box,
170
179
  { flexDirection: "column" },
171
- ...list.slice(0, ROWS).map((n, i) => {
180
+ ...list.slice(start, start + ROWS).map((n, i) => {
172
181
  const cells = [
173
182
  { text: `${n.unread ? GLYPH.bullet : GLYPH.custom} `, color: n.unread ? C.ACCENT : C.FG_FAINT },
174
183
  { text: cellE(n.title, 40), color: n.unread ? C.FG : C.FG_DIM },
175
184
  { text: " ", color: C.FG },
176
185
  { text: String(n.when || "").slice(0, 10).padEnd(10), color: C.FG_DIM },
177
186
  ];
178
- return h(ListRow, { key: n.id, active: i === cur, cells, width });
187
+ return h(ListRow, { key: n.id, active: start + i === cur, cells, width });
179
188
  }),
180
189
  );
181
190
  }
182
191
  } else {
183
192
  if (!drift) body = h(Text, { color: C.FG_DIM }, ` Press r to scan ${dir} and compare against tracked usages.`);
184
193
  else if (drift.loading) body = h(StateLine, { kind: "loading", spin, text: "scanning for drift…" });
185
- else if (drift.error) body = h(StateLine, { kind: "error", text: drift.error });
194
+ else if (drift.error) body = h(StateLine, { kind: "error", text: `drift scan failed — ${drift.error}`, hint: "r rescans" });
186
195
  else {
187
196
  const gone = drift.gone || [];
188
- const curGone = clampCursor(cursor, gone.length);
197
+ // Clamp the archive cursor to the VISIBLE rows — `a` must never act on a
198
+ // row below the 7 shown.
199
+ const curGone = clampCursor(cursor, Math.min(gone.length, 7));
189
200
  body = h(
190
201
  Box,
191
202
  { flexDirection: "column" },
@@ -206,6 +217,7 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
206
217
  ];
207
218
  return h(ListRow, { key: "a" + i, active: false, cells, width });
208
219
  }),
220
+ drift.added.length > 5 ? h(Text, { color: C.FG_DIM }, ` … ${drift.added.length - 5} more new`) : null,
209
221
  ...gone.slice(0, 7).map((u, i) => {
210
222
  const cells = [
211
223
  { text: "- ", color: "#dc2626" },
@@ -215,6 +227,7 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
215
227
  ];
216
228
  return h(ListRow, { key: "g" + i, active: i === curGone, cells, width });
217
229
  }),
230
+ gone.length > 7 ? h(Text, { color: C.FG_DIM }, ` … ${gone.length - 7} more gone`) : null,
218
231
  );
219
232
  }
220
233
  }