@modelstatus/cli 0.1.0

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/src/tui/ui.js ADDED
@@ -0,0 +1,47 @@
1
+ import React from "react";
2
+ import { Text } from "ink";
3
+
4
+ export const h = React.createElement;
5
+
6
+ export const HEALTH_COLOR = {
7
+ ok: "green",
8
+ deprecating: "yellow",
9
+ retiring: "magenta",
10
+ retired: "red",
11
+ custom: "gray",
12
+ };
13
+ export const HEALTH_GLYPH = {
14
+ ok: "●",
15
+ deprecating: "●",
16
+ retiring: "▲",
17
+ retired: "■",
18
+ custom: "○",
19
+ };
20
+
21
+ export const healthColor = (hh) => HEALTH_COLOR[hh] || "white";
22
+ export const healthGlyph = (hh) => HEALTH_GLYPH[hh] || "·";
23
+
24
+ /** Truncate + pad to a fixed width for column alignment. */
25
+ export const cell = (s, w) => String(s ?? "").slice(0, w).padEnd(w);
26
+
27
+ /** Data-fetching hook: { loading, data, error, reload, setData }. */
28
+ export function useAsync(fn, deps = []) {
29
+ const [state, setState] = React.useState({ loading: true, data: null, error: null });
30
+ const ref = React.useRef(fn);
31
+ ref.current = fn;
32
+ const reload = React.useCallback(() => {
33
+ setState((s) => ({ ...s, loading: true, error: null }));
34
+ ref.current().then(
35
+ (data) => setState({ loading: false, data, error: null }),
36
+ (error) => setState({ loading: false, data: null, error: error?.message || String(error) }),
37
+ );
38
+ // eslint-disable-next-line react-hooks/exhaustive-deps
39
+ }, deps);
40
+ React.useEffect(() => {
41
+ reload();
42
+ }, [reload]);
43
+ return { ...state, reload, setData: (data) => setState((s) => ({ ...s, data })) };
44
+ }
45
+
46
+ /** Clamp a cursor index into [0, len). */
47
+ export const clampCursor = (i, len) => (len <= 0 ? 0 : Math.max(0, Math.min(len - 1, i)));
@@ -0,0 +1,76 @@
1
+ import React from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { h } from "../ui.js";
4
+ import { loadConfig, configFilePath } from "../../config.js";
5
+ import { openUrl } from "../../openUrl.js";
6
+
7
+ const FREE_LIMITS = { projects: 1, usages: 15, channels: 1 };
8
+
9
+ export function AccountView({ client, me, refreshMe, apiBase, ui, active }) {
10
+ const [status, setStatus] = React.useState(null);
11
+ const cfg = loadConfig();
12
+ const keyPrefix = cfg.apiKey ? `${cfg.apiKey.slice(0, 12)}…` : "(none)";
13
+
14
+ async function upgrade() {
15
+ if (me?.plan && me.plan !== "free") return ui.showToast(`already on ${me.plan}`, "yellow");
16
+ setStatus("Starting checkout…");
17
+ try {
18
+ const { url } = await client.checkout();
19
+ openUrl(url);
20
+ setStatus("Complete checkout in your browser… (polling /me)");
21
+ const deadline = Date.now() + 10 * 60 * 1000;
22
+ const tick = async () => {
23
+ if (Date.now() > deadline) return setStatus("Timed out — press u to retry.");
24
+ try {
25
+ const m = await client.me();
26
+ if (m?.account?.plan && m.account.plan !== "free") {
27
+ setStatus(null);
28
+ ui.showToast(`upgraded to ${m.account.plan}!`);
29
+ refreshMe();
30
+ return;
31
+ }
32
+ } catch {
33
+ /* keep polling */
34
+ }
35
+ setTimeout(tick, 4000);
36
+ };
37
+ setTimeout(tick, 4000);
38
+ } catch (e) {
39
+ setStatus(e.status === 503 ? "Billing isn't configured on this server." : e.message);
40
+ }
41
+ }
42
+
43
+ useInput(
44
+ (input) => {
45
+ if (!active) return;
46
+ if (input === "u") return upgrade();
47
+ if (input === "g") return refreshMe();
48
+ },
49
+ { isActive: active },
50
+ );
51
+
52
+ const plan = me?.plan ?? "…";
53
+ const isFree = plan === "free";
54
+ return h(
55
+ Box,
56
+ { flexDirection: "column" },
57
+ h(Text, { bold: true }, me?.name ?? "Account"),
58
+ h(Text, {}, h(Text, { color: "cyan" }, "plan "), h(Text, { color: isFree ? "gray" : "green" }, plan)),
59
+ h(Text, {}, h(Text, { color: "cyan" }, "retiring "), `${me?.retiring_window_days ?? 90} day window`),
60
+ h(Text, {}, h(Text, { color: "cyan" }, "endpoint "), apiBase || cfg.apiBase || "https://llmstatus.ai"),
61
+ h(Text, {}, h(Text, { color: "cyan" }, "key "), keyPrefix),
62
+ h(Text, { color: "gray" }, `config: ${configFilePath}`),
63
+ h(Text, {}, ""),
64
+ isFree
65
+ ? h(
66
+ Box,
67
+ { flexDirection: "column" },
68
+ h(Text, { color: "yellow" }, `Free plan · up to ${FREE_LIMITS.projects} project, ${FREE_LIMITS.usages} usages, email+in-app alerts.`),
69
+ h(Text, { color: "gray" }, "Press u to upgrade to Pro ($5/yr): unlimited usages + Slack/Discord/SMS/webhook alerts."),
70
+ )
71
+ : h(Text, { color: "green" }, "Pro features unlocked. Thanks for supporting LLM Status!"),
72
+ status ? h(Text, { color: "cyan", marginTop: 1 }, status) : null,
73
+ h(Text, {}, ""),
74
+ h(Text, { color: "gray" }, "u upgrade · g refresh · (mm logout from the shell to sign out)"),
75
+ );
76
+ }
@@ -0,0 +1,84 @@
1
+ import React from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { h, useAsync, cell, clampCursor } from "../ui.js";
4
+
5
+ const ENV_ORDER = ["prod", "staging", "dev", "other"];
6
+
7
+ export function AddView({ client, ui, active }) {
8
+ const projQ = useAsync(async () => (await client.listProjects()).data || [], []);
9
+ const [modelStr, setModelStr] = React.useState("");
10
+ const [resolved, setResolved] = React.useState(null); // { model_id, display, match_confidence }
11
+ const [projectIdx, setProjectIdx] = React.useState(0);
12
+ const [envIdx, setEnvIdx] = React.useState(0);
13
+ const [busy, setBusy] = React.useState(false);
14
+
15
+ const projects = projQ.data || [];
16
+ const project = projects[clampCursor(projectIdx, projects.length)] || null;
17
+
18
+ async function setModel(value) {
19
+ setModelStr(value);
20
+ setResolved(null);
21
+ if (!value?.trim()) return;
22
+ try {
23
+ const r = (await client.resolve([value.trim()])).data?.[0] || null;
24
+ setResolved(r);
25
+ } catch (e) {
26
+ ui.showToast(e.message, "red");
27
+ }
28
+ }
29
+
30
+ async function submit() {
31
+ if (!modelStr.trim()) return ui.showToast("enter a model string first", "yellow");
32
+ setBusy(true);
33
+ try {
34
+ let projectId = project?.id;
35
+ if (!projectId) projectId = (await client.createProject("Manual")).id;
36
+ await client.createUsage({
37
+ project_id: projectId,
38
+ model_id: resolved?.model_id ?? undefined,
39
+ custom_model_name: resolved?.model_id ? undefined : modelStr.trim(),
40
+ environment: ENV_ORDER[envIdx],
41
+ location_label: "manual",
42
+ discovered_by: "manual",
43
+ });
44
+ ui.showToast(`added "${modelStr.trim()}"`);
45
+ setModelStr("");
46
+ setResolved(null);
47
+ ui.switchTo("inventory");
48
+ } catch (e) {
49
+ ui.showToast(e.message, "red");
50
+ } finally {
51
+ setBusy(false);
52
+ }
53
+ }
54
+
55
+ useInput(
56
+ (input, key) => {
57
+ if (!active || busy) return;
58
+ if (input === "m") return ui.askPrompt("Model string", { initial: modelStr, onSubmit: setModel });
59
+ if (input === "p") return setProjectIdx((i) => (projects.length ? (i + 1) % projects.length : 0));
60
+ if (input === "e") return setEnvIdx((i) => (i + 1) % ENV_ORDER.length);
61
+ if (key.return || input === "s") return submit();
62
+ },
63
+ { isActive: active },
64
+ );
65
+
66
+ const match = resolved?.model_id
67
+ ? h(Text, { color: "green" }, `→ ${resolved.display} (registry match)`)
68
+ : modelStr
69
+ ? h(Text, { color: "gray" }, "→ will be tracked as a custom model")
70
+ : null;
71
+
72
+ return h(
73
+ Box,
74
+ { flexDirection: "column" },
75
+ h(Text, { bold: true }, "Add a usage manually"),
76
+ h(Text, {}, ""),
77
+ h(Text, {}, h(Text, { color: "cyan" }, "model "), modelStr || h(Text, { color: "gray" }, "(press m to enter)")),
78
+ match,
79
+ h(Text, {}, h(Text, { color: "cyan" }, "project "), project ? project.name : h(Text, { color: "gray" }, "(new: Manual)")),
80
+ h(Text, {}, h(Text, { color: "cyan" }, "env "), ENV_ORDER[envIdx]),
81
+ h(Text, {}, ""),
82
+ h(Text, { color: "gray" }, "m model · p project · e env · enter/s save"),
83
+ );
84
+ }
@@ -0,0 +1,160 @@
1
+ import React from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { h, useAsync, cell, clampCursor } from "../ui.js";
4
+
5
+ const DELIVERY = ["immediate", "daily", "weekly"];
6
+ const DEFAULT_EVENTS = ["model_deprecated", "model_retired", "replacement_set", "price_changed", "new_model"];
7
+ const CHANNEL_KINDS = ["slack", "discord", "teams", "webhook", "sms"];
8
+
9
+ export function AlertsView({ client, ui, active }) {
10
+ const [tab, setTab] = React.useState(0); // 0 rules, 1 channels
11
+ const [cursor, setCursor] = React.useState(0);
12
+ const rules = useAsync(async () => (await client.listRules()).data || [], []);
13
+ const channels = useAsync(async () => (await client.listChannels()).data || [], []);
14
+
15
+ const ruleList = rules.data || [];
16
+ const chanList = channels.data || [];
17
+ const list = tab === 0 ? ruleList : chanList;
18
+ const cur = list[clampCursor(cursor, list.length)] || null;
19
+
20
+ async function newRule() {
21
+ try {
22
+ await client.createRule({
23
+ name: "All lifecycle changes",
24
+ scope: "mine",
25
+ events: DEFAULT_EVENTS,
26
+ lead_times: [90, 30, 7, 1],
27
+ channels: ["inapp", "email"],
28
+ delivery: "immediate",
29
+ min_severity: "info",
30
+ enabled: true,
31
+ });
32
+ ui.showToast("rule created");
33
+ rules.reload();
34
+ } catch (e) {
35
+ ui.showToast(e.message, "red");
36
+ }
37
+ }
38
+
39
+ function addChannel() {
40
+ ui.askPrompt(`Channel kind (${CHANNEL_KINDS.join("/")})`, {
41
+ onSubmit: (kind) => {
42
+ const k = (kind || "").trim().toLowerCase();
43
+ if (!CHANNEL_KINDS.includes(k)) return ui.showToast("unknown kind", "red");
44
+ ui.askPrompt(k === "sms" ? "Phone (+15555550123)" : "Webhook URL", {
45
+ onSubmit: async (value) => {
46
+ if (!value?.trim()) return;
47
+ try {
48
+ await client.createChannel({ kind: k, value: value.trim() });
49
+ ui.showToast(`${k} channel added`);
50
+ channels.reload();
51
+ } catch (e) {
52
+ if (e.status === 402) ui.showToast("Channels are a Pro feature — press 6 → u to upgrade", "yellow");
53
+ else ui.showToast(e.message, "red");
54
+ }
55
+ },
56
+ });
57
+ },
58
+ });
59
+ }
60
+
61
+ async function testChannel(c) {
62
+ try {
63
+ await client.testChannel({ kind: c.kind, value: c.value });
64
+ ui.showToast("test sent");
65
+ } catch (e) {
66
+ if (e.status === 402) ui.showToast("Channels are a Pro feature", "yellow");
67
+ else ui.showToast(e.message, "red");
68
+ }
69
+ }
70
+
71
+ useInput(
72
+ (input, key) => {
73
+ if (!active) return;
74
+ if (key.leftArrow || key.rightArrow) {
75
+ setTab((t) => (t + 1) % 2);
76
+ setCursor(0);
77
+ return;
78
+ }
79
+ if (key.downArrow || input === "j") return setCursor((c) => clampCursor(c + 1, list.length));
80
+ if (key.upArrow || input === "k") return setCursor((c) => clampCursor(c - 1, list.length));
81
+ if (input === "g") return tab === 0 ? rules.reload() : channels.reload();
82
+ if (tab === 0) {
83
+ if (input === "n") return newRule();
84
+ if (!cur) return;
85
+ if (input === " ")
86
+ return client.patchRule(cur.id, { enabled: !cur.enabled }).then(() => rules.reload()).catch((e) => ui.showToast(e.message, "red"));
87
+ if (input === "c")
88
+ return client
89
+ .patchRule(cur.id, { delivery: DELIVERY[(DELIVERY.indexOf(cur.delivery) + 1) % 3] })
90
+ .then(() => rules.reload())
91
+ .catch((e) => ui.showToast(e.message, "red"));
92
+ if (input === "d")
93
+ return client.deleteRule(cur.id).then(() => {
94
+ ui.showToast("rule deleted");
95
+ rules.reload();
96
+ }).catch((e) => ui.showToast(e.message, "red"));
97
+ } else {
98
+ if (input === "n") return addChannel();
99
+ if (input === "t" && cur) return testChannel(cur);
100
+ }
101
+ },
102
+ { isActive: active },
103
+ );
104
+
105
+ const tabs = h(
106
+ Box,
107
+ {},
108
+ h(Text, { color: tab === 0 ? "black" : "gray", backgroundColor: tab === 0 ? "cyan" : undefined }, " Rules "),
109
+ h(Text, { color: tab === 1 ? "black" : "gray", backgroundColor: tab === 1 ? "cyan" : undefined }, " Channels "),
110
+ h(Text, { color: "gray" }, " (←/→ switch)"),
111
+ );
112
+
113
+ let body;
114
+ if (tab === 0) {
115
+ if (rules.loading) body = h(Text, { color: "gray" }, "Loading rules…");
116
+ else if (!ruleList.length) body = h(Text, { color: "gray" }, "No alert rules. Press n to create one.");
117
+ else
118
+ body = h(
119
+ Box,
120
+ { flexDirection: "column" },
121
+ ...ruleList.slice(0, 12).map((r, i) =>
122
+ h(
123
+ Text,
124
+ { key: r.id, inverse: i === clampCursor(cursor, ruleList.length), color: r.enabled ? "white" : "gray" },
125
+ `${r.enabled ? "✓" : "·"} ${cell(r.name || "(unnamed)", 26)} ${cell(r.delivery, 10)} ${(r.channels || []).join(",")}`,
126
+ ),
127
+ ),
128
+ );
129
+ } else {
130
+ if (channels.loading) body = h(Text, { color: "gray" }, "Loading channels…");
131
+ else if (!chanList.length) body = h(Text, { color: "gray" }, "No channels. Press n to add one (Pro).");
132
+ else
133
+ body = h(
134
+ Box,
135
+ { flexDirection: "column" },
136
+ ...chanList.slice(0, 12).map((c, i) =>
137
+ h(
138
+ Text,
139
+ { key: c.id, inverse: i === clampCursor(cursor, chanList.length) },
140
+ `${cell(c.kind, 10)} ${cell(c.label || c.value, 40)}`,
141
+ ),
142
+ ),
143
+ );
144
+ }
145
+
146
+ const footer =
147
+ tab === 0
148
+ ? "←/→ tab · ↑↓ move · n new · space enable · c cadence · d delete · g refresh"
149
+ : "←/→ tab · ↑↓ move · n add · t test · g refresh";
150
+
151
+ return h(
152
+ Box,
153
+ { flexDirection: "column" },
154
+ tabs,
155
+ h(Text, {}, ""),
156
+ body,
157
+ h(Text, {}, ""),
158
+ h(Text, { color: "gray" }, footer),
159
+ );
160
+ }
@@ -0,0 +1,102 @@
1
+ import React from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { h, useAsync, healthColor, healthGlyph, cell, clampCursor } from "../ui.js";
4
+
5
+ const ENV_ORDER = ["prod", "staging", "dev", "other"];
6
+
7
+ export function InventoryView({ client, ui, active }) {
8
+ const q = useAsync(async () => {
9
+ const [u, p] = await Promise.all([client.listUsages(), client.listProjects()]);
10
+ return { usages: u.data || [], projects: p.data || [] };
11
+ }, []);
12
+ const [cursor, setCursor] = React.useState(0);
13
+
14
+ const usages = q.data?.usages || [];
15
+ const projects = q.data?.projects || [];
16
+ const projName = (id) => projects.find((p) => p.id === id)?.name || "—";
17
+ const cur = usages[clampCursor(cursor, usages.length)] || null;
18
+
19
+ async function patch(u, body, label) {
20
+ try {
21
+ await client.patchUsage(u.id, body);
22
+ ui.showToast(label);
23
+ q.reload();
24
+ } catch (e) {
25
+ ui.showToast(e.message, "red");
26
+ }
27
+ }
28
+
29
+ useInput(
30
+ (input, key) => {
31
+ if (!active) return;
32
+ if (key.downArrow || input === "j") return setCursor((c) => clampCursor(c + 1, usages.length));
33
+ if (key.upArrow || input === "k") return setCursor((c) => clampCursor(c - 1, usages.length));
34
+ if (input === "g") return q.reload();
35
+ if (input === "r") return ui.switchTo("scan");
36
+ if (input === "n") return ui.switchTo("add");
37
+ if (!cur) return;
38
+ if (input === "e") return patch(cur, { environment: ENV_ORDER[(ENV_ORDER.indexOf(cur.environment) + 1) % 4] }, "env updated");
39
+ if (input === "c") return patch(cur, { is_critical: !cur.is_critical }, "critical toggled");
40
+ if (input === "d")
41
+ return client
42
+ .deleteUsage(cur.id)
43
+ .then(() => {
44
+ ui.showToast("deleted");
45
+ setCursor((c) => clampCursor(c, usages.length - 1));
46
+ q.reload();
47
+ })
48
+ .catch((e) => ui.showToast(e.message, "red"));
49
+ },
50
+ { isActive: active },
51
+ );
52
+
53
+ if (q.loading) return h(Text, { color: "gray" }, "Loading inventory…");
54
+ if (q.error) return h(Text, { color: "red" }, `Error: ${q.error}`);
55
+ if (!usages.length)
56
+ return h(
57
+ Box,
58
+ { flexDirection: "column" },
59
+ h(Text, { color: "gray" }, "No tracked usages yet."),
60
+ h(Text, { color: "gray" }, "Press 2 to Scan a repo, or n to Add one manually."),
61
+ );
62
+
63
+ const view = usages.slice(0, 16);
64
+ return h(
65
+ Box,
66
+ {},
67
+ // List
68
+ h(
69
+ Box,
70
+ { flexDirection: "column", width: 64 },
71
+ h(Text, { color: "gray" }, `${cell("MODEL", 24)} ${cell("ENV", 8)} PROJECT`),
72
+ ...view.map((u, i) =>
73
+ h(
74
+ Text,
75
+ { key: u.id, inverse: i === clampCursor(cursor, usages.length), color: healthColor(u.health) },
76
+ `${healthGlyph(u.health)} ${cell(u.model_display || u.custom_model_name || "?", 22)} ${cell(u.environment, 8)} ${projName(u.project_id)}`,
77
+ ),
78
+ ),
79
+ usages.length > 16 ? h(Text, { color: "gray" }, `…and ${usages.length - 16} more`) : null,
80
+ ),
81
+ // Detail
82
+ h(
83
+ Box,
84
+ { flexDirection: "column", marginLeft: 2, borderStyle: "round", borderColor: "gray", paddingX: 1, minWidth: 36 },
85
+ cur
86
+ ? h(
87
+ Box,
88
+ { flexDirection: "column" },
89
+ h(Text, { bold: true }, cur.model_display || cur.custom_model_name || "?"),
90
+ h(Text, { color: healthColor(cur.health) }, `health: ${cur.health}`),
91
+ h(Text, {}, `env: ${cur.environment}${cur.is_critical ? " · ⚑ critical" : ""}`),
92
+ h(Text, { color: "gray" }, `project: ${projName(cur.project_id)}`),
93
+ h(Text, { color: "gray" }, `where: ${cur.location_label || cur.source_path || "—"}`),
94
+ cur.retires_date ? h(Text, { color: "magenta" }, `retires: ${cur.retires_date}`) : null,
95
+ h(Text, { color: "gray" }, `via: ${cur.discovered_by}`),
96
+ )
97
+ : h(Text, { color: "gray" }, "—"),
98
+ ),
99
+ );
100
+ }
101
+
102
+ export const inventoryFooter = "↑↓ move · e env · c critical · d delete · r rescan · n new · g refresh";
@@ -0,0 +1,125 @@
1
+ import React from "react";
2
+ import path from "node:path";
3
+ 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 || [] : [];
13
+ const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
14
+ const seen = new Set();
15
+ const rows = [];
16
+ for (const c of cands) {
17
+ 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 });
23
+ }
24
+ const projects = (await client.listProjects()).data || [];
25
+ return { rows, projects };
26
+ }, [dir]);
27
+
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);
32
+
33
+ React.useEffect(() => {
34
+ if (load.data) setItems(load.data.rows.map((r) => ({ ...r })));
35
+ }, [load.data]);
36
+
37
+ const projects = load.data?.projects || [];
38
+ const project = projects[projectIdx] || null;
39
+ const selCount = items.filter((i) => i.selected).length;
40
+
41
+ async function createProject(name) {
42
+ if (!name?.trim()) return;
43
+ try {
44
+ await client.createProject(name.trim());
45
+ ui.showToast(`project "${name.trim()}" created`);
46
+ load.reload();
47
+ } catch (e) {
48
+ ui.showToast(e.message, "red");
49
+ }
50
+ }
51
+
52
+ async function upload() {
53
+ const selected = items.filter((r) => r.selected);
54
+ if (!selected.length) return ui.showToast("nothing selected", "yellow");
55
+ setBusy(true);
56
+ try {
57
+ let projectId = project?.id;
58
+ 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
+ }));
67
+ const res = await client.bulkUpload(projectId, usages);
68
+ ui.showToast(`uploaded ${usages.length} → ${res.created} new, ${res.updated} updated`);
69
+ } catch (e) {
70
+ ui.showToast(e.message, "red");
71
+ } finally {
72
+ setBusy(false);
73
+ }
74
+ }
75
+
76
+ useInput(
77
+ (input, key) => {
78
+ 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)));
83
+ if (input === "a") return setItems((its) => its.map((it) => ({ ...it, selected: true })));
84
+ if (input === "x") return setItems((its) => its.map((it) => ({ ...it, selected: false })));
85
+ 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();
88
+ if (input === "u") return upload();
89
+ },
90
+ { isActive: active },
91
+ );
92
+
93
+ if (load.loading) return h(Text, { color: "gray" }, `Scanning ${dir} …`);
94
+ if (load.error) return h(Text, { color: "red" }, `Error: ${load.error}`);
95
+ 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
+ );
102
+
103
+ const view = items.slice(0, 16);
104
+ return h(
105
+ Box,
106
+ { 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"),
124
+ );
125
+ }