@modelstatus/cli 0.1.58 β 0.1.60
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/tui/app.js +1 -1
- package/src/tui/ui.js +15 -4
- package/src/tui/views/account.js +24 -14
- package/src/tui/views/add.js +14 -5
- package/src/tui/views/alerts.js +9 -9
- package/src/tui/views/integrations.js +5 -5
- package/src/tui/views/inventory.js +38 -18
- package/src/tui/views/local.js +24 -10
- package/src/tui/views/scan.js +21 -5
- package/src/tui/views/whatsnew.js +36 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modelstatus/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.60",
|
|
4
4
|
"description": "Track which AI models you use, where, and never get surprised by a retirement. Free offline model-health for any repo (mm status), browser sign-in for cloud inventory + alerts.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"llm",
|
package/src/tui/app.js
CHANGED
|
@@ -281,7 +281,7 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
|
|
|
281
281
|
h(Text, {}, ""),
|
|
282
282
|
toast ? h(Text, { color: toast.color }, ` ${toast.msg}`) : h(Text, {}, ""),
|
|
283
283
|
h(StatusBar, { segsLeft, segsRight, width: W }),
|
|
284
|
-
h(KeyBar, { keys: keys.some((k) => k.k === "P") ? keys : [...keys, { k: "P", label: "play" }], width: W }),
|
|
284
|
+
h(KeyBar, { keys: keys.some((k) => k.k === "P") ? keys : [...keys, { k: "P", label: "play π¦" }], width: W }),
|
|
285
285
|
);
|
|
286
286
|
}
|
|
287
287
|
|
package/src/tui/ui.js
CHANGED
|
@@ -159,8 +159,12 @@ export function relativeTime(retiresDate, today = new Date()) {
|
|
|
159
159
|
/** env β { text, color }. */
|
|
160
160
|
export function envTag(env) {
|
|
161
161
|
const e = String(env || "");
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
// Fit the 5-char env column without mid-word chops ("unknown" β "unkno").
|
|
163
|
+
// Unknown renders BLANK: it's the default, not information β only a real env
|
|
164
|
+
// should draw the eye.
|
|
165
|
+
const SHORT = { unknown: "", production: "prod", staging: "stage", development: "dev" };
|
|
166
|
+
const color = e === "prod" || e === "production" ? C.ACCENT : e === "staging" ? "#a78bfa" : C.FG_FAINT;
|
|
167
|
+
return { text: SHORT[e] ?? e, color };
|
|
164
168
|
}
|
|
165
169
|
|
|
166
170
|
// ===========================================================================
|
|
@@ -360,9 +364,16 @@ export function KeyBar({ keys, width }) {
|
|
|
360
364
|
// ===========================================================================
|
|
361
365
|
|
|
362
366
|
/** kind β loading|scanning|done|error β leading glyph + colored text, one row. */
|
|
363
|
-
export function StateLine({ kind, text, spin }) {
|
|
367
|
+
export function StateLine({ kind, text, spin, hint }) {
|
|
368
|
+
// `hint` = dim recovery affordance appended after an error ("g retries") so a
|
|
369
|
+
// failure line always says how to get unstuck, in one consistent style.
|
|
364
370
|
if (kind === "done") return h(Text, {}, h(Text, { color: "#16a34a" }, ` ${GLYPH.check} `), h(Text, { color: C.FG_DIM }, text));
|
|
365
|
-
if (kind === "error")
|
|
371
|
+
if (kind === "error")
|
|
372
|
+
return h(Text, {},
|
|
373
|
+
h(Text, { color: "#dc2626" }, ` ${GLYPH.cross} `),
|
|
374
|
+
h(Text, { color: "#dc2626" }, text),
|
|
375
|
+
hint ? h(Text, { color: C.FG_DIM }, ` Β· ${hint}`) : null,
|
|
376
|
+
);
|
|
366
377
|
// loading / scanning
|
|
367
378
|
return h(Text, {}, h(Text, { color: C.ACCENT }, ` ${spin ?? SPINNER[0]} `), h(Text, { color: C.FG_DIM }, text));
|
|
368
379
|
}
|
package/src/tui/views/account.js
CHANGED
|
@@ -28,7 +28,10 @@ export function AccountView({ client, me, refreshMe, apiBase, ui, active }) {
|
|
|
28
28
|
React.useEffect(() => ui?.reportStatus?.({ context: `plan: ${me?.plan ?? "β¦"}` }), [me, ui]);
|
|
29
29
|
|
|
30
30
|
async function upgrade() {
|
|
31
|
-
|
|
31
|
+
// Without a loaded account a checkout would just fire at an unreachable
|
|
32
|
+
// endpoint and die with a raw fetch error β say what to do instead.
|
|
33
|
+
if (!me?.plan) return ui.showToast("account not loaded β press g to refresh first", "yellow");
|
|
34
|
+
if (me.plan !== "free") return ui.showToast(`already on ${me.plan}`, "yellow");
|
|
32
35
|
setStatus("Starting checkoutβ¦");
|
|
33
36
|
try {
|
|
34
37
|
const { url } = await client.checkout();
|
|
@@ -52,7 +55,7 @@ export function AccountView({ client, me, refreshMe, apiBase, ui, active }) {
|
|
|
52
55
|
};
|
|
53
56
|
setTimeout(tick, 4000);
|
|
54
57
|
} catch (e) {
|
|
55
|
-
setStatus(e.status === 503 ? "Billing isn't configured on this server." : e.message);
|
|
58
|
+
setStatus(e.status === 503 ? "Billing isn't configured on this server." : `checkout failed: ${e.message} β press u to retry.`);
|
|
56
59
|
}
|
|
57
60
|
}
|
|
58
61
|
|
|
@@ -67,11 +70,16 @@ export function AccountView({ client, me, refreshMe, apiBase, ui, active }) {
|
|
|
67
70
|
|
|
68
71
|
const plan = me?.plan ?? "β¦";
|
|
69
72
|
const isFree = plan === "free";
|
|
73
|
+
// An UNLOADED account is not Pro β never show the PRO badge (or the thank-you
|
|
74
|
+
// line below) until /me actually resolves; offline must read as offline.
|
|
75
|
+
const unknown = me?.plan == null;
|
|
70
76
|
const endpoint = apiBase || cfg.apiBase || "https://llmstatus.ai";
|
|
71
77
|
|
|
72
|
-
const badge =
|
|
73
|
-
? h(Text, { color: C.FG_FAINT }, "
|
|
74
|
-
:
|
|
78
|
+
const badge = unknown
|
|
79
|
+
? h(Text, { color: C.FG_FAINT }, " β¦ ")
|
|
80
|
+
: isFree
|
|
81
|
+
? h(Text, { color: C.FG_FAINT }, " free ")
|
|
82
|
+
: h(Text, { backgroundColor: BG_ON ? "#16a34a" : undefined, color: C.ACCENT_INK, bold: true }, " PRO ");
|
|
75
83
|
|
|
76
84
|
return h(
|
|
77
85
|
Box,
|
|
@@ -90,17 +98,19 @@ export function AccountView({ client, me, refreshMe, apiBase, ui, active }) {
|
|
|
90
98
|
h(Row, { label: "retiring", value: `${me?.retiring_window_days ?? 90} day window` }),
|
|
91
99
|
h(Row, { label: "endpoint", value: endpoint }),
|
|
92
100
|
h(Row, { label: "key", value: keyPrefix }),
|
|
93
|
-
h(
|
|
101
|
+
h(Row, { label: "config", value: configFilePath, color: C.FG_FAINT }),
|
|
94
102
|
),
|
|
95
103
|
h(Text, {}, ""),
|
|
96
|
-
|
|
97
|
-
? h(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
+
unknown
|
|
105
|
+
? h(Text, {}, h(Text, { color: "#dc2626" }, `${GLYPH.cross} couldn't load account`), h(Text, { color: C.FG_DIM }, " Β· g retries"))
|
|
106
|
+
: isFree
|
|
107
|
+
? h(
|
|
108
|
+
Box,
|
|
109
|
+
{ flexDirection: "column" },
|
|
110
|
+
h(Text, { color: "#d97706" }, `Free plan Β· up to ${FREE_LIMITS.projects} project, ${FREE_LIMITS.usages} usages, email+in-app alerts.`),
|
|
111
|
+
h(Text, { color: C.FG_DIM }, "Press u to upgrade to Pro ($5/yr): unlimited usages + Slack/Discord/SMS/webhook alerts."),
|
|
112
|
+
)
|
|
113
|
+
: h(Text, { color: "#16a34a" }, `${GLYPH.check} Pro features unlocked. Thanks for supporting LLM Status!`),
|
|
104
114
|
status ? h(Text, { color: C.ACCENT, marginTop: 1 }, status) : null,
|
|
105
115
|
);
|
|
106
116
|
}
|
package/src/tui/views/add.js
CHANGED
|
@@ -2,7 +2,9 @@ import React from "react";
|
|
|
2
2
|
import { Box, Text, useInput } from "ink";
|
|
3
3
|
import { h, C, GLYPH, useAsync, clampCursor } from "../ui.js";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// No "unknown" here: on a MANUAL add the user knows the env β the unknown
|
|
6
|
+
// bucket is something the scanner infers, never a deliberate choice.
|
|
7
|
+
const ENV_ORDER = ["prod", "staging", "dev"];
|
|
6
8
|
|
|
7
9
|
export const meta = {
|
|
8
10
|
keys: [
|
|
@@ -67,9 +69,12 @@ export function AddView({ client, ui, active }) {
|
|
|
67
69
|
(input, key) => {
|
|
68
70
|
if (!active || busy) return;
|
|
69
71
|
if (input === "m") return ui.askPrompt("Model string", { initial: modelStr, onSubmit: setModel });
|
|
70
|
-
|
|
72
|
+
// With no projects loaded (offline / failed fetch), p retries the fetch
|
|
73
|
+
// instead of being a silent no-op.
|
|
74
|
+
if (input === "p") return projects.length ? setProjectIdx((i) => (i + 1) % projects.length) : projQ.reload();
|
|
71
75
|
if (input === "e") return setEnvIdx((i) => (i + 1) % ENV_ORDER.length);
|
|
72
|
-
|
|
76
|
+
// enter is the one advertised save key β no undocumented "s" alias to trip on.
|
|
77
|
+
if (key.return) return submit();
|
|
73
78
|
},
|
|
74
79
|
{ isActive: active },
|
|
75
80
|
);
|
|
@@ -98,9 +103,13 @@ export function AddView({ client, ui, active }) {
|
|
|
98
103
|
h(Text, {}, ""),
|
|
99
104
|
field("model ", modelStr, "(press m to type one)"),
|
|
100
105
|
resolveLine,
|
|
101
|
-
field("project", project ? project.name : "",
|
|
106
|
+
field("project", project ? project.name : "", '(p to pick Β· else saves to a new "Manual" project)'),
|
|
107
|
+
projQ.error
|
|
108
|
+
? h(Text, { color: "#dc2626" }, ` ${GLYPH.cross} couldn't load projects β ${projQ.error}`, h(Text, { color: C.FG_DIM }, " Β· p retries"))
|
|
109
|
+
: null,
|
|
102
110
|
field("env ", ENV_ORDER[envIdx], ""),
|
|
103
111
|
h(Text, {}, ""),
|
|
104
|
-
|
|
112
|
+
// Keys live in the keybar below β this line carries only what the keybar can't.
|
|
113
|
+
h(Text, { color: C.FG_FAINT }, "models are auto-matched to the registry; unmatched names are tracked as custom"),
|
|
105
114
|
);
|
|
106
115
|
}
|
package/src/tui/views/alerts.js
CHANGED
|
@@ -27,8 +27,8 @@ const KIND_COLOR = {
|
|
|
27
27
|
// follows the active sub-tab (published via ui.setKeys) rather than advertising
|
|
28
28
|
// Rules keys while you're on Channels.
|
|
29
29
|
const ALERTS_TAB_KEYS = [
|
|
30
|
-
[{ k: "
|
|
31
|
-
[{ k: "
|
|
30
|
+
[{ k: "ββ", label: "nav" }, { k: "n", label: "new rule" }, { k: "space", label: "toggle" }, { k: "g", label: "refresh" }, { k: "c", label: "cadence" }, { k: "d", label: "delete" }],
|
|
31
|
+
[{ k: "ββ", label: "nav" }, { k: "n", label: "add channel" }, { k: "t", label: "test" }, { k: "g", label: "refresh" }],
|
|
32
32
|
];
|
|
33
33
|
|
|
34
34
|
export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
|
|
@@ -49,8 +49,8 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
|
|
|
49
49
|
const tick = useTick(80, loading);
|
|
50
50
|
|
|
51
51
|
React.useEffect(
|
|
52
|
-
() => ui?.reportStatus?.({ context: `${ruleList.length} rules Β· ${chanList.length} chan` }),
|
|
53
|
-
[ruleList, chanList, ui],
|
|
52
|
+
() => ui?.reportStatus?.({ context: rules.error || channels.error ? "offline" : `${ruleList.length} rules Β· ${chanList.length} chan` }),
|
|
53
|
+
[ruleList, chanList, rules.error, channels.error, ui],
|
|
54
54
|
);
|
|
55
55
|
|
|
56
56
|
async function newRule() {
|
|
@@ -85,7 +85,7 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
|
|
|
85
85
|
ui.showToast(`${k} channel added`);
|
|
86
86
|
channels.reload();
|
|
87
87
|
} catch (e) {
|
|
88
|
-
if (e.status === 402) ui.showToast("Channels are a Pro feature β press
|
|
88
|
+
if (e.status === 402) ui.showToast("Channels are a Pro feature β press 7 β u to upgrade", "yellow");
|
|
89
89
|
else ui.showToast(e.message, "red");
|
|
90
90
|
}
|
|
91
91
|
},
|
|
@@ -145,7 +145,7 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
|
|
|
145
145
|
body = h(StateLine, { kind: "loading", spin: SPINNER[tick % SPINNER.length], text: tab === 0 ? "loading rulesβ¦" : "loading channelsβ¦" });
|
|
146
146
|
} else if (err) {
|
|
147
147
|
// Don't let a network/API error masquerade as "no rules" (a false empty).
|
|
148
|
-
body = h(StateLine, { kind: "error", text: `couldn't load ${tab === 0 ? "rules" : "channels"} β ${err}
|
|
148
|
+
body = h(StateLine, { kind: "error", text: `couldn't load ${tab === 0 ? "rules" : "channels"} β ${err}`, hint: "g retries" });
|
|
149
149
|
} else if (tab === 0) {
|
|
150
150
|
if (!ruleList.length) {
|
|
151
151
|
body = h(EmptyCard, { title: "No alert rules yet", lines: ["Stay ahead of your model timeline β a heads-up 90, 30, 7, and 1 day before anything you use is deprecated or retired.", "Press n to set the sensible default (your models Β· in-app + email Β· those lead times)."], width });
|
|
@@ -179,7 +179,7 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
|
|
|
179
179
|
Box,
|
|
180
180
|
{ flexDirection: "column" },
|
|
181
181
|
h(EmptyCard, { title: "No channels", lines: ["Press n to add one (Pro)."], width }),
|
|
182
|
-
h(Text, { color: "#d97706" }, " Slack/Discord/SMS/webhook channels need Pro β press
|
|
182
|
+
h(Text, { color: "#d97706" }, " Slack/Discord/SMS/webhook channels need Pro β press 7 β u to upgrade."),
|
|
183
183
|
);
|
|
184
184
|
} else {
|
|
185
185
|
const curIdx = clampCursor(cursor, chanList.length);
|
|
@@ -200,7 +200,7 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
|
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
const hint = tab === 0
|
|
203
|
-
? " Rules watch the registry
|
|
203
|
+
? " Rules watch the registry for lifecycle changes and alert your channels."
|
|
204
204
|
: " Channels deliver alerts: Slack/Discord/SMS/webhook are Pro; in-app + email are always on.";
|
|
205
205
|
return h(
|
|
206
206
|
Box,
|
|
@@ -216,7 +216,7 @@ export const meta = {
|
|
|
216
216
|
{ k: "ββ", label: "tab" },
|
|
217
217
|
{ k: "ββ", label: "nav" },
|
|
218
218
|
{ k: "n", label: "new" },
|
|
219
|
-
{ k: "space", label: "
|
|
219
|
+
{ k: "space", label: "toggle" },
|
|
220
220
|
{ k: "c", label: "cadence" },
|
|
221
221
|
{ k: "t", label: "test" },
|
|
222
222
|
{ k: "d", label: "delete" },
|
|
@@ -156,13 +156,13 @@ export function IntegrationsView({ client, me, ui, active, width = 78, height =
|
|
|
156
156
|
// Column budget: rail(1) + toggle(2) + label + gap(1) + kind(10) + status(rest) + env(8).
|
|
157
157
|
const ENV_W = 8;
|
|
158
158
|
const KIND_W = 10;
|
|
159
|
-
const LABEL_W =
|
|
159
|
+
const LABEL_W = 35;
|
|
160
160
|
const restW = Math.max(8, width - 1 - 2 - LABEL_W - 1 - KIND_W - ENV_W);
|
|
161
161
|
|
|
162
162
|
// Status string per row: not-installed βΊ off/available βΊ on βΊ onΒ·authorized / auth failed.
|
|
163
163
|
function statusFor(r) {
|
|
164
164
|
if (!r.hasCmd) return { text: `${r.meta.requiresCmd} not installed`, color: C.FG_FAINT };
|
|
165
|
-
if (r.probe && !r.probe.connected) return { text:
|
|
165
|
+
if (r.probe && !r.probe.connected) return { text: `auth failed${r.probe.reason ? ` Β· ${r.probe.reason}` : ""}`, color: "#dc2626" };
|
|
166
166
|
if (r.probe && r.probe.connected) {
|
|
167
167
|
const who = r.probe.account ? ` Β· ${r.probe.account}` : "";
|
|
168
168
|
return { text: `authorized${who}`, color: "#16a34a" };
|
|
@@ -190,7 +190,7 @@ export function IntegrationsView({ client, me, ui, active, width = 78, height =
|
|
|
190
190
|
const isCur = i === curIdx;
|
|
191
191
|
const st = statusFor(r);
|
|
192
192
|
const spin = probing === r.id ? `${SPINNER[tick % SPINNER.length]} ` : "";
|
|
193
|
-
const et = envTagSeg(r.env);
|
|
193
|
+
const et = r.env === "unknown" ? { text: "env ?", color: C.FG_FAINT } : envTagSeg(r.env);
|
|
194
194
|
const cells = [
|
|
195
195
|
{ text: `${r.enabled ? GLYPH.check : GLYPH.dot} `, color: r.enabled ? C.ACCENT : C.FG_FAINT },
|
|
196
196
|
{ text: cellE(r.meta.label, LABEL_W), color: isCur ? C.FG_STRONG : r.enabled ? C.FG : C.FG_FAINT, bold: isCur },
|
|
@@ -204,7 +204,7 @@ export function IntegrationsView({ client, me, ui, active, width = 78, height =
|
|
|
204
204
|
);
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
const hint = " Toggle which live deployments scan by default
|
|
207
|
+
const hint = " Toggle which live deployments mm scan checks by default. Secrets stay on your machine.";
|
|
208
208
|
return h(
|
|
209
209
|
Box,
|
|
210
210
|
{ flexDirection: "column" },
|
|
@@ -218,7 +218,7 @@ export const meta = {
|
|
|
218
218
|
{ k: "ββ", label: "nav" },
|
|
219
219
|
{ k: "space", label: "toggle" },
|
|
220
220
|
{ k: "e", label: "env" },
|
|
221
|
-
{ k: "t", label: "test" },
|
|
221
|
+
{ k: "t", label: "test auth" },
|
|
222
222
|
{ k: "g", label: "refresh" },
|
|
223
223
|
],
|
|
224
224
|
};
|
|
@@ -14,18 +14,21 @@ import { sourceOf, SOURCE_META, sourceGlyph } from "../source-meta.js";
|
|
|
14
14
|
|
|
15
15
|
const ENV_ORDER = ["prod", "staging", "dev", "unknown"];
|
|
16
16
|
|
|
17
|
+
// Ordered by survival priority β KeyBar truncates the tail on narrow terminals,
|
|
18
|
+
// so recovery (g) and the populate verbs (r/n) must outlive the per-row
|
|
19
|
+
// secondaries, and destructive keys come last.
|
|
17
20
|
export const meta = {
|
|
18
21
|
keys: [
|
|
19
22
|
{ k: "ββ", label: "nav" },
|
|
20
23
|
{ k: "/", label: "search" },
|
|
24
|
+
{ k: "g", label: "refresh" },
|
|
25
|
+
{ k: "r", label: "rescan" },
|
|
26
|
+
{ k: "n", label: "new" },
|
|
21
27
|
{ k: "e", label: "env" },
|
|
22
|
-
{ k: "t", label: "tag
|
|
28
|
+
{ k: "t", label: "tag" },
|
|
23
29
|
{ k: "c", label: "critical" },
|
|
24
30
|
{ k: "d", label: "delete" },
|
|
25
31
|
{ k: "C", label: "clear all" },
|
|
26
|
-
{ k: "r", label: "rescan" },
|
|
27
|
-
{ k: "n", label: "new" },
|
|
28
|
-
{ k: "g", label: "refresh" },
|
|
29
32
|
],
|
|
30
33
|
};
|
|
31
34
|
|
|
@@ -84,12 +87,18 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
|
|
|
84
87
|
return abs ? readSnippet(abs, cur.source_line, matchStr) : null;
|
|
85
88
|
}, [cur?.source_path, cur?.source_line, matchStr, dir]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
86
89
|
|
|
87
|
-
// Push health legend + tracked context up to the shell status bar.
|
|
90
|
+
// Push health legend + tracked context up to the shell status bar. While the
|
|
91
|
+
// fetch is failed/loading the truth is UNKNOWN β suppress the zeroed counts
|
|
92
|
+
// and say so, rather than asserting "0 tracked".
|
|
88
93
|
React.useEffect(() => {
|
|
94
|
+
if (q.error || q.loading) {
|
|
95
|
+
ui?.reportStatus?.({ context: q.error ? "offline" : "loadingβ¦" });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
89
98
|
const counts = { ok: 0, deprecating: 0, retiring: 0, retired: 0, custom: 0 };
|
|
90
99
|
for (const u of usages) if (counts[u.health] != null) counts[u.health] += 1;
|
|
91
100
|
ui?.reportStatus?.({ counts, context: query ? `${filtered.length} of ${usages.length}` : `${usages.length} tracked` });
|
|
92
|
-
}, [usages, ui, query, filtered.length]);
|
|
101
|
+
}, [usages, ui, query, filtered.length, q.error, q.loading]);
|
|
93
102
|
|
|
94
103
|
async function patch(u, body, label) {
|
|
95
104
|
try {
|
|
@@ -161,28 +170,39 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
|
|
|
161
170
|
return patch(cur, { environment: ENV_ORDER[(i + 1) % ENV_ORDER.length] }, "env updated");
|
|
162
171
|
}
|
|
163
172
|
if (input === "c") return patch(cur, { is_critical: !cur.is_critical }, "critical toggled");
|
|
164
|
-
if (input === "d")
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
+
if (input === "d") {
|
|
174
|
+
// One stray keypress shouldn't silently delete a cloud-synced usage β
|
|
175
|
+
// confirm like C clear-all does (proportionally lighter: a single y).
|
|
176
|
+
const name = cur.model_display || cur.custom_model_name || "this usage";
|
|
177
|
+
return ui.askPrompt(`Delete ${name}? type y`, {
|
|
178
|
+
onSubmit: (v) => {
|
|
179
|
+
if (String(v || "").trim().toLowerCase() !== "y") return ui.showToast("delete cancelled");
|
|
180
|
+
client
|
|
181
|
+
.deleteUsage(cur.id)
|
|
182
|
+
.then(() => {
|
|
183
|
+
ui.showToast("deleted");
|
|
184
|
+
setCursor((c) => clampCursor(c, filtered.length - 1));
|
|
185
|
+
q.reload();
|
|
186
|
+
})
|
|
187
|
+
.catch((e) => ui.showToast(e.message, "red"));
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
}
|
|
173
191
|
},
|
|
174
192
|
{ isActive: active },
|
|
175
193
|
);
|
|
176
194
|
|
|
177
195
|
if (q.loading) return h(StateLine, { kind: "loading", spin, text: "loading inventoryβ¦" });
|
|
178
|
-
if (q.error) return h(StateLine, { kind: "error", text: q.error });
|
|
196
|
+
if (q.error) return h(StateLine, { kind: "error", text: `couldn't load inventory β ${q.error}`, hint: "g retries" });
|
|
179
197
|
if (!usages.length)
|
|
180
198
|
return h(EmptyCard, {
|
|
181
199
|
icon: GLYPH.spark,
|
|
182
200
|
title: "Let's find your AI models",
|
|
183
201
|
lines: [
|
|
184
|
-
|
|
185
|
-
|
|
202
|
+
// Name this tab's OWN keys (r/n in the keybar below), and be honest that
|
|
203
|
+
// the cloud inventory fills from the Scan tab's upload, not the scan alone.
|
|
204
|
+
"Press r to scan this repo, then u on the Scan tab to upload what it finds.",
|
|
205
|
+
"Or press n to add one by name β takes about 30 seconds.",
|
|
186
206
|
],
|
|
187
207
|
width,
|
|
188
208
|
});
|
package/src/tui/views/local.js
CHANGED
|
@@ -19,15 +19,18 @@ import { readSnippet } from "../snippet.js";
|
|
|
19
19
|
import { openLocation } from "../../openUrl.js";
|
|
20
20
|
import { addGlobalIgnore } from "../../sources/filesystem.js";
|
|
21
21
|
|
|
22
|
+
// Ordered by survival priority β KeyBar drops trailing entries on narrow
|
|
23
|
+
// terminals, so the primary verb (u push) and recovery (g rescan) must come
|
|
24
|
+
// before the secondaries. "p pause" lives in the in-strip hint while a scan
|
|
25
|
+
// runs (the only time it works), never here.
|
|
22
26
|
export const meta = {
|
|
23
27
|
keys: [
|
|
24
|
-
{ k: "ββ", label: "
|
|
28
|
+
{ k: "ββ", label: "nav" },
|
|
25
29
|
{ k: "β΅", label: "refs" },
|
|
26
|
-
{ k: "
|
|
27
|
-
{ k: "/", label: "search" },
|
|
28
|
-
{ k: "p", label: "pause" },
|
|
30
|
+
{ k: "u", label: "push β Inv" },
|
|
29
31
|
{ k: "g", label: "rescan" },
|
|
30
|
-
{ k: "
|
|
32
|
+
{ k: "/", label: "search" },
|
|
33
|
+
{ k: "e", label: "exclude" },
|
|
31
34
|
],
|
|
32
35
|
};
|
|
33
36
|
|
|
@@ -169,6 +172,16 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
|
|
|
169
172
|
return () => setHandlesBack?.(false);
|
|
170
173
|
}, [setHandlesBack, focus, search.query]);
|
|
171
174
|
|
|
175
|
+
// The refs drill-in swaps the keymap (β΅ opens in editor, esc backs out) β the
|
|
176
|
+
// keybar must say so instead of advertising the dead list keys.
|
|
177
|
+
const setKeys = ui?.setKeys;
|
|
178
|
+
React.useEffect(() => {
|
|
179
|
+
setKeys?.(focus === "refs"
|
|
180
|
+
? [{ k: "ββ", label: "nav" }, { k: "β΅", label: "open in editor" }, { k: "e", label: "exclude path" }, { k: "esc", label: "back" }]
|
|
181
|
+
: null);
|
|
182
|
+
return () => setKeys?.(null);
|
|
183
|
+
}, [setKeys, focus]);
|
|
184
|
+
|
|
172
185
|
useInput(
|
|
173
186
|
(input, key) => {
|
|
174
187
|
if (!active) return;
|
|
@@ -211,7 +224,7 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
|
|
|
211
224
|
{ isActive: active },
|
|
212
225
|
);
|
|
213
226
|
|
|
214
|
-
if (phase === "error") return h(StateLine, { kind: "error", text: scan.error });
|
|
227
|
+
if (phase === "error") return h(StateLine, { kind: "error", text: scan.error, hint: "g rescans" });
|
|
215
228
|
|
|
216
229
|
// ----- strip (1 line) -----
|
|
217
230
|
const counters = `${fmtNum(filesScanned)} files Β· ${fmtNum(dirsSeen)} dirs Β· ${fmtNum(candidateCount)} refs${catalogsSkipped ? ` Β· ${catalogsSkipped} catalog${catalogsSkipped === 1 ? "" : "s"} skipped` : ""}`;
|
|
@@ -224,14 +237,15 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
|
|
|
224
237
|
else if (phase === "scanning")
|
|
225
238
|
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"));
|
|
226
239
|
else
|
|
240
|
+
// Lead with the verdict (what needs attention), then the scan housekeeping β
|
|
241
|
+
// the warning is the one thing the user came for; stats are context.
|
|
227
242
|
strip = h(
|
|
228
243
|
Box,
|
|
229
244
|
{},
|
|
230
|
-
h(Text, { color: "#16a34a" }, ` ${GLYPH.check} `),
|
|
231
|
-
h(Text, { color: C.FG_DIM }, fromCache ? `loaded last scan${scannedAt ? ` (${agoText(Date.now() - scannedAt)})` : ""} Β· ${fmtNum(candidateCount)} refs Β· g rescan` : `scanned just now Β· ${counters} Β· g rescan`),
|
|
232
245
|
attention > 0
|
|
233
|
-
? h(Text, { color: "#d97706" }, `
|
|
234
|
-
: h(Text, { color: "#16a34a" },
|
|
246
|
+
? h(Text, { color: "#d97706" }, ` ${GLYPH.warn} ${attention} need attention`)
|
|
247
|
+
: h(Text, { color: "#16a34a" }, ` ${GLYPH.check} all current`),
|
|
248
|
+
h(Text, { color: C.FG_DIM }, fromCache ? ` scanned ${scannedAt ? agoText(Date.now() - scannedAt) : "earlier"} Β· ${fmtNum(candidateCount)} refs Β· g rescan` : ` scanned just now Β· ${counters} Β· g rescan`),
|
|
235
249
|
);
|
|
236
250
|
|
|
237
251
|
// ----- list rows (full width) -----
|
package/src/tui/views/scan.js
CHANGED
|
@@ -36,16 +36,19 @@ import { track } from "../../telemetry.js";
|
|
|
36
36
|
import { openUrl, openLocation } from "../../openUrl.js";
|
|
37
37
|
import { boardSize, MIN_W, MIN_H } from "../game/dk-core.js";
|
|
38
38
|
|
|
39
|
+
// Ordered by survival priority β KeyBar truncates the tail on narrow terminals,
|
|
40
|
+
// and `u upload` is this tab's entire purpose: it must never be the key that
|
|
41
|
+
// gets hidden behind the "β¦".
|
|
39
42
|
export const meta = {
|
|
40
43
|
keys: [
|
|
41
44
|
{ k: "ββ", label: "nav" },
|
|
42
45
|
{ k: "space", label: "toggle" },
|
|
46
|
+
{ k: "u", label: "upload" },
|
|
43
47
|
{ k: "a/x", label: "all/none" },
|
|
44
48
|
{ k: "β΅", label: "refs" },
|
|
45
49
|
{ k: "/", label: "search" },
|
|
46
50
|
{ k: "g", label: "rescan" },
|
|
47
|
-
{ k: "
|
|
48
|
-
{ k: "P", label: "play" }, // launches anytime (also the global Shift-P)
|
|
51
|
+
{ k: "P", label: "play π¦" }, // launches anytime (also the global Shift-P)
|
|
49
52
|
{ k: "N", label: "new project" },
|
|
50
53
|
],
|
|
51
54
|
};
|
|
@@ -162,7 +165,10 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
|
|
|
162
165
|
|
|
163
166
|
React.useEffect(() => {
|
|
164
167
|
const counts = countHealth(items);
|
|
165
|
-
|
|
168
|
+
// "p <project>" self-documents the otherwise-invisible p key (cycles the
|
|
169
|
+
// upload target) β important because the adjacent Here tab used to teach
|
|
170
|
+
// people that p means pause.
|
|
171
|
+
const ctx = `p ${project ? project.name : "auto"} Β· ${selCount}/${items.length} sel`;
|
|
166
172
|
ui?.reportStatus?.({ counts, context: ctx });
|
|
167
173
|
}, [items, selCount, project, ui]);
|
|
168
174
|
|
|
@@ -174,6 +180,16 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
|
|
|
174
180
|
return () => setHandlesBack?.(false);
|
|
175
181
|
}, [setHandlesBack, focus, search.query]);
|
|
176
182
|
|
|
183
|
+
// The refs drill-in swaps the keymap β advertise the real keys, not the dead
|
|
184
|
+
// list ones (same pattern as the Here tab).
|
|
185
|
+
const setKeys = ui?.setKeys;
|
|
186
|
+
React.useEffect(() => {
|
|
187
|
+
setKeys?.(focus === "refs"
|
|
188
|
+
? [{ k: "ββ", label: "nav" }, { k: "β΅", label: "open in editor" }, { k: "e", label: "exclude path" }, { k: "esc", label: "back" }]
|
|
189
|
+
: null);
|
|
190
|
+
return () => setKeys?.(null);
|
|
191
|
+
}, [setKeys, focus]);
|
|
192
|
+
|
|
177
193
|
function openRef(r) {
|
|
178
194
|
if (!r) return;
|
|
179
195
|
const abs = path.resolve(dir, r.source_path || r.location_label || "");
|
|
@@ -285,7 +301,7 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
|
|
|
285
301
|
{ isActive: active },
|
|
286
302
|
);
|
|
287
303
|
|
|
288
|
-
if (scan.phase === "error") return h(StateLine, { kind: "error", text: scan.error });
|
|
304
|
+
if (scan.phase === "error") return h(StateLine, { kind: "error", text: scan.error, hint: "g rescans" });
|
|
289
305
|
if (running && !items.length)
|
|
290
306
|
return h(
|
|
291
307
|
Box,
|
|
@@ -319,7 +335,7 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
|
|
|
319
335
|
|
|
320
336
|
// While the walk is still running (and the terminal fits a board), nudge the
|
|
321
337
|
// "Play Donkey Kong while you wait" affordance on the showing line.
|
|
322
|
-
const playHint = running && canPlay ? " Β· P play" : "";
|
|
338
|
+
const playHint = running && canPlay ? " Β· P play π¦" : "";
|
|
323
339
|
const showingLine = h(
|
|
324
340
|
Text,
|
|
325
341
|
{ color: C.FG_FAINT },
|
|
@@ -13,24 +13,19 @@ import { loadConfig, setConfigValue } from "../../config.js";
|
|
|
13
13
|
|
|
14
14
|
const TABS = ["Registry", "Alerts", "Drift"];
|
|
15
15
|
|
|
16
|
-
export const meta = {
|
|
17
|
-
keys: [
|
|
18
|
-
{ k: "ββ", label: "section" },
|
|
19
|
-
{ k: "ββ", label: "scroll" },
|
|
20
|
-
{ k: "m", label: "mark seen" },
|
|
21
|
-
{ k: "r", label: "drift" },
|
|
22
|
-
],
|
|
23
|
-
};
|
|
24
|
-
|
|
25
16
|
// Per-section keybars β the active actions differ by tab (Registry: mark seen Β·
|
|
26
17
|
// Notifications: mark read Β· Drift: rescan + archive). Published via ui.setKeys()
|
|
27
18
|
// so the keybar never advertises a key that's dead on the current section.
|
|
28
19
|
const TAB_KEYS = [
|
|
29
|
-
[{ k: "ββ", label: "section" }, { k: "ββ", label: "
|
|
30
|
-
[{ k: "ββ", label: "section" }, { k: "ββ", label: "
|
|
31
|
-
[{ k: "ββ", label: "section" }, { k: "ββ", label: "
|
|
20
|
+
[{ k: "ββ", label: "section" }, { k: "ββ", label: "nav" }, { k: "m", label: "mark all seen" }, { k: "g", label: "refresh" }],
|
|
21
|
+
[{ k: "ββ", label: "section" }, { k: "ββ", label: "nav" }, { k: "o", label: "mark read" }, { k: "g", label: "refresh" }],
|
|
22
|
+
[{ k: "ββ", label: "section" }, { k: "ββ", label: "nav" }, { k: "r", label: "rescan" }, { k: "a", label: "archive" }],
|
|
32
23
|
];
|
|
33
24
|
|
|
25
|
+
// The static fallback must match the default (Registry) section exactly β
|
|
26
|
+
// advertising `m`/`r` together implies both work everywhere, and they don't.
|
|
27
|
+
export const meta = { keys: TAB_KEYS[0] };
|
|
28
|
+
|
|
34
29
|
export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14 }) {
|
|
35
30
|
const [tab, setTab] = React.useState(0);
|
|
36
31
|
const [cursor, setCursor] = React.useState(0);
|
|
@@ -93,6 +88,13 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
93
88
|
if (key.rightArrow) return (setTab((t) => (t + 1) % TABS.length), setCursor(0));
|
|
94
89
|
if (key.downArrow || input === "j") return setCursor((c) => c + 1);
|
|
95
90
|
if (key.upArrow || input === "k") return setCursor((c) => Math.max(0, c - 1));
|
|
91
|
+
// g refreshes whichever section is showing β the universal retry across tabs
|
|
92
|
+
// (and the escape hatch when a fetch fails).
|
|
93
|
+
if (input === "g") {
|
|
94
|
+
if (tab === 0) return reg.reload();
|
|
95
|
+
if (tab === 1) return notif.reload();
|
|
96
|
+
return runDrift();
|
|
97
|
+
}
|
|
96
98
|
|
|
97
99
|
if (tab === 0) {
|
|
98
100
|
if (input === "m") {
|
|
@@ -110,7 +112,8 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
110
112
|
} else if (tab === 2) {
|
|
111
113
|
if (input === "r") return runDrift();
|
|
112
114
|
const gone = drift?.gone || [];
|
|
113
|
-
|
|
115
|
+
// Same visible-rows clamp as the render β archive only what's on screen.
|
|
116
|
+
const cur = gone[clampCursor(cursor, Math.min(gone.length, 7))];
|
|
114
117
|
if (input === "a" && cur)
|
|
115
118
|
client.deleteUsage(cur.id).then(() => {
|
|
116
119
|
ui.showToast("archived");
|
|
@@ -123,17 +126,19 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
123
126
|
|
|
124
127
|
// Push per-tab context up to the shell status bar.
|
|
125
128
|
React.useEffect(() => {
|
|
129
|
+
// On a failed fetch the count is UNKNOWN, not zero β say "offline", don't
|
|
130
|
+
// assert "0 changes".
|
|
126
131
|
let context;
|
|
127
|
-
if (tab === 0) context = `${reg.data?.events.length || 0} changes`;
|
|
128
|
-
else if (tab === 1) context = `${(notif.data || []).length} alerts`;
|
|
132
|
+
if (tab === 0) context = reg.error ? "offline" : `${reg.data?.events.length || 0} changes`;
|
|
133
|
+
else if (tab === 1) context = notif.error ? "offline" : `${(notif.data || []).length} alerts`;
|
|
129
134
|
else context = drift && !drift.loading && !drift.error ? `+${drift.added.length} / -${drift.gone.length}` : "drift";
|
|
130
135
|
ui?.reportStatus?.({ context });
|
|
131
|
-
}, [tab, reg.data, notif.data, drift, ui]);
|
|
136
|
+
}, [tab, reg.data, reg.error, notif.data, notif.error, drift, ui]);
|
|
132
137
|
|
|
133
138
|
let body;
|
|
134
139
|
if (tab === 0) {
|
|
135
140
|
if (reg.loading) body = h(StateLine, { kind: "loading", spin, text: "loading registry changesβ¦" });
|
|
136
|
-
else if (reg.error) body = h(StateLine, { kind: "error", text: reg.error });
|
|
141
|
+
else if (reg.error) body = h(StateLine, { kind: "error", text: `couldn't load registry changes β ${reg.error}`, hint: "g retries" });
|
|
137
142
|
else {
|
|
138
143
|
const events = reg.data.events;
|
|
139
144
|
if (!events.length) body = h(Text, { color: C.FG_DIM }, " No registry changes recorded yet.");
|
|
@@ -161,31 +166,37 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
161
166
|
} else if (tab === 1) {
|
|
162
167
|
const list = notif.data || [];
|
|
163
168
|
if (notif.loading) body = h(StateLine, { kind: "loading", spin, text: "loading alertsβ¦" });
|
|
164
|
-
else if (notif.error) body = h(StateLine, { kind: "error", text: notif.error });
|
|
165
|
-
else if (!list.length) body = h(Text, { color: C.FG_DIM }, " No alerts yet.
|
|
169
|
+
else if (notif.error) body = h(StateLine, { kind: "error", text: `couldn't load alerts β ${notif.error}`, hint: "g retries" });
|
|
170
|
+
else if (!list.length) body = h(Text, { color: C.FG_DIM }, " No alerts yet. Press 6 to set up alert rules.");
|
|
166
171
|
else {
|
|
172
|
+
// Window the list around the cursor so ββ can reach every row β a fixed
|
|
173
|
+
// slice(0, ROWS) lets the selection walk below the visible page and `o`
|
|
174
|
+
// act on a row the user can't see.
|
|
167
175
|
const cur = clampCursor(cursor, list.length);
|
|
176
|
+
const start = Math.max(0, Math.min(cur - ROWS + 1, list.length - ROWS));
|
|
168
177
|
body = h(
|
|
169
178
|
Box,
|
|
170
179
|
{ flexDirection: "column" },
|
|
171
|
-
...list.slice(
|
|
180
|
+
...list.slice(start, start + ROWS).map((n, i) => {
|
|
172
181
|
const cells = [
|
|
173
182
|
{ text: `${n.unread ? GLYPH.bullet : GLYPH.custom} `, color: n.unread ? C.ACCENT : C.FG_FAINT },
|
|
174
183
|
{ text: cellE(n.title, 40), color: n.unread ? C.FG : C.FG_DIM },
|
|
175
184
|
{ text: " ", color: C.FG },
|
|
176
185
|
{ text: String(n.when || "").slice(0, 10).padEnd(10), color: C.FG_DIM },
|
|
177
186
|
];
|
|
178
|
-
return h(ListRow, { key: n.id, active: i === cur, cells, width });
|
|
187
|
+
return h(ListRow, { key: n.id, active: start + i === cur, cells, width });
|
|
179
188
|
}),
|
|
180
189
|
);
|
|
181
190
|
}
|
|
182
191
|
} else {
|
|
183
192
|
if (!drift) body = h(Text, { color: C.FG_DIM }, ` Press r to scan ${dir} and compare against tracked usages.`);
|
|
184
193
|
else if (drift.loading) body = h(StateLine, { kind: "loading", spin, text: "scanning for driftβ¦" });
|
|
185
|
-
else if (drift.error) body = h(StateLine, { kind: "error", text: drift.error });
|
|
194
|
+
else if (drift.error) body = h(StateLine, { kind: "error", text: `drift scan failed β ${drift.error}`, hint: "r rescans" });
|
|
186
195
|
else {
|
|
187
196
|
const gone = drift.gone || [];
|
|
188
|
-
|
|
197
|
+
// Clamp the archive cursor to the VISIBLE rows β `a` must never act on a
|
|
198
|
+
// row below the 7 shown.
|
|
199
|
+
const curGone = clampCursor(cursor, Math.min(gone.length, 7));
|
|
189
200
|
body = h(
|
|
190
201
|
Box,
|
|
191
202
|
{ flexDirection: "column" },
|
|
@@ -206,6 +217,7 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
206
217
|
];
|
|
207
218
|
return h(ListRow, { key: "a" + i, active: false, cells, width });
|
|
208
219
|
}),
|
|
220
|
+
drift.added.length > 5 ? h(Text, { color: C.FG_DIM }, ` β¦ ${drift.added.length - 5} more new`) : null,
|
|
209
221
|
...gone.slice(0, 7).map((u, i) => {
|
|
210
222
|
const cells = [
|
|
211
223
|
{ text: "- ", color: "#dc2626" },
|
|
@@ -215,6 +227,7 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
215
227
|
];
|
|
216
228
|
return h(ListRow, { key: "g" + i, active: i === curGone, cells, width });
|
|
217
229
|
}),
|
|
230
|
+
gone.length > 7 ? h(Text, { color: C.FG_DIM }, ` β¦ ${gone.length - 7} more gone`) : null,
|
|
218
231
|
);
|
|
219
232
|
}
|
|
220
233
|
}
|