@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.
- 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
package/src/tui/views/account.js
CHANGED
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Box, Text, useInput } from "ink";
|
|
3
|
-
import { h } from "../ui.js";
|
|
3
|
+
import { h, C, GLYPH } from "../ui.js";
|
|
4
4
|
import { loadConfig, configFilePath } from "../../config.js";
|
|
5
5
|
import { openUrl } from "../../openUrl.js";
|
|
6
6
|
|
|
7
7
|
const FREE_LIMITS = { projects: 1, usages: 15, channels: 1 };
|
|
8
|
+
// Background paint only when colors are on (mirrors ui.js BG_ON gate; ui handles the rest).
|
|
9
|
+
const BG_ON = process.env.MM_ASCII !== "1" && process.env.TERM !== "dumb" && process.env.NO_COLOR == null;
|
|
10
|
+
|
|
11
|
+
export const meta = { keys: [{ k: "u", label: "upgrade" }, { k: "g", label: "refresh" }] };
|
|
12
|
+
|
|
13
|
+
/** label/value row: padded accent label + value. */
|
|
14
|
+
function Row({ label, value, color = C.FG, bold = false }) {
|
|
15
|
+
return h(
|
|
16
|
+
Text,
|
|
17
|
+
{},
|
|
18
|
+
h(Text, { color: C.ACCENT }, label.padEnd(9)),
|
|
19
|
+
h(Text, { color, bold }, value),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
8
22
|
|
|
9
23
|
export function AccountView({ client, me, refreshMe, apiBase, ui, active }) {
|
|
10
24
|
const [status, setStatus] = React.useState(null);
|
|
11
25
|
const cfg = loadConfig();
|
|
12
26
|
const keyPrefix = cfg.apiKey ? `${cfg.apiKey.slice(0, 12)}…` : "(none)";
|
|
13
27
|
|
|
28
|
+
React.useEffect(() => ui?.reportStatus?.({ context: `plan: ${me?.plan ?? "…"}` }), [me, ui]);
|
|
29
|
+
|
|
14
30
|
async function upgrade() {
|
|
15
31
|
if (me?.plan && me.plan !== "free") return ui.showToast(`already on ${me.plan}`, "yellow");
|
|
16
32
|
setStatus("Starting checkout…");
|
|
@@ -51,26 +67,40 @@ export function AccountView({ client, me, refreshMe, apiBase, ui, active }) {
|
|
|
51
67
|
|
|
52
68
|
const plan = me?.plan ?? "…";
|
|
53
69
|
const isFree = plan === "free";
|
|
70
|
+
const endpoint = apiBase || cfg.apiBase || "https://llmstatus.ai";
|
|
71
|
+
|
|
72
|
+
const badge = isFree
|
|
73
|
+
? h(Text, { color: C.FG_FAINT }, " free ")
|
|
74
|
+
: h(Text, { backgroundColor: BG_ON ? "#16a34a" : undefined, color: C.ACCENT_INK, bold: true }, " PRO ");
|
|
75
|
+
|
|
54
76
|
return h(
|
|
55
77
|
Box,
|
|
56
78
|
{ flexDirection: "column" },
|
|
57
|
-
h(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
79
|
+
h(
|
|
80
|
+
Box,
|
|
81
|
+
{ flexDirection: "column", borderStyle: "round", borderColor: C.BORDER, paddingX: 1 },
|
|
82
|
+
h(
|
|
83
|
+
Text,
|
|
84
|
+
{},
|
|
85
|
+
h(Text, { bold: true, color: C.FG_STRONG }, me?.name ?? "Account"),
|
|
86
|
+
h(Text, {}, " "),
|
|
87
|
+
badge,
|
|
88
|
+
),
|
|
89
|
+
h(Row, { label: "plan", value: plan, color: isFree ? C.FG_DIM : "#16a34a", bold: !isFree }),
|
|
90
|
+
h(Row, { label: "retiring", value: `${me?.retiring_window_days ?? 90} day window` }),
|
|
91
|
+
h(Row, { label: "endpoint", value: endpoint }),
|
|
92
|
+
h(Row, { label: "key", value: keyPrefix }),
|
|
93
|
+
h(Text, { color: C.FG_FAINT }, `config: ${configFilePath}`),
|
|
94
|
+
),
|
|
63
95
|
h(Text, {}, ""),
|
|
64
96
|
isFree
|
|
65
97
|
? h(
|
|
66
98
|
Box,
|
|
67
99
|
{ flexDirection: "column" },
|
|
68
|
-
h(Text, { color: "
|
|
69
|
-
h(Text, { color:
|
|
100
|
+
h(Text, { color: "#d97706" }, `Free plan · up to ${FREE_LIMITS.projects} project, ${FREE_LIMITS.usages} usages, email+in-app alerts.`),
|
|
101
|
+
h(Text, { color: C.FG_DIM }, "Press u to upgrade to Pro ($5/yr): unlimited usages + Slack/Discord/SMS/webhook alerts."),
|
|
70
102
|
)
|
|
71
|
-
: h(Text, { color: "
|
|
72
|
-
status ? h(Text, { color:
|
|
73
|
-
h(Text, {}, ""),
|
|
74
|
-
h(Text, { color: "gray" }, "u upgrade · g refresh · (mm logout from the shell to sign out)"),
|
|
103
|
+
: h(Text, { color: "#16a34a" }, `${GLYPH.check} Pro features unlocked. Thanks for supporting LLM Status!`),
|
|
104
|
+
status ? h(Text, { color: C.ACCENT, marginTop: 1 }, status) : null,
|
|
75
105
|
);
|
|
76
106
|
}
|
package/src/tui/views/add.js
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Box, Text, useInput } from "ink";
|
|
3
|
-
import { h,
|
|
3
|
+
import { h, C, GLYPH, useAsync, clampCursor } from "../ui.js";
|
|
4
4
|
|
|
5
5
|
const ENV_ORDER = ["prod", "staging", "dev", "other"];
|
|
6
6
|
|
|
7
|
+
export const meta = {
|
|
8
|
+
keys: [
|
|
9
|
+
{ k: "m", label: "model" },
|
|
10
|
+
{ k: "p", label: "project" },
|
|
11
|
+
{ k: "e", label: "env" },
|
|
12
|
+
{ k: "enter", label: "save" },
|
|
13
|
+
],
|
|
14
|
+
};
|
|
15
|
+
|
|
7
16
|
export function AddView({ client, ui, active }) {
|
|
8
17
|
const projQ = useAsync(async () => (await client.listProjects()).data || [], []);
|
|
9
18
|
const [modelStr, setModelStr] = React.useState("");
|
|
@@ -15,6 +24,8 @@ export function AddView({ client, ui, active }) {
|
|
|
15
24
|
const projects = projQ.data || [];
|
|
16
25
|
const project = projects[clampCursor(projectIdx, projects.length)] || null;
|
|
17
26
|
|
|
27
|
+
React.useEffect(() => ui?.reportStatus?.({ context: "add a usage" }), [ui]);
|
|
28
|
+
|
|
18
29
|
async function setModel(value) {
|
|
19
30
|
setModelStr(value);
|
|
20
31
|
setResolved(null);
|
|
@@ -63,22 +74,33 @@ export function AddView({ client, ui, active }) {
|
|
|
63
74
|
{ isActive: active },
|
|
64
75
|
);
|
|
65
76
|
|
|
66
|
-
const
|
|
67
|
-
? h(Text, { color: "
|
|
77
|
+
const resolveLine = resolved?.model_id
|
|
78
|
+
? h(Text, {}, h(Text, { color: "#16a34a" }, ` ${GLYPH.repl} ${resolved.display} (registry match)`))
|
|
68
79
|
: modelStr
|
|
69
|
-
? h(Text, { color:
|
|
80
|
+
? h(Text, {}, h(Text, { color: C.FG_DIM }, ` ${GLYPH.repl} tracked as a custom model`))
|
|
70
81
|
: null;
|
|
71
82
|
|
|
83
|
+
const field = (label, value, placeholder) =>
|
|
84
|
+
h(
|
|
85
|
+
Text,
|
|
86
|
+
{},
|
|
87
|
+
h(Text, { color: C.ACCENT }, `${label} `),
|
|
88
|
+
value
|
|
89
|
+
? h(Text, { color: C.FG }, value)
|
|
90
|
+
: h(Text, { color: C.FG_FAINT }, placeholder),
|
|
91
|
+
);
|
|
92
|
+
|
|
72
93
|
return h(
|
|
73
94
|
Box,
|
|
74
|
-
{ flexDirection: "column" },
|
|
75
|
-
h(Text, { bold: true },
|
|
95
|
+
{ flexDirection: "column", borderStyle: "round", borderColor: C.BORDER, paddingX: 1 },
|
|
96
|
+
h(Text, { color: C.ACCENT, bold: true }, `${GLYPH.spark} Add a usage`),
|
|
97
|
+
h(Text, { color: C.FG_FAINT }, "Manually track a model you use — e.g. one the scanner can't see (a gateway, a notebook)."),
|
|
76
98
|
h(Text, {}, ""),
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
99
|
+
field("model ", modelStr, "(press m to type one)"),
|
|
100
|
+
resolveLine,
|
|
101
|
+
field("project", project ? project.name : "", "(new: Manual)"),
|
|
102
|
+
field("env ", ENV_ORDER[envIdx], ""),
|
|
81
103
|
h(Text, {}, ""),
|
|
82
|
-
h(Text, { color:
|
|
104
|
+
h(Text, { color: C.FG_FAINT }, "m model · p project · e env · ↵ save (auto-matched to the registry, else tracked as custom)"),
|
|
83
105
|
);
|
|
84
106
|
}
|
package/src/tui/views/alerts.js
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Box, Text, useInput } from "ink";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
h, C, GLYPH, SubTabs, ListRow, StateLine, EmptyCard,
|
|
5
|
+
cell, cellE, SPINNER, useTick, useAsync, clampCursor,
|
|
6
|
+
} from "../ui.js";
|
|
4
7
|
|
|
5
8
|
const DELIVERY = ["immediate", "daily", "weekly"];
|
|
6
9
|
const DEFAULT_EVENTS = ["model_deprecated", "model_retired", "replacement_set", "price_changed", "new_model"];
|
|
7
10
|
const CHANNEL_KINDS = ["slack", "discord", "teams", "webhook", "sms"];
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
// Readable label for a rule even when it has no name (seed/sample rules do):
|
|
13
|
+
// fall back to what it watches, so the list never reads "(unnamed)".
|
|
14
|
+
const SCOPE_WORD = { mine: "Models I use", providers: "Providers I use", all: "All models" };
|
|
15
|
+
const ruleLabel = (r) => r.name || SCOPE_WORD[r.scope] || "Custom rule";
|
|
16
|
+
|
|
17
|
+
// Channel kind → accent pill color.
|
|
18
|
+
const KIND_COLOR = {
|
|
19
|
+
slack: "#a78bfa",
|
|
20
|
+
discord: "#5865f2",
|
|
21
|
+
teams: "#6264a7",
|
|
22
|
+
sms: C.ACCENT,
|
|
23
|
+
webhook: C.FG_DIM,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
|
|
27
|
+
const ROWS = Math.max(3, height - 4); // subtabs + blank + footer overhead
|
|
10
28
|
const [tab, setTab] = React.useState(0); // 0 rules, 1 channels
|
|
11
29
|
const [cursor, setCursor] = React.useState(0);
|
|
12
30
|
const rules = useAsync(async () => (await client.listRules()).data || [], []);
|
|
@@ -17,6 +35,14 @@ export function AlertsView({ client, ui, active }) {
|
|
|
17
35
|
const list = tab === 0 ? ruleList : chanList;
|
|
18
36
|
const cur = list[clampCursor(cursor, list.length)] || null;
|
|
19
37
|
|
|
38
|
+
const loading = tab === 0 ? rules.loading : channels.loading;
|
|
39
|
+
const tick = useTick(80, loading);
|
|
40
|
+
|
|
41
|
+
React.useEffect(
|
|
42
|
+
() => ui?.reportStatus?.({ context: `${ruleList.length} rules · ${chanList.length} chan` }),
|
|
43
|
+
[ruleList, chanList, ui],
|
|
44
|
+
);
|
|
45
|
+
|
|
20
46
|
async function newRule() {
|
|
21
47
|
try {
|
|
22
48
|
await client.createRule({
|
|
@@ -102,59 +128,85 @@ export function AlertsView({ client, ui, active }) {
|
|
|
102
128
|
{ isActive: active },
|
|
103
129
|
);
|
|
104
130
|
|
|
105
|
-
const
|
|
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
|
-
);
|
|
131
|
+
const subtabs = h(SubTabs, { idx: tab, tabs: ["Rules", "Channels"] });
|
|
112
132
|
|
|
113
133
|
let body;
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
134
|
+
if (loading) {
|
|
135
|
+
body = h(StateLine, { kind: "loading", spin: SPINNER[tick % SPINNER.length], text: tab === 0 ? "loading rules…" : "loading channels…" });
|
|
136
|
+
} else if (tab === 0) {
|
|
137
|
+
if (!ruleList.length) {
|
|
138
|
+
body = h(EmptyCard, { title: "No alert rules", lines: ["A rule notifies you when models you use are deprecated/retired or get a replacement.", "Press n for a sensible default (your models · in-app + email · 90/30/7/1-day lead times)."], width });
|
|
139
|
+
} else {
|
|
140
|
+
const curIdx = clampCursor(cursor, ruleList.length);
|
|
141
|
+
const fixed = 2 + 26 + 1 + 10 + 1; // glyph + name + gap + delivery + gap
|
|
142
|
+
const rest = Math.max(8, width - 1 - fixed);
|
|
118
143
|
body = h(
|
|
119
144
|
Box,
|
|
120
145
|
{ flexDirection: "column" },
|
|
121
|
-
...ruleList.slice(0,
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
146
|
+
...ruleList.slice(0, ROWS).map((r, i) => {
|
|
147
|
+
const isCur = i === curIdx;
|
|
148
|
+
const enabled = !!r.enabled;
|
|
149
|
+
const leads = r.leadTimes || r.lead_times || [];
|
|
150
|
+
const chanText = [(r.channels || []).join(","), leads.length ? leads.join("/") + "d" : ""].filter(Boolean).join(" · ");
|
|
151
|
+
const cells = [
|
|
152
|
+
{ text: `${enabled ? GLYPH.check : GLYPH.dot} `, color: enabled ? C.ACCENT : C.FG_FAINT },
|
|
153
|
+
{ text: cellE(ruleLabel(r), 26), color: isCur ? C.FG_STRONG : enabled ? C.FG : C.FG_FAINT, bold: isCur },
|
|
154
|
+
{ text: " ", color: C.FG },
|
|
155
|
+
{ text: cell(r.delivery, 10), color: C.FG_DIM },
|
|
156
|
+
{ text: " ", color: C.FG },
|
|
157
|
+
{ text: cellE(chanText, rest), color: C.FG_DIM },
|
|
158
|
+
];
|
|
159
|
+
return h(ListRow, { key: r.id, active: isCur, cells, width });
|
|
160
|
+
}),
|
|
128
161
|
);
|
|
162
|
+
}
|
|
129
163
|
} else {
|
|
130
|
-
if (
|
|
131
|
-
else if (!chanList.length) body = h(Text, { color: "gray" }, "No channels. Press n to add one (Pro).");
|
|
132
|
-
else
|
|
164
|
+
if (!chanList.length) {
|
|
133
165
|
body = h(
|
|
134
166
|
Box,
|
|
135
167
|
{ flexDirection: "column" },
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
),
|
|
168
|
+
h(EmptyCard, { title: "No channels", lines: ["Press n to add one (Pro)."], width }),
|
|
169
|
+
h(Text, { color: "#d97706" }, " Slack/Discord/SMS/webhook channels need Pro — press 6 → u to upgrade."),
|
|
143
170
|
);
|
|
171
|
+
} else {
|
|
172
|
+
const curIdx = clampCursor(cursor, chanList.length);
|
|
173
|
+
const rest = Math.max(8, width - 1 - 10); // after the 10-wide kind pill
|
|
174
|
+
body = h(
|
|
175
|
+
Box,
|
|
176
|
+
{ flexDirection: "column" },
|
|
177
|
+
...chanList.slice(0, ROWS).map((c, i) => {
|
|
178
|
+
const isCur = i === curIdx;
|
|
179
|
+
const cells = [
|
|
180
|
+
{ text: cell(c.kind, 10), color: KIND_COLOR[c.kind] || C.FG_DIM, bold: true },
|
|
181
|
+
{ text: cellE(c.label || c.value, rest), color: C.FG },
|
|
182
|
+
];
|
|
183
|
+
return h(ListRow, { key: c.id, active: isCur, cells, width });
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
144
187
|
}
|
|
145
188
|
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
: "←/→ tab · ↑↓ move · n add · t test · g refresh";
|
|
150
|
-
|
|
189
|
+
const hint = tab === 0
|
|
190
|
+
? " Rules watch the registry — deprecations · retirements · replacements · new models — and alert your channels."
|
|
191
|
+
: " Channels deliver alerts: Slack/Discord/SMS/webhook are Pro; in-app + email are always on.";
|
|
151
192
|
return h(
|
|
152
193
|
Box,
|
|
153
194
|
{ flexDirection: "column" },
|
|
154
|
-
|
|
155
|
-
h(Text, {},
|
|
195
|
+
subtabs,
|
|
196
|
+
h(Text, { color: C.FG_FAINT }, cellE(hint, width)),
|
|
156
197
|
body,
|
|
157
|
-
h(Text, {}, ""),
|
|
158
|
-
h(Text, { color: "gray" }, footer),
|
|
159
198
|
);
|
|
160
199
|
}
|
|
200
|
+
|
|
201
|
+
export const meta = {
|
|
202
|
+
keys: [
|
|
203
|
+
{ k: "←→", label: "tab" },
|
|
204
|
+
{ k: "↑↓", label: "nav" },
|
|
205
|
+
{ k: "n", label: "new" },
|
|
206
|
+
{ k: "space", label: "enable" },
|
|
207
|
+
{ k: "c", label: "cadence" },
|
|
208
|
+
{ k: "t", label: "test" },
|
|
209
|
+
{ k: "d", label: "delete" },
|
|
210
|
+
{ k: "g", label: "refresh" },
|
|
211
|
+
],
|
|
212
|
+
};
|
|
@@ -1,21 +1,61 @@
|
|
|
1
|
+
/* Inventory view — your tracked usages across projects. Body-only: the shell
|
|
2
|
+
* (app.js) owns window chrome, status bar (fed by reportStatus) and keybar.
|
|
3
|
+
* Two-pane layout: a health-glyph'd usage list on the left, a detail drawer on
|
|
4
|
+
* the right for the cursor row. Data loading + key handlers are unchanged. */
|
|
1
5
|
import React from "react";
|
|
6
|
+
import path from "node:path";
|
|
2
7
|
import { Box, Text, useInput } from "ink";
|
|
3
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
h, C, GLYPH, ListRow, StateLine, EmptyCard, healthColor, healthGlyph,
|
|
10
|
+
relativeTime, envTag, cell, cellE, snippetLines, SPINNER, useTick, useAsync, clampCursor,
|
|
11
|
+
} from "../ui.js";
|
|
12
|
+
import { readSnippet } from "../snippet.js";
|
|
4
13
|
|
|
5
14
|
const ENV_ORDER = ["prod", "staging", "dev", "other"];
|
|
6
15
|
|
|
7
|
-
export
|
|
16
|
+
export const meta = {
|
|
17
|
+
keys: [
|
|
18
|
+
{ k: "↑↓", label: "nav" },
|
|
19
|
+
{ k: "e", label: "env" },
|
|
20
|
+
{ k: "c", label: "critical" },
|
|
21
|
+
{ k: "d", label: "delete" },
|
|
22
|
+
{ k: "r", label: "rescan" },
|
|
23
|
+
{ k: "n", label: "new" },
|
|
24
|
+
{ k: "g", label: "refresh" },
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function InventoryView({ client, ui, dir = ".", active, width = 78, height = 14 }) {
|
|
8
29
|
const q = useAsync(async () => {
|
|
9
30
|
const [u, p] = await Promise.all([client.listUsages(), client.listProjects()]);
|
|
10
31
|
return { usages: u.data || [], projects: p.data || [] };
|
|
11
32
|
}, []);
|
|
12
33
|
const [cursor, setCursor] = React.useState(0);
|
|
34
|
+
const tick = useTick(80, q.loading);
|
|
35
|
+
const spin = SPINNER[tick % SPINNER.length];
|
|
13
36
|
|
|
14
37
|
const usages = q.data?.usages || [];
|
|
15
38
|
const projects = q.data?.projects || [];
|
|
16
39
|
const projName = (id) => projects.find((p) => p.id === id)?.name || "—";
|
|
17
40
|
const cur = usages[clampCursor(cursor, usages.length)] || null;
|
|
18
41
|
|
|
42
|
+
// Drill-down code preview for the highlighted usage's source location — same
|
|
43
|
+
// syntax-highlighted snippet as the Here tab, shown below the list (the side
|
|
44
|
+
// drawer stays). Reads the local file at source_path:source_line if present;
|
|
45
|
+
// keyed on scalar fields so it isn't re-read from disk on every re-render.
|
|
46
|
+
const matchStr = cur ? (cur.custom_model_name || (cur.model_display || "").split("/").pop() || cur.model_display) : null;
|
|
47
|
+
const snippet = React.useMemo(() => {
|
|
48
|
+
if (!cur || !cur.source_path) return null;
|
|
49
|
+
return readSnippet(path.resolve(dir, cur.source_path), cur.source_line, matchStr);
|
|
50
|
+
}, [cur?.source_path, cur?.source_line, matchStr, dir]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
51
|
+
|
|
52
|
+
// Push health legend + tracked context up to the shell status bar.
|
|
53
|
+
React.useEffect(() => {
|
|
54
|
+
const counts = { ok: 0, deprecating: 0, retiring: 0, retired: 0, custom: 0 };
|
|
55
|
+
for (const u of usages) if (counts[u.health] != null) counts[u.health] += 1;
|
|
56
|
+
ui?.reportStatus?.({ counts, context: `${usages.length} tracked` });
|
|
57
|
+
}, [usages, ui]);
|
|
58
|
+
|
|
19
59
|
async function patch(u, body, label) {
|
|
20
60
|
try {
|
|
21
61
|
await client.patchUsage(u.id, body);
|
|
@@ -50,53 +90,115 @@ export function InventoryView({ client, ui, active }) {
|
|
|
50
90
|
{ isActive: active },
|
|
51
91
|
);
|
|
52
92
|
|
|
53
|
-
if (q.loading) return h(
|
|
54
|
-
if (q.error) return h(
|
|
93
|
+
if (q.loading) return h(StateLine, { kind: "loading", spin, text: "loading inventory…" });
|
|
94
|
+
if (q.error) return h(StateLine, { kind: "error", text: q.error });
|
|
55
95
|
if (!usages.length)
|
|
56
|
-
return h(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
|
|
96
|
+
return h(EmptyCard, {
|
|
97
|
+
icon: GLYPH.spark,
|
|
98
|
+
title: "No tracked usages yet",
|
|
99
|
+
lines: ["Press 4 Scan to import a repo, or 5 Add to enter one."],
|
|
100
|
+
width,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ----- layout: left list pane + right detail drawer -----
|
|
104
|
+
const drawerW = 34;
|
|
105
|
+
const leftWidth = Math.max(24, width - drawerW - 2);
|
|
106
|
+
// left columns: glyph(2) + slug + sp(1) + env(7) + sp(1) + project; rail is col 1.
|
|
107
|
+
const ENV_W = 7;
|
|
108
|
+
const FIXED = 2 + 1 + ENV_W + 1; // glyph+sp, sp, env, sp
|
|
109
|
+
const slugW = Math.max(10, Math.floor((leftWidth - 1 - FIXED) * 0.5));
|
|
110
|
+
const projW = Math.max(8, leftWidth - 1 - FIXED - slugW);
|
|
111
|
+
|
|
112
|
+
// Preview block below the list+drawer: the syntax-highlighted source snippet
|
|
113
|
+
// when the file is readable locally, else a one-line note (the usage may be
|
|
114
|
+
// sample data, or its code lives on another machine — the preview reads the
|
|
115
|
+
// LOCAL file at source_path). Reserve rows only with room to keep the bordered
|
|
116
|
+
// drawer (~11 rows) intact, so small terminals never overflow + scroll.
|
|
117
|
+
const srcRef = cur && cur.source_path ? `${cur.source_path}${cur.source_line ? ":" + cur.source_line : ""}` : null;
|
|
118
|
+
const SNIP = snippet ? Math.max(0, Math.min(8, height - 12)) : 0;
|
|
119
|
+
const showSnip = SNIP >= 3; // sep + header + ≥1 code line
|
|
120
|
+
const showNote = !showSnip && !!srcRef && height >= 14; // has a path but no local file
|
|
121
|
+
const reserve = showSnip ? SNIP : showNote ? 2 : 0;
|
|
122
|
+
const topH = height - reserve;
|
|
123
|
+
const VISIBLE = Math.max(3, topH - 2); // header + "…and N more" overhead
|
|
124
|
+
const c = clampCursor(cursor, usages.length);
|
|
125
|
+
const start = Math.max(0, Math.min(c - Math.floor(VISIBLE / 2), Math.max(0, usages.length - VISIBLE)));
|
|
126
|
+
const view = usages.slice(start, start + VISIBLE);
|
|
127
|
+
|
|
128
|
+
const header = h(
|
|
129
|
+
Text,
|
|
130
|
+
{ color: C.FG_FAINT },
|
|
131
|
+
" " + cell("MODEL", 2 + slugW) + cell("ENV", 1 + ENV_W) + "PROJECT",
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const rowNodes = view.map((u, i) => {
|
|
135
|
+
const isCur = start + i === c;
|
|
136
|
+
const et = envTag(u.environment);
|
|
137
|
+
const slug = u.model_display || u.custom_model_name || "?";
|
|
138
|
+
const cells = [
|
|
139
|
+
{ text: `${healthGlyph(u.health)} `, color: healthColor(u.health) },
|
|
140
|
+
{ text: cellE(slug, slugW), color: isCur ? C.FG_STRONG : C.FG, bold: isCur },
|
|
141
|
+
{ text: " ", color: C.FG },
|
|
142
|
+
{ text: cell(et.text, ENV_W), color: et.color },
|
|
143
|
+
{ text: " ", color: C.FG },
|
|
144
|
+
{ text: cellE(projName(u.project_id), projW), color: C.FG_DIM },
|
|
145
|
+
];
|
|
146
|
+
return h(ListRow, { key: u.id, active: isCur, cells, width: leftWidth });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const list = h(
|
|
150
|
+
Box,
|
|
151
|
+
{ flexDirection: "column", width: leftWidth },
|
|
152
|
+
header,
|
|
153
|
+
...rowNodes,
|
|
154
|
+
usages.length > VISIBLE
|
|
155
|
+
? h(Text, { color: C.FG_FAINT }, ` ${start + 1}-${Math.min(start + VISIBLE, usages.length)} of ${usages.length}`)
|
|
156
|
+
: null,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
// ----- detail drawer (cursor usage) -----
|
|
160
|
+
const rt = cur?.retires_date ? relativeTime(cur.retires_date) : null;
|
|
161
|
+
const where = cur?.location_label || cur?.source_path || "—";
|
|
162
|
+
const drawer = h(
|
|
163
|
+
Box,
|
|
164
|
+
{ borderStyle: "round", borderColor: C.BORDER, paddingX: 1, flexDirection: "column", minWidth: drawerW },
|
|
165
|
+
h(Text, { color: C.ACCENT, bold: true }, " DETAIL "),
|
|
166
|
+
cur
|
|
167
|
+
? h(
|
|
168
|
+
Box,
|
|
169
|
+
{ flexDirection: "column" },
|
|
170
|
+
h(Text, { color: C.FG_STRONG, bold: true }, cur.model_display || cur.custom_model_name || "?"),
|
|
171
|
+
h(
|
|
172
|
+
Text,
|
|
173
|
+
{},
|
|
174
|
+
h(Text, { color: healthColor(cur.health) }, `${healthGlyph(cur.health)} ${cur.health}`),
|
|
175
|
+
),
|
|
176
|
+
h(
|
|
177
|
+
Text,
|
|
178
|
+
{},
|
|
179
|
+
h(Text, { color: C.FG_DIM }, "env "),
|
|
180
|
+
h(Text, { color: envTag(cur.environment).color }, cur.environment),
|
|
181
|
+
cur.is_critical ? h(Text, { color: "#ea580c" }, ` ${GLYPH.crit} critical`) : null,
|
|
182
|
+
),
|
|
183
|
+
h(Text, {}, h(Text, { color: C.FG_DIM }, "project "), h(Text, { color: C.FG }, projName(cur.project_id))),
|
|
184
|
+
h(Text, {}, h(Text, { color: C.FG_DIM }, "where "), h(Text, { color: C.FG_DIM }, where)),
|
|
185
|
+
rt && rt.text
|
|
186
|
+
? h(Text, {}, h(Text, { color: C.FG_DIM }, "retires "), h(Text, { color: rt.color, bold: rt.bold }, rt.text))
|
|
187
|
+
: null,
|
|
188
|
+
cur.replacement_slug
|
|
189
|
+
? h(Text, { color: "#16a34a" }, `${GLYPH.repl} ${cur.replacement_slug}`)
|
|
190
|
+
: null,
|
|
191
|
+
cur.discovered_by ? h(Text, { color: C.FG_FAINT }, `via ${cur.discovered_by}`) : null,
|
|
192
|
+
)
|
|
193
|
+
: h(Text, { color: C.FG_DIM }, "—"),
|
|
194
|
+
);
|
|
195
|
+
|
|
64
196
|
return h(
|
|
65
197
|
Box,
|
|
66
|
-
{},
|
|
67
|
-
|
|
68
|
-
h(
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
),
|
|
198
|
+
{ flexDirection: "column" },
|
|
199
|
+
h(Box, { flexDirection: "row" }, list, h(Box, { width: 2 }), drawer),
|
|
200
|
+
showSnip || showNote ? h(Text, { color: C.BORDER }, "─".repeat(width)) : null,
|
|
201
|
+
...(showSnip ? snippetLines(snippet, width, SNIP - 2) : []),
|
|
202
|
+
showNote ? h(Text, { color: C.FG_FAINT }, cellE(` ↳ ${srcRef} — not found in this directory; open the repo to preview the code`, width)) : null,
|
|
99
203
|
);
|
|
100
204
|
}
|
|
101
|
-
|
|
102
|
-
export const inventoryFooter = "↑↓ move · e env · c critical · d delete · r rescan · n new · g refresh";
|