@modelstatus/cli 0.1.0 → 0.1.25
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/api.js +3 -0
- package/src/ci.js +143 -0
- package/src/detect/core.js +26 -1
- package/src/index.js +200 -10
- package/src/openUrl.js +43 -1
- package/src/registry/local.js +23 -4
- package/src/sources/filesystem.js +0 -0
- package/src/telemetry.js +66 -0
- package/src/tui/app.js +173 -91
- package/src/tui/scan-stream.js +234 -0
- package/src/tui/signin.js +142 -0
- package/src/tui/snippet.js +127 -0
- package/src/tui/ui.js +661 -16
- package/src/tui/views/account.js +43 -13
- package/src/tui/views/add.js +33 -11
- package/src/tui/views/alerts.js +91 -39
- package/src/tui/views/inventory.js +149 -47
- package/src/tui/views/local.js +229 -0
- package/src/tui/views/scan.js +231 -72
- package/src/tui/views/whatsnew.js +92 -50
- package/src/updater.js +170 -0
- package/src/version.js +32 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/* Free local-repo view — the TUI's default front door, no sign-in required.
|
|
2
|
+
* Streams a cooperative filesystem scan (engine in scan-stream.js) and keeps the
|
|
3
|
+
* result cached, so returning to this tab does NOT rescan (press g to force one).
|
|
4
|
+
* List is scrollable (j/k/↑↓), filterable (/), with a full-width detail panel for
|
|
5
|
+
* the highlighted row; ↵ drills into that model's usage locations and ↵ opens the
|
|
6
|
+
* selected one in your editor. Body-only; the shell (app.js) owns chrome. */
|
|
7
|
+
import React from "react";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { Box, Text, useInput } from "ink";
|
|
11
|
+
import {
|
|
12
|
+
h, C, GLYPH, healthColor, healthGlyph, relativeTime, cellE, rpad, fmtNum, clampCursor,
|
|
13
|
+
SPINNER, SweepBar, ListRow, StateLine, ModelDetailBar, SearchBar, distinctRefs,
|
|
14
|
+
useTick, useCursorList, useSearch,
|
|
15
|
+
} from "../ui.js";
|
|
16
|
+
import { useStreamingScan, countHealth } from "../scan-stream.js";
|
|
17
|
+
import { readSnippet } from "../snippet.js";
|
|
18
|
+
import { openLocation } from "../../openUrl.js";
|
|
19
|
+
import { addGlobalIgnore } from "../../sources/filesystem.js";
|
|
20
|
+
|
|
21
|
+
export const meta = {
|
|
22
|
+
keys: [
|
|
23
|
+
{ k: "↑↓", label: "scroll" },
|
|
24
|
+
{ k: "↵", label: "refs" },
|
|
25
|
+
{ k: "e", label: "exclude" },
|
|
26
|
+
{ k: "/", label: "search" },
|
|
27
|
+
{ k: "p", label: "pause" },
|
|
28
|
+
{ k: "g", label: "rescan" },
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const PANEL_H = 6; // bottom detail-panel rows (collapsed)
|
|
33
|
+
|
|
34
|
+
const agoText = (ms) => {
|
|
35
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
36
|
+
if (s < 60) return `${s}s ago`;
|
|
37
|
+
const m = Math.round(s / 60);
|
|
38
|
+
if (m < 60) return `${m}m ago`;
|
|
39
|
+
const hr = Math.round(m / 60);
|
|
40
|
+
if (hr < 24) return `${hr}h ago`;
|
|
41
|
+
return `${Math.round(hr / 24)}d ago`;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function LocalView({ dir, ui, width = 78, height = 14, active = true, fresh = false }) {
|
|
45
|
+
const scan = useStreamingScan(dir, { fresh });
|
|
46
|
+
const running = scan.phase === "registry" || scan.phase === "scanning";
|
|
47
|
+
const tick = useTick(80, running);
|
|
48
|
+
const spin = SPINNER[tick % SPINNER.length];
|
|
49
|
+
const search = useSearch();
|
|
50
|
+
const [focus, setFocus] = React.useState("list"); // list | refs
|
|
51
|
+
const [refIdx, setRefIdx] = React.useState(0);
|
|
52
|
+
const refIdxRef = React.useRef(0); // synchronous mirror of refIdx for the input handler
|
|
53
|
+
|
|
54
|
+
const { snapshot, rows, custom, refsBySlug, customRefs, candidateCount, filesScanned, dirsSeen, catalogsSkipped, scannedAt, fromCache, phase } = scan;
|
|
55
|
+
|
|
56
|
+
const items = React.useMemo(() => {
|
|
57
|
+
const known = rows.map((r) => ({ slug: r.model.slug, model: r.model, health: r.health, count: r.count, refs: refsBySlug.get(r.model.slug) || [] }));
|
|
58
|
+
const cust = [...custom.entries()].map(([name, n]) => ({ slug: name, model: null, health: "custom", count: n, refs: customRefs.get(name) || [] }));
|
|
59
|
+
return [...known, ...cust];
|
|
60
|
+
}, [rows, custom, refsBySlug, customRefs]);
|
|
61
|
+
|
|
62
|
+
const q = search.query.toLowerCase();
|
|
63
|
+
const filtered = q ? items.filter((it) => it.slug.toLowerCase().includes(q)) : items;
|
|
64
|
+
|
|
65
|
+
const FIXED = 3; // strip + showing line + search line
|
|
66
|
+
const wantPanel = filtered.length > 0;
|
|
67
|
+
const focusedPanel = Math.min(22, Math.max(PANEL_H, height - FIXED - 2)); // room for refs + snippet
|
|
68
|
+
const panelH = wantPanel ? (focus === "refs" ? focusedPanel : PANEL_H) : 0;
|
|
69
|
+
const pageSize = Math.max(1, height - FIXED - panelH);
|
|
70
|
+
const nav = useCursorList(filtered.length, pageSize);
|
|
71
|
+
React.useEffect(() => { nav.reset(); setFocus("list"); refIdxRef.current = 0; setRefIdx(0); }, [search.query]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
72
|
+
|
|
73
|
+
const cur = filtered[nav.cursor];
|
|
74
|
+
const drefs = cur ? distinctRefs(cur.refs) : [];
|
|
75
|
+
// Syntax-highlighted snippet of the highlighted reference (drill-in view only).
|
|
76
|
+
// Key off the selected ref's scalar fields, NOT the `drefs` array — distinctRefs
|
|
77
|
+
// returns a fresh array each render, which would defeat the memo and re-read the
|
|
78
|
+
// file from disk on every unrelated re-render (toast, status, resize).
|
|
79
|
+
const selRef = focus === "refs" && drefs.length ? drefs[clampCursor(refIdx, drefs.length)] : null;
|
|
80
|
+
const snippet = React.useMemo(() => {
|
|
81
|
+
if (!selRef || !selRef.source_path) return null;
|
|
82
|
+
return readSnippet(path.resolve(dir, selRef.source_path), selRef.source_line, selRef.model_string);
|
|
83
|
+
}, [selRef?.source_path, selRef?.source_line, selRef?.model_string, dir]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
84
|
+
|
|
85
|
+
const openRef = (r) => {
|
|
86
|
+
if (!r) return;
|
|
87
|
+
const abs = path.resolve(dir, r.source_path || r.location_label || "");
|
|
88
|
+
if (r.source_path && !fs.existsSync(abs)) return ui?.showToast?.("file moved — press g to rescan", "#d97706");
|
|
89
|
+
openLocation(abs, r.source_line);
|
|
90
|
+
ui?.showToast?.(`opened ${r.source_path || r.location_label}${r.source_line ? ":" + r.source_line : ""}`);
|
|
91
|
+
};
|
|
92
|
+
// Exclude a reference's path from future scans. Prefilled with its top-level
|
|
93
|
+
// dir (e.g. "modelmanager") so excluding a whole project is one keystroke +
|
|
94
|
+
// Enter; editable for finer globs. Persists to the global ignore + rescans.
|
|
95
|
+
const excludeRef = (r) => {
|
|
96
|
+
if (!r) return;
|
|
97
|
+
const rel = (r.source_path || r.location_label || "").replace(/\\/g, "/");
|
|
98
|
+
const seg = rel.split("/")[0] || rel; // top-level project dir (one-Enter case)
|
|
99
|
+
ui?.askPrompt?.("Exclude path/glob from scans", {
|
|
100
|
+
initial: seg,
|
|
101
|
+
onSubmit: (pat) => {
|
|
102
|
+
setFocus("list");
|
|
103
|
+
if (addGlobalIgnore(pat)) {
|
|
104
|
+
ui?.showToast?.(`excluded "${(pat || "").trim()}" — rescanning`);
|
|
105
|
+
scan.reload();
|
|
106
|
+
} else {
|
|
107
|
+
ui?.showToast?.(`couldn't exclude "${(pat || "").trim()}"`, "#dc2626");
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
React.useEffect(() => {
|
|
114
|
+
const counts = countHealth(rows);
|
|
115
|
+
counts.custom = custom.size;
|
|
116
|
+
ui?.reportStatus?.({ counts, context: snapshot ? `registry · ${fmtNum(snapshot.models.length)} models` : "scanning…" });
|
|
117
|
+
}, [rows, custom, snapshot, phase, ui]);
|
|
118
|
+
|
|
119
|
+
// Tell the shell when backspace should back out *within* this view (drilled
|
|
120
|
+
// into refs, or an active filter) rather than stepping to the previous tab.
|
|
121
|
+
const setHandlesBack = ui?.setHandlesBack;
|
|
122
|
+
React.useEffect(() => {
|
|
123
|
+
setHandlesBack?.(focus === "refs" || !!search.query);
|
|
124
|
+
return () => setHandlesBack?.(false);
|
|
125
|
+
}, [setHandlesBack, focus, search.query]);
|
|
126
|
+
|
|
127
|
+
useInput(
|
|
128
|
+
(input, key) => {
|
|
129
|
+
if (!active) return;
|
|
130
|
+
if (search.isSearchingNow()) {
|
|
131
|
+
if (key.escape) { search.clear(); ui?.setCapturing?.(false); return; }
|
|
132
|
+
if (key.return) { search.confirm(); ui?.setCapturing?.(false); return; }
|
|
133
|
+
if (key.backspace || key.delete) return search.backspace();
|
|
134
|
+
if (input && !key.ctrl && !key.meta) return search.type(input);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (focus === "refs") {
|
|
138
|
+
if (key.escape || key.leftArrow || key.backspace || key.delete) return setFocus("list");
|
|
139
|
+
// refIdx read from a ref (not closure state) so a same-tick j/k + open/e
|
|
140
|
+
// acts on the row the user actually has highlighted.
|
|
141
|
+
if (key.downArrow || input === "j") { refIdxRef.current = clampCursor(refIdxRef.current + 1, drefs.length); return setRefIdx(refIdxRef.current); }
|
|
142
|
+
if (key.upArrow || input === "k") { refIdxRef.current = clampCursor(refIdxRef.current - 1, drefs.length); return setRefIdx(refIdxRef.current); }
|
|
143
|
+
if (key.return || input === "o") return openRef(drefs[clampCursor(refIdxRef.current, drefs.length)]);
|
|
144
|
+
if (input === "e") return excludeRef(drefs[clampCursor(refIdxRef.current, drefs.length)]);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (typeof input === "string" && input.startsWith("/")) {
|
|
148
|
+
search.open();
|
|
149
|
+
ui?.setCapturing?.(true);
|
|
150
|
+
const rest = input.slice(1);
|
|
151
|
+
if (rest) search.type(rest);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if ((key.escape || key.backspace || key.delete) && search.query) return search.clear();
|
|
155
|
+
if (key.return || key.rightArrow) {
|
|
156
|
+
if (drefs.length) { setFocus("refs"); refIdxRef.current = 0; setRefIdx(0); } // drill in → refs + snippet
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (key.downArrow || input === "j") return nav.down();
|
|
160
|
+
if (key.upArrow || input === "k") return nav.up();
|
|
161
|
+
if (input === "e") return excludeRef(drefs[0]); // exclude the highlighted model's location (editable)
|
|
162
|
+
if (input === "p" && running) return scan.togglePause();
|
|
163
|
+
if (input === "g") return scan.reload();
|
|
164
|
+
},
|
|
165
|
+
{ isActive: active },
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
if (phase === "error") return h(StateLine, { kind: "error", text: scan.error });
|
|
169
|
+
|
|
170
|
+
// ----- strip (1 line) -----
|
|
171
|
+
const counters = `${fmtNum(filesScanned)} files · ${fmtNum(dirsSeen)} dirs · ${fmtNum(candidateCount)} refs${catalogsSkipped ? ` · ${catalogsSkipped} catalog${catalogsSkipped === 1 ? "" : "s"} skipped` : ""}`;
|
|
172
|
+
const attention = rows.filter((r) => r.health !== "ok").length;
|
|
173
|
+
let strip;
|
|
174
|
+
if (phase === "registry") strip = h(StateLine, { kind: "loading", spin, text: "fetching the signed registry…" });
|
|
175
|
+
else if (phase === "scanning" && scan.paused)
|
|
176
|
+
strip = h(Box, {}, h(Text, { color: "#d97706" }, ` ${GLYPH.pause} paused `), h(Text, { color: C.FG_DIM }, `${counters} · p resume`));
|
|
177
|
+
else if (phase === "scanning")
|
|
178
|
+
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"));
|
|
179
|
+
else
|
|
180
|
+
strip = h(
|
|
181
|
+
Box,
|
|
182
|
+
{},
|
|
183
|
+
h(Text, { color: "#16a34a" }, ` ${GLYPH.check} `),
|
|
184
|
+
h(Text, { color: C.FG_DIM }, fromCache ? `loaded last scan${scannedAt ? ` (${agoText(Date.now() - scannedAt)})` : ""} · ${fmtNum(candidateCount)} refs · g rescan` : `scanned ${counters}`),
|
|
185
|
+
attention > 0
|
|
186
|
+
? h(Text, { color: "#d97706" }, ` ${GLYPH.warn} ${attention} need attention`)
|
|
187
|
+
: h(Text, { color: "#16a34a" }, " all current"),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// ----- list rows (full width) -----
|
|
191
|
+
const slugW = Math.max(16, width - 1 - 2 - 1 - 8 - 1 - 5);
|
|
192
|
+
const view = filtered.slice(nav.start, nav.start + pageSize);
|
|
193
|
+
const rowNodes = view.map((it, i) => {
|
|
194
|
+
const realIdx = nav.start + i;
|
|
195
|
+
const isCur = realIdx === nav.cursor;
|
|
196
|
+
const rt = it.model ? relativeTime(it.model.retires_date) : { text: "", color: C.FG_FAINT, bold: false };
|
|
197
|
+
const cells = [
|
|
198
|
+
{ text: `${healthGlyph(it.health)} `, color: healthColor(it.health) },
|
|
199
|
+
{ text: cellE(it.slug, slugW), color: isCur && focus === "list" ? C.FG_STRONG : it.model ? C.FG : C.FG_FAINT, bold: isCur && focus === "list" },
|
|
200
|
+
{ text: " ", color: C.FG },
|
|
201
|
+
{ text: rpad(rt.text, 8), color: rt.color, bold: rt.bold },
|
|
202
|
+
{ text: " ", color: C.FG },
|
|
203
|
+
{ text: rpad(`(${it.count})`, 5), color: C.FG_DIM },
|
|
204
|
+
];
|
|
205
|
+
return h(ListRow, { key: it.slug + realIdx, active: isCur, cells, width });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const emptyDone = phase === "done" && filtered.length === 0;
|
|
209
|
+
const showingLine = h(
|
|
210
|
+
Text,
|
|
211
|
+
{ color: C.FG_FAINT },
|
|
212
|
+
filtered.length > pageSize
|
|
213
|
+
? ` ${nav.start + 1}-${Math.min(nav.end, filtered.length)} of ${filtered.length}${search.query ? ` · filter "${search.query}"` : ""}`
|
|
214
|
+
: filtered.length
|
|
215
|
+
? ` ${filtered.length} model${filtered.length === 1 ? "" : "s"} in use${search.query ? ` · filter "${search.query}"` : ""}`
|
|
216
|
+
: "",
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
return h(
|
|
220
|
+
Box,
|
|
221
|
+
{ flexDirection: "column" },
|
|
222
|
+
strip,
|
|
223
|
+
emptyDone ? h(Text, { color: C.FG_DIM }, search.query ? ` no matches for "${search.query}"` : " No AI model references found here.") : null,
|
|
224
|
+
...rowNodes,
|
|
225
|
+
showingLine,
|
|
226
|
+
cur ? h(ModelDetailBar, { title: cur.slug, health: cur.health, model: cur.model, refs: cur.refs, width, height: panelH, refCursor: focus === "refs" ? clampCursor(refIdx, drefs.length) : -1, snippet }) : null,
|
|
227
|
+
h(SearchBar, { searching: search.searching, query: search.query, count: filtered.length }),
|
|
228
|
+
);
|
|
229
|
+
}
|
package/src/tui/views/scan.js
CHANGED
|
@@ -1,73 +1,185 @@
|
|
|
1
|
+
/* Scan view — THE hero screen (matches the marketing mockup). Walks the repo,
|
|
2
|
+
* resolves each reference against the signed registry for rich health +
|
|
3
|
+
* retirement display, and lets you select + upload to your inventory. Server
|
|
4
|
+
* model_ids are resolved only at upload time so uploads stay correct.
|
|
5
|
+
* Scrollable (j/k/↑↓), filterable (/), with a full-width detail panel; ↵ drills
|
|
6
|
+
* into the highlighted model's usage locations and ↵ opens one in your editor. */
|
|
1
7
|
import React from "react";
|
|
8
|
+
import fs from "node:fs";
|
|
2
9
|
import path from "node:path";
|
|
3
10
|
import { Box, Text, useInput } from "ink";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
import {
|
|
12
|
+
h, C, GLYPH, layout, ListRow, StateLine, EmptyCard, ModelDetailBar, SearchBar, distinctRefs,
|
|
13
|
+
healthColor, healthGlyph, relativeTime, envTag, cellE, cellL, cell, rpad, fmtNum,
|
|
14
|
+
SPINNER, useTick, useAsync, useCursorList, useSearch, clampCursor,
|
|
15
|
+
} from "../ui.js";
|
|
16
|
+
import { HEALTH_RANK, countHealth, useStreamingScan } from "../scan-stream.js";
|
|
17
|
+
import { resolveLocal, computeHealth } from "../../registry/local.js";
|
|
18
|
+
import { addGlobalIgnore } from "../../sources/filesystem.js";
|
|
19
|
+
import { readSnippet } from "../snippet.js";
|
|
20
|
+
import { track } from "../../telemetry.js";
|
|
21
|
+
import { openUrl, openLocation } from "../../openUrl.js";
|
|
22
|
+
|
|
23
|
+
export const meta = {
|
|
24
|
+
keys: [
|
|
25
|
+
{ k: "↑↓", label: "nav" },
|
|
26
|
+
{ k: "space", label: "select" },
|
|
27
|
+
{ k: "↵", label: "refs" },
|
|
28
|
+
{ k: "e", label: "exclude" },
|
|
29
|
+
{ k: "/", label: "search" },
|
|
30
|
+
{ k: "g", label: "rescan" },
|
|
31
|
+
{ k: "u", label: "upload" },
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const PANEL_H = 6;
|
|
36
|
+
|
|
37
|
+
export function ScanView({ client, dir, ui, active, width = 78, height = 14, fresh = false }) {
|
|
38
|
+
// Reuse the SHARED cached scan engine (same module-level cache as the Here
|
|
39
|
+
// tab) instead of re-walking the repo on every visit. Switching to Scan after
|
|
40
|
+
// Here is now instant; `g` forces a fresh walk for both.
|
|
41
|
+
const scan = useStreamingScan(dir, { fresh });
|
|
42
|
+
const running = scan.phase === "registry" || scan.phase === "scanning";
|
|
43
|
+
|
|
44
|
+
const [items, setItems] = React.useState([]);
|
|
45
|
+
const [projectIdx, setProjectIdx] = React.useState(0);
|
|
46
|
+
const [busy, setBusy] = React.useState(false);
|
|
47
|
+
const [focus, setFocus] = React.useState("list"); // list | refs
|
|
48
|
+
const [refIdx, setRefIdx] = React.useState(0);
|
|
49
|
+
const refIdxRef = React.useRef(0); // synchronous mirror for the input handler
|
|
50
|
+
const tick = useTick(80, running || busy);
|
|
51
|
+
const search = useSearch();
|
|
52
|
+
|
|
53
|
+
// Upload-target projects (cheap; independent of the scan).
|
|
54
|
+
const projQ = useAsync(() => client.listProjects().then((r) => r.data || []).catch(() => []), []);
|
|
55
|
+
const projects = projQ.data || [];
|
|
56
|
+
const project = projects[clampCursor(projectIdx, projects.length)] || null;
|
|
57
|
+
|
|
58
|
+
// Selectable per-(model, location) rows derived from the cached scan's
|
|
59
|
+
// candidates — resolved locally for display/health; server model_ids are
|
|
60
|
+
// looked up only at upload time.
|
|
61
|
+
const rows = React.useMemo(() => {
|
|
62
|
+
if (!scan.snapshot) return [];
|
|
63
|
+
const resolved = resolveLocal(scan.snapshot, [...new Set(scan.candidates.map((c) => c.model_string))]);
|
|
13
64
|
const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
|
|
14
65
|
const seen = new Set();
|
|
15
|
-
const
|
|
16
|
-
|
|
66
|
+
const out = [];
|
|
67
|
+
const today = new Date();
|
|
68
|
+
for (const c of scan.candidates) {
|
|
17
69
|
const r = byStr.get(c.model_string.toLowerCase());
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
seen.
|
|
22
|
-
|
|
70
|
+
const slug = r?.model_slug || null;
|
|
71
|
+
const model = r?.model || null;
|
|
72
|
+
const key = `${slug || "custom:" + c.model_string}|${c.location_label}`;
|
|
73
|
+
if (seen.has(key)) continue;
|
|
74
|
+
seen.add(key);
|
|
75
|
+
out.push({ ...c, key, slug, model, display: slug || c.model_string, health: model ? computeHealth(model, 90, today) : "custom", selected: true });
|
|
23
76
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
77
|
+
out.sort(
|
|
78
|
+
(a, b) =>
|
|
79
|
+
HEALTH_RANK[a.health] - HEALTH_RANK[b.health] ||
|
|
80
|
+
String(a.model?.retires_date || "9999-99-99").localeCompare(String(b.model?.retires_date || "9999-99-99")),
|
|
81
|
+
);
|
|
82
|
+
return out;
|
|
83
|
+
}, [scan.candidates, scan.snapshot]);
|
|
27
84
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
85
|
+
// Mirror rows into selectable items. Keyed on the row SET (not the array
|
|
86
|
+
// identity) so unrelated re-renders don't wipe the user's selections; a real
|
|
87
|
+
// rescan changes the set and re-syncs (all selected by default).
|
|
88
|
+
const rowsKey = rows.map((r) => r.key).join("|");
|
|
89
|
+
React.useEffect(() => { setItems(rows.map((r) => ({ ...r }))); }, [rowsKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
90
|
+
const selCount = items.filter((i) => i.selected).length;
|
|
91
|
+
|
|
92
|
+
const q = search.query.toLowerCase();
|
|
93
|
+
const filtered = q ? items.filter((it) => it.display.toLowerCase().includes(q) || (it.location_label || "").toLowerCase().includes(q)) : items;
|
|
94
|
+
|
|
95
|
+
const FIXED = 2; // showing line + footer/search line
|
|
96
|
+
const wantPanel = filtered.length > 0;
|
|
97
|
+
const focusedPanel = Math.min(22, Math.max(PANEL_H, height - FIXED - 2)); // room for refs + snippet
|
|
98
|
+
const panelH = wantPanel ? (focus === "refs" ? focusedPanel : PANEL_H) : 0;
|
|
99
|
+
const pageSize = Math.max(1, height - FIXED - panelH);
|
|
100
|
+
const nav = useCursorList(filtered.length, pageSize);
|
|
101
|
+
React.useEffect(() => { nav.reset(); setFocus("list"); refIdxRef.current = 0; setRefIdx(0); }, [search.query]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
102
|
+
|
|
103
|
+
const cur = filtered[nav.cursor];
|
|
104
|
+
const refs = cur ? items.filter((it) => (cur.model ? it.slug === cur.slug : it.model_string === cur.model_string)) : [];
|
|
105
|
+
const drefs = distinctRefs(refs);
|
|
106
|
+
// Key the memo off the selected ref's scalar fields, not the fresh `drefs`
|
|
107
|
+
// array (which changes identity every render and would re-read from disk).
|
|
108
|
+
const selRef = focus === "refs" && drefs.length ? drefs[clampCursor(refIdx, drefs.length)] : null;
|
|
109
|
+
const snippet = React.useMemo(() => {
|
|
110
|
+
if (!selRef || !selRef.source_path) return null;
|
|
111
|
+
return readSnippet(path.resolve(dir, selRef.source_path), selRef.source_line, selRef.model_string);
|
|
112
|
+
}, [selRef?.source_path, selRef?.source_line, selRef?.model_string, dir]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
32
113
|
|
|
33
114
|
React.useEffect(() => {
|
|
34
|
-
|
|
35
|
-
|
|
115
|
+
const counts = countHealth(items);
|
|
116
|
+
const ctx = `${project ? project.name + " · " : ""}${selCount}/${items.length} sel`;
|
|
117
|
+
ui?.reportStatus?.({ counts, context: ctx });
|
|
118
|
+
}, [items, selCount, project, ui]);
|
|
36
119
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
120
|
+
// Tell the shell when backspace should back out *within* this view (drilled
|
|
121
|
+
// into refs, or an active filter) rather than stepping to the previous tab.
|
|
122
|
+
const setHandlesBack = ui?.setHandlesBack;
|
|
123
|
+
React.useEffect(() => {
|
|
124
|
+
setHandlesBack?.(focus === "refs" || !!search.query);
|
|
125
|
+
return () => setHandlesBack?.(false);
|
|
126
|
+
}, [setHandlesBack, focus, search.query]);
|
|
127
|
+
|
|
128
|
+
function openRef(r) {
|
|
129
|
+
if (!r) return;
|
|
130
|
+
const abs = path.resolve(dir, r.source_path || r.location_label || "");
|
|
131
|
+
if (r.source_path && !fs.existsSync(abs)) return ui?.showToast?.("file moved — press g to rescan", "#d97706");
|
|
132
|
+
openLocation(abs, r.source_line);
|
|
133
|
+
ui?.showToast?.(`opened ${r.source_path || r.location_label}${r.source_line ? ":" + r.source_line : ""}`);
|
|
134
|
+
}
|
|
135
|
+
function excludeRef(r) {
|
|
136
|
+
if (!r) return;
|
|
137
|
+
const rel = (r.source_path || r.location_label || "").replace(/\\/g, "/");
|
|
138
|
+
const seg = rel.split("/")[0] || rel;
|
|
139
|
+
ui?.askPrompt?.("Exclude path/glob from scans", {
|
|
140
|
+
initial: seg,
|
|
141
|
+
onSubmit: (pat) => {
|
|
142
|
+
setFocus("list");
|
|
143
|
+
if (addGlobalIgnore(pat)) {
|
|
144
|
+
ui?.showToast?.(`excluded "${(pat || "").trim()}" — rescanning`);
|
|
145
|
+
scan.reload();
|
|
146
|
+
} else {
|
|
147
|
+
ui?.showToast?.(`couldn't exclude "${(pat || "").trim()}"`, "#dc2626");
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
}
|
|
40
152
|
|
|
41
153
|
async function createProject(name) {
|
|
42
154
|
if (!name?.trim()) return;
|
|
43
155
|
try {
|
|
44
156
|
await client.createProject(name.trim());
|
|
45
157
|
ui.showToast(`project "${name.trim()}" created`);
|
|
46
|
-
|
|
158
|
+
projQ.reload();
|
|
47
159
|
} catch (e) {
|
|
48
|
-
ui.showToast(e.message, "
|
|
160
|
+
ui.showToast(e.message, "#dc2626");
|
|
49
161
|
}
|
|
50
162
|
}
|
|
51
163
|
|
|
52
164
|
async function upload() {
|
|
53
165
|
const selected = items.filter((r) => r.selected);
|
|
54
|
-
if (!selected.length) return ui.showToast("nothing selected", "
|
|
166
|
+
if (!selected.length) return ui.showToast("nothing selected", "#d97706");
|
|
55
167
|
setBusy(true);
|
|
56
168
|
try {
|
|
57
169
|
let projectId = project?.id;
|
|
58
170
|
if (!projectId) projectId = (await client.createProject(path.basename(path.resolve(dir)))).id;
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
source_path: r.source_path,
|
|
65
|
-
|
|
66
|
-
}));
|
|
171
|
+
const uniq = [...new Set(selected.filter((r) => r.slug).map((r) => r.model_string))];
|
|
172
|
+
const ids = uniq.length ? (await client.resolve(uniq)).data || [] : [];
|
|
173
|
+
const idByStr = new Map(ids.map((r) => [r.input.toLowerCase(), r.model_id]));
|
|
174
|
+
const usages = selected.map((r) => {
|
|
175
|
+
const mid = idByStr.get(r.model_string.toLowerCase());
|
|
176
|
+
return { model_id: mid ?? undefined, custom_model_name: mid ? undefined : r.model_string, environment: r.environment, location_label: r.location_label, source_repo: (process.env.GITHUB_REPOSITORY || "").trim() || undefined, source_path: r.source_path, source_line: r.source_line };
|
|
177
|
+
});
|
|
67
178
|
const res = await client.bulkUpload(projectId, usages);
|
|
179
|
+
track("usages_uploaded", { count: usages.length, created: res.created, updated: res.updated, source: "tui" });
|
|
68
180
|
ui.showToast(`uploaded ${usages.length} → ${res.created} new, ${res.updated} updated`);
|
|
69
181
|
} catch (e) {
|
|
70
|
-
ui.showToast(e.message, "
|
|
182
|
+
ui.showToast(e.message, "#dc2626");
|
|
71
183
|
} finally {
|
|
72
184
|
setBusy(false);
|
|
73
185
|
}
|
|
@@ -76,50 +188,97 @@ export function ScanView({ client, dir, ui, active }) {
|
|
|
76
188
|
useInput(
|
|
77
189
|
(input, key) => {
|
|
78
190
|
if (!active || busy) return;
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
191
|
+
if (search.isSearchingNow()) {
|
|
192
|
+
if (key.escape) { search.clear(); ui?.setCapturing?.(false); return; }
|
|
193
|
+
if (key.return) { search.confirm(); ui?.setCapturing?.(false); return; }
|
|
194
|
+
if (key.backspace || key.delete) return search.backspace();
|
|
195
|
+
if (input && !key.ctrl && !key.meta) return search.type(input);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (focus === "refs") {
|
|
199
|
+
if (key.escape || key.leftArrow || key.backspace || key.delete) return setFocus("list");
|
|
200
|
+
if (key.downArrow || input === "j") { refIdxRef.current = clampCursor(refIdxRef.current + 1, drefs.length); return setRefIdx(refIdxRef.current); }
|
|
201
|
+
if (key.upArrow || input === "k") { refIdxRef.current = clampCursor(refIdxRef.current - 1, drefs.length); return setRefIdx(refIdxRef.current); }
|
|
202
|
+
if (key.return || input === "o") return openRef(drefs[clampCursor(refIdxRef.current, drefs.length)]);
|
|
203
|
+
if (input === "e") return excludeRef(drefs[clampCursor(refIdxRef.current, drefs.length)]);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (typeof input === "string" && input.startsWith("/")) {
|
|
207
|
+
search.open();
|
|
208
|
+
ui?.setCapturing?.(true);
|
|
209
|
+
const rest = input.slice(1);
|
|
210
|
+
if (rest) search.type(rest);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if ((key.escape || key.backspace || key.delete) && search.query) return search.clear();
|
|
214
|
+
if (key.return || key.rightArrow) {
|
|
215
|
+
if (drefs.length) { setFocus("refs"); refIdxRef.current = 0; setRefIdx(0); } // drill in → refs + snippet
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (key.downArrow || input === "j") return nav.down();
|
|
219
|
+
if (key.upArrow || input === "k") return nav.up();
|
|
220
|
+
if (input === " ") {
|
|
221
|
+
const c = filtered[nav.cursor];
|
|
222
|
+
if (c) setItems((its) => its.map((it) => (it.key === c.key ? { ...it, selected: !it.selected } : it)));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (input === "e") return excludeRef(drefs[0]); // exclude the highlighted model's location (editable)
|
|
83
226
|
if (input === "a") return setItems((its) => its.map((it) => ({ ...it, selected: true })));
|
|
84
227
|
if (input === "x") return setItems((its) => its.map((it) => ({ ...it, selected: false })));
|
|
85
228
|
if (input === "p") return setProjectIdx((i) => (projects.length ? (i + 1) % projects.length : 0));
|
|
86
|
-
if (input === "P") return ui.askPrompt("New project
|
|
87
|
-
if (input === "g") return
|
|
229
|
+
if (input === "P") return ui.askPrompt("New project", { onSubmit: createProject });
|
|
230
|
+
if (input === "g") return scan.reload();
|
|
88
231
|
if (input === "u") return upload();
|
|
232
|
+
if (input === "l") {
|
|
233
|
+
if (cur?.slug) openUrl(`https://llmstatus.ai/registry/${encodeURIComponent(cur.slug)}`);
|
|
234
|
+
else ui.showToast("no registry page for a custom model", "#d97706");
|
|
235
|
+
}
|
|
89
236
|
},
|
|
90
237
|
{ isActive: active },
|
|
91
238
|
);
|
|
92
239
|
|
|
93
|
-
if (
|
|
94
|
-
if (
|
|
240
|
+
if (scan.phase === "error") return h(StateLine, { kind: "error", text: scan.error });
|
|
241
|
+
if (running && !items.length) return h(StateLine, { kind: "scanning", spin: SPINNER[tick % SPINNER.length], text: `scanning ${dir}…` });
|
|
95
242
|
if (!items.length)
|
|
96
|
-
return h(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
243
|
+
return h(EmptyCard, { icon: GLYPH.spark, title: `No model usage found in ${path.basename(dir)}`, lines: ["Press g to rescan, or 5 Add to enter one manually."], width });
|
|
244
|
+
|
|
245
|
+
const L = layout(width);
|
|
246
|
+
const view = filtered.slice(nav.start, nav.start + pageSize);
|
|
247
|
+
const rowNodes = view.map((it, i) => {
|
|
248
|
+
const realIdx = nav.start + i;
|
|
249
|
+
const isCur = realIdx === nav.cursor;
|
|
250
|
+
const rt = relativeTime(it.model?.retires_date);
|
|
251
|
+
const et = envTag(it.environment);
|
|
252
|
+
const loc = (it.source_path || it.location_label || "") + (it.source_line ? ":" + it.source_line : "");
|
|
253
|
+
const cells = [
|
|
254
|
+
{ text: `${it.selected ? GLYPH.selOn : GLYPH.selOff} `, color: it.selected ? C.ACCENT : C.FG_FAINT },
|
|
255
|
+
{ text: `${healthGlyph(it.health)} `, color: healthColor(it.health) },
|
|
256
|
+
{ text: cellE(it.display, L.slug), color: isCur && focus === "list" ? C.FG_STRONG : it.slug ? C.FG : C.FG_FAINT, bold: isCur && focus === "list" },
|
|
257
|
+
];
|
|
258
|
+
if (!L.dropLoc) { cells.push({ text: " ", color: C.FG }); cells.push({ text: cellL(loc, L.loc), color: C.FG_DIM }); }
|
|
259
|
+
cells.push({ text: " ", color: C.FG });
|
|
260
|
+
cells.push({ text: cell(et.text, L.env), color: et.color });
|
|
261
|
+
cells.push({ text: " ", color: C.FG });
|
|
262
|
+
cells.push({ text: rpad(rt.text, L.rel), color: rt.color, bold: rt.bold });
|
|
263
|
+
return h(ListRow, { key: it.key + realIdx, active: isCur, selected: it.selected, cells, width });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const showingLine = h(
|
|
267
|
+
Text,
|
|
268
|
+
{ color: C.FG_FAINT },
|
|
269
|
+
` ${fmtNum(filtered.length)} ref${filtered.length === 1 ? "" : "s"}${filtered.length > pageSize ? ` · ${nav.start + 1}-${Math.min(nav.end, filtered.length)}` : ""}${search.query ? ` · filter "${search.query}"` : ""}${scan.fromCache ? " · cached · g rescan" : ""}`,
|
|
270
|
+
);
|
|
271
|
+
const footer = busy
|
|
272
|
+
? h(Text, { color: C.ACCENT }, ` ${SPINNER[tick % SPINNER.length]} uploading…`)
|
|
273
|
+
: h(SearchBar, { searching: search.searching, query: search.query, count: filtered.length });
|
|
102
274
|
|
|
103
|
-
const view = items.slice(0, 16);
|
|
104
275
|
return h(
|
|
105
276
|
Box,
|
|
106
277
|
{ flexDirection: "column" },
|
|
107
|
-
h(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
),
|
|
113
|
-
h(Text, {}, ""),
|
|
114
|
-
...view.map((it, i) =>
|
|
115
|
-
h(
|
|
116
|
-
Text,
|
|
117
|
-
{ key: i, inverse: i === clampCursor(cursor, items.length), color: it.model_id ? "green" : "gray" },
|
|
118
|
-
`${it.selected ? "◉" : "○"} ${cell(it.display || it.model_string, 26)} ${cell(it.location_label, 28)} ${it.environment}`,
|
|
119
|
-
),
|
|
120
|
-
),
|
|
121
|
-
items.length > 16 ? h(Text, { color: "gray" }, `…and ${items.length - 16} more`) : null,
|
|
122
|
-
h(Text, {}, ""),
|
|
123
|
-
h(Text, { color: "gray" }, "space toggle · a all · x none · p project · P new-project · u upload · g rescan"),
|
|
278
|
+
filtered.length === 0 ? h(Text, { color: C.FG_DIM }, ` no matches for "${search.query}"`) : null,
|
|
279
|
+
...rowNodes,
|
|
280
|
+
showingLine,
|
|
281
|
+
cur ? h(ModelDetailBar, { title: cur.display, health: cur.health, model: cur.model, refs, width, height: panelH, refCursor: focus === "refs" ? clampCursor(refIdx, drefs.length) : -1, snippet }) : null,
|
|
282
|
+
footer,
|
|
124
283
|
);
|
|
125
284
|
}
|