@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.
@@ -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(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}`),
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: "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."),
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: "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)"),
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
  }
@@ -1,9 +1,18 @@
1
1
  import React from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
- import { h, useAsync, cell, clampCursor } from "../ui.js";
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 match = resolved?.model_id
67
- ? h(Text, { color: "green" }, `→ ${resolved.display} (registry match)`)
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: "gray" }, "→ will be tracked as a custom model")
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 }, "Add a usage manually"),
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
- 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]),
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: "gray" }, "m model · p project · e env · enter/s save"),
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
  }
@@ -1,12 +1,30 @@
1
1
  import React from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
- import { h, useAsync, cell, clampCursor } from "../ui.js";
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
- export function AlertsView({ client, ui, active }) {
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 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
- );
131
+ const subtabs = h(SubTabs, { idx: tab, tabs: ["Rules", "Channels"] });
112
132
 
113
133
  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
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, 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
- ),
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 (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
164
+ if (!chanList.length) {
133
165
  body = h(
134
166
  Box,
135
167
  { 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
- ),
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 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
-
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
- tabs,
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 { h, useAsync, healthColor, healthGlyph, cell, clampCursor } from "../ui.js";
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 function InventoryView({ client, ui, active }) {
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(Text, { color: "gray" }, "Loading inventory…");
54
- if (q.error) return h(Text, { color: "red" }, `Error: ${q.error}`);
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
- 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);
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
- // 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
- ),
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";