@modelstatus/cli 0.1.1 → 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.
@@ -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
+ }
@@ -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 { h, useAsync, cell, clampCursor } from "../ui.js";
5
- import { collectFrom } from "../../sources/index.js";
6
-
7
- export function ScanView({ client, dir, ui, active }) {
8
- const load = useAsync(async () => {
9
- const patterns = await client.detectionPatterns();
10
- const cands = await collectFrom(["filesystem"], { root: dir }, patterns);
11
- const uniq = [...new Set(cands.map((c) => c.model_string))];
12
- const resolved = uniq.length ? (await client.resolve(uniq)).data || [] : [];
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 rows = [];
16
- for (const c of cands) {
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 model_id = r?.model_id ?? null;
19
- const k = `${model_id ?? "custom:" + c.model_string}|${c.location_label}`;
20
- if (seen.has(k)) continue;
21
- seen.add(k);
22
- rows.push({ ...c, model_id, display: model_id ? r.display : c.model_string, selected: true });
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
- const projects = (await client.listProjects()).data || [];
25
- return { rows, projects };
26
- }, [dir]);
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
- const [items, setItems] = React.useState([]);
29
- const [cursor, setCursor] = React.useState(0);
30
- const [projectIdx, setProjectIdx] = React.useState(0);
31
- const [busy, setBusy] = React.useState(false);
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
- if (load.data) setItems(load.data.rows.map((r) => ({ ...r })));
35
- }, [load.data]);
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
- const projects = load.data?.projects || [];
38
- const project = projects[projectIdx] || null;
39
- const selCount = items.filter((i) => i.selected).length;
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
- load.reload();
158
+ projQ.reload();
47
159
  } catch (e) {
48
- ui.showToast(e.message, "red");
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", "yellow");
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 usages = selected.map((r) => ({
60
- model_id: r.model_id ?? undefined,
61
- custom_model_name: r.model_id ? undefined : r.model_string,
62
- environment: r.environment,
63
- location_label: r.location_label,
64
- source_path: r.source_path,
65
- source_line: r.source_line,
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, "red");
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 (key.downArrow || input === "j") return setCursor((c) => clampCursor(c + 1, items.length));
80
- if (key.upArrow || input === "k") return setCursor((c) => clampCursor(c - 1, items.length));
81
- if (input === " ")
82
- return setItems((its) => its.map((it, i) => (i === clampCursor(cursor, items.length) ? { ...it, selected: !it.selected } : it)));
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 name", { onSubmit: createProject });
87
- if (input === "g") return load.reload();
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 (load.loading) return h(Text, { color: "gray" }, `Scanning ${dir} …`);
94
- if (load.error) return h(Text, { color: "red" }, `Error: ${load.error}`);
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
- Box,
98
- { flexDirection: "column" },
99
- h(Text, { color: "gray" }, `No model usage found in ${dir}.`),
100
- h(Text, { color: "gray" }, "Press g to re-scan."),
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
- Text,
109
- { color: "gray" },
110
- `${items.length} found · ${selCount} selected · target project: `,
111
- h(Text, { color: "cyan" }, project ? project.name : `(new: ${path.basename(path.resolve(dir))})`),
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
  }