@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelstatus/cli",
3
- "version": "0.1.58",
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
- const color = e === "prod" ? C.ACCENT : e === "staging" ? "#a78bfa" : C.FG_FAINT;
163
- return { text: e, color };
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") return h(Text, {}, h(Text, { color: "#dc2626" }, ` ${GLYPH.cross} `), h(Text, { color: "#dc2626" }, text));
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
  }
@@ -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
- if (me?.plan && me.plan !== "free") return ui.showToast(`already on ${me.plan}`, "yellow");
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 = isFree
73
- ? h(Text, { color: C.FG_FAINT }, " free ")
74
- : h(Text, { backgroundColor: BG_ON ? "#16a34a" : undefined, color: C.ACCENT_INK, bold: true }, " PRO ");
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(Text, { color: C.FG_FAINT }, `config: ${configFilePath}`),
101
+ h(Row, { label: "config", value: configFilePath, color: C.FG_FAINT }),
94
102
  ),
95
103
  h(Text, {}, ""),
96
- isFree
97
- ? h(
98
- Box,
99
- { flexDirection: "column" },
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."),
102
- )
103
- : h(Text, { color: "#16a34a" }, `${GLYPH.check} Pro features unlocked. Thanks for supporting LLM Status!`),
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
  }
@@ -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
- const ENV_ORDER = ["prod", "staging", "dev", "unknown"];
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
- if (input === "p") return setProjectIdx((i) => (projects.length ? (i + 1) % projects.length : 0));
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
- if (key.return || input === "s") return submit();
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 : "", "(new: Manual)"),
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
- h(Text, { color: C.FG_FAINT }, "m model Β· p project Β· e env Β· ↡ save (auto-matched to the registry, else tracked as custom)"),
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
  }
@@ -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: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "n", label: "new rule" }, { k: "space", label: "enable" }, { k: "c", label: "cadence" }, { k: "d", label: "delete" }, { k: "g", label: "refresh" }],
31
- [{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "n", label: "add channel" }, { k: "t", label: "test" }, { k: "g", label: "refresh" }],
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 6 β†’ u to upgrade", "yellow");
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}. Press g to retry.` });
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 6 β†’ u to upgrade."),
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 β€” deprecations Β· retirements Β· replacements Β· new models β€” and alert your channels."
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: "enable" },
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 = 30;
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: "auth failed", color: "#dc2626" };
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 Β· space on/off Β· t authorize Β· e env. Secrets stay on your machine.";
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 untagged" },
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
- return client
166
- .deleteUsage(cur.id)
167
- .then(() => {
168
- ui.showToast("deleted");
169
- setCursor((c) => clampCursor(c, filtered.length - 1));
170
- q.reload();
171
- })
172
- .catch((e) => ui.showToast(e.message, "red"));
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
- "Press 4 Scan to auto-detect every model used in this repo.",
185
- "Or press 5 Add to enter one by name β€” takes about 30 seconds.",
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
  });
@@ -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: "scroll" },
28
+ { k: "↑↓", label: "nav" },
25
29
  { k: "↡", label: "refs" },
26
- { k: "e", label: "exclude" },
27
- { k: "/", label: "search" },
28
- { k: "p", label: "pause" },
30
+ { k: "u", label: "push β†’ Inv" },
29
31
  { k: "g", label: "rescan" },
30
- { k: "u", label: "push all β†’ inventory" },
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" }, ` ${GLYPH.warn} ${attention} need attention`)
234
- : h(Text, { color: "#16a34a" }, " all current"),
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) -----
@@ -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: "u", label: "upload all" },
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
- const ctx = `${project ? project.name + " Β· " : ""}${selCount}/${items.length} sel`;
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: "scroll" }, { k: "m", label: "mark all seen" }],
30
- [{ k: "←→", label: "section" }, { k: "↑↓", label: "scroll" }, { k: "o", label: "mark read" }],
31
- [{ k: "←→", label: "section" }, { k: "↑↓", label: "scroll" }, { k: "r", label: "rescan" }, { k: "a", label: "archive" }],
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
- const cur = gone[clampCursor(cursor, gone.length)];
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. Configure rules in the Alerts view.");
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(0, ROWS).map((n, i) => {
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
- const curGone = clampCursor(cursor, gone.length);
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
  }