@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 +1 -1
- package/src/fix.js +127 -0
- package/src/index.js +65 -1
- package/src/tui/ui.js +15 -4
- package/src/tui/views/account.js +24 -14
- package/src/tui/views/add.js +14 -5
- package/src/tui/views/alerts.js +9 -9
- package/src/tui/views/integrations.js +5 -5
- package/src/tui/views/inventory.js +38 -18
- package/src/tui/views/local.js +54 -10
- package/src/tui/views/scan.js +19 -3
- package/src/tui/views/whatsnew.js +36 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modelstatus/cli",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
|
|
163
|
-
|
|
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")
|
|
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
|
}
|
package/src/tui/views/account.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
73
|
-
? h(Text, { color: C.FG_FAINT }, "
|
|
74
|
-
:
|
|
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(
|
|
101
|
+
h(Row, { label: "config", value: configFilePath, color: C.FG_FAINT }),
|
|
94
102
|
),
|
|
95
103
|
h(Text, {}, ""),
|
|
96
|
-
|
|
97
|
-
? h(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
}
|
package/src/tui/views/add.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 : "",
|
|
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
|
-
|
|
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
|
}
|
package/src/tui/views/alerts.js
CHANGED
|
@@ -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: "
|
|
31
|
-
[{ k: "
|
|
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
|
|
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}
|
|
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
|
|
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
|
|
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: "
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
});
|
package/src/tui/views/local.js
CHANGED
|
@@ -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: "
|
|
28
|
+
{ k: "↑↓", label: "nav" },
|
|
25
29
|
{ k: "↵", label: "refs" },
|
|
26
|
-
{ k: "
|
|
27
|
-
{ k: "
|
|
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: "
|
|
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" }, `
|
|
234
|
-
: h(Text, { color: "#16a34a" },
|
|
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) -----
|
package/src/tui/views/scan.js
CHANGED
|
@@ -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
|
-
|
|
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: "
|
|
30
|
-
[{ k: "←→", label: "section" }, { k: "↑↓", label: "
|
|
31
|
-
[{ k: "←→", label: "section" }, { k: "↑↓", label: "
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
}
|