@modelstatus/cli 0.1.84 → 0.1.86
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api.js +2 -2
- package/src/changelog-data.js +20 -0
- package/src/index.js +104 -10
- package/src/tui/game/loop.js +3 -1
- package/src/tui/views/whatsnew.js +82 -28
- package/src/upgrade.js +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modelstatus/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.86",
|
|
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/api.js
CHANGED
|
@@ -90,7 +90,7 @@ export function createClient({ apiBase, apiKey }) {
|
|
|
90
90
|
listNotifications: (params) => req("GET", `/notifications${qs(params)}`),
|
|
91
91
|
readNotification: (id) => req("POST", `/notifications/${id}/read`),
|
|
92
92
|
|
|
93
|
-
// billing
|
|
94
|
-
checkout: () => req("POST",
|
|
93
|
+
// billing — plan: undefined → Pro ($5/yr subscription); "lifetime" → $29 one-time
|
|
94
|
+
checkout: (plan) => req("POST", `/billing/checkout${plan ? `?plan=${encodeURIComponent(plan)}` : ""}`),
|
|
95
95
|
};
|
|
96
96
|
}
|
package/src/changelog-data.js
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
/* GENERATED by scripts/gen-changelog.mjs from apps/web/lib/changelog.json — do not edit.
|
|
2
2
|
* Release notes baked into the binary (in-TUI + the on-load what's-new card). */
|
|
3
3
|
export const CHANGELOG = [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.86",
|
|
6
|
+
"date": "2026-06-16",
|
|
7
|
+
"title": "Search the What's New feed",
|
|
8
|
+
"items": [
|
|
9
|
+
"The What's New tab now has `/` search, like Scan and Inventory. Filter the registry feed, alerts, fixes, and releases by typing — e.g. `deprecated` to see just the deprecations, or a model or provider name to jump to a specific change.",
|
|
10
|
+
"Press `/` to start filtering, `esc` to clear; the count of matches shows as you type."
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"version": "0.1.85",
|
|
15
|
+
"date": "2026-06-14",
|
|
16
|
+
"title": "Command polish + safer billing",
|
|
17
|
+
"items": [
|
|
18
|
+
"`mm upgrade --lifetime` starts the one-time Lifetime checkout from the CLI (plain `mm upgrade` is still the $5/yr Pro plan).",
|
|
19
|
+
"`mm <command> --help` now prints per-command usage and flags; an unknown command exits non-zero instead of silently printing the help.",
|
|
20
|
+
"`mm clear` requires an explicit `--yes`/`--force` — it will no longer wipe your cloud inventory on `--ci` alone. `mm logout` no longer claims it removed a key when you weren't signed in, and warns if an env-var key still authenticates you.",
|
|
21
|
+
"Billing safety: checkout won't start a duplicate charge for an account that's already on Pro or Lifetime."
|
|
22
|
+
]
|
|
23
|
+
},
|
|
4
24
|
{
|
|
5
25
|
"version": "0.1.84",
|
|
6
26
|
"date": "2026-06-14",
|
package/src/index.js
CHANGED
|
@@ -72,7 +72,7 @@ if (process.argv[2] === "__bench_frames") {
|
|
|
72
72
|
const p = runGame({
|
|
73
73
|
width: 80, height: 24, scanStore: scan,
|
|
74
74
|
_inject: {
|
|
75
|
-
out, inp: input, proc: process, now,
|
|
75
|
+
out, inp: input, proc: process, now, noPersist: true, // bench: never write dkHighScore
|
|
76
76
|
schedule: (fn, ms) => setTimeout(() => { const t = now(); if (prev != null) emit({ t: "frame", dtMs: t - prev }); prev = t; fn(); }, ms),
|
|
77
77
|
cancel: (h) => clearTimeout(h),
|
|
78
78
|
},
|
|
@@ -181,8 +181,14 @@ async function cmdSignup(_positional, flags) {
|
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
function cmdLogout() {
|
|
184
|
+
const had = !!loadConfig().apiKey;
|
|
184
185
|
clearAuth();
|
|
185
|
-
console.log("✓ Signed out (API key removed). Run `mm login` to sign back in.");
|
|
186
|
+
if (had) console.log("✓ Signed out (saved API key removed). Run `mm login` to sign back in.");
|
|
187
|
+
else console.log("Not signed in — no saved API key to remove.");
|
|
188
|
+
// clearAuth only touches the config file; an env key still authenticates.
|
|
189
|
+
if (process.env.LLMSTATUS_API_KEY || process.env.MM_API_KEY) {
|
|
190
|
+
console.log("Note: an API key is still set via LLMSTATUS_API_KEY/MM_API_KEY — unset it to fully sign out.");
|
|
191
|
+
}
|
|
186
192
|
}
|
|
187
193
|
|
|
188
194
|
async function cmdUpgrade(_positional, flags) {
|
|
@@ -193,7 +199,9 @@ async function cmdUpgrade(_positional, flags) {
|
|
|
193
199
|
}
|
|
194
200
|
const client = createClient({ apiBase, apiKey });
|
|
195
201
|
const { upgradeViaBrowser } = await import("./upgrade.js");
|
|
196
|
-
|
|
202
|
+
// --lifetime opens the one-time $29 checkout; default is the $5/yr Pro subscription.
|
|
203
|
+
const checkoutPlan = flags.lifetime ? "lifetime" : undefined;
|
|
204
|
+
const plan = await upgradeViaBrowser({ client, plan: checkoutPlan });
|
|
197
205
|
if (!plan) {
|
|
198
206
|
console.error("Upgrade not detected (timed out). Run `mm upgrade` again if you completed checkout.");
|
|
199
207
|
process.exit(1);
|
|
@@ -554,9 +562,13 @@ async function cmdClear(_positional, flags) {
|
|
|
554
562
|
}
|
|
555
563
|
const all = !!flags.all;
|
|
556
564
|
const scope = all ? "ALL usages, projects, alert rules + the in-app feed" : "ALL usages";
|
|
557
|
-
|
|
565
|
+
// A destructive delete requires an EXPLICIT --yes/--force. `--ci` implies --yes
|
|
566
|
+
// for scan/fix non-interactivity, but it must NOT silently skip the safety
|
|
567
|
+
// prompt here — so check the raw argv, not the --ci-derived flags.yes.
|
|
568
|
+
const explicitConfirm = process.argv.includes("--yes") || process.argv.includes("--force");
|
|
569
|
+
if (!explicitConfirm) {
|
|
558
570
|
if (!process.stdin.isTTY) {
|
|
559
|
-
console.error(`Refusing to delete ${scope} without confirmation. Re-run with --yes.`);
|
|
571
|
+
console.error(`Refusing to delete ${scope} without confirmation. Re-run with --yes (or --force).`);
|
|
560
572
|
process.exit(1);
|
|
561
573
|
}
|
|
562
574
|
const ok = await confirm(`Delete ${scope} from your account? This cannot be undone. [y/N] `);
|
|
@@ -856,7 +868,7 @@ Usage:
|
|
|
856
868
|
mm integrations Manage live integrations (list | enable <id> | disable <id> | env <id> <tag>)
|
|
857
869
|
mm clear Delete all tracked usages from your inventory (--all also wipes projects/rules; --yes to skip the prompt)
|
|
858
870
|
mm update Update to the latest version now and relaunch (or add --update to any command)
|
|
859
|
-
mm upgrade Open Stripe checkout and poll until
|
|
871
|
+
mm upgrade Open Stripe checkout and poll until active (--lifetime for the one-time plan; this is the paid plan, not the binary)
|
|
860
872
|
mm play [dir] Play Donkey Kong while a background scan walks the dir (just for fun)
|
|
861
873
|
mm tui Force-launch the TUI (needs an interactive terminal)
|
|
862
874
|
|
|
@@ -875,7 +887,82 @@ Flags: --update · --api <url> · --key <key> · --project <id|name> · --dir <p
|
|
|
875
887
|
--sources <list> · --region <r> · --namespace <ns> · --kube-context <c> · --db <dsn> · --sql-table <t>
|
|
876
888
|
--vercel-project <p> · --vercel-team <t> · --gh-repo <owner/name> · --supabase-ref <ref>
|
|
877
889
|
|
|
878
|
-
Get started: \`mm login\` (opens your browser)
|
|
890
|
+
Get started: \`mm login\` (opens your browser).
|
|
891
|
+
|
|
892
|
+
Per-command help: \`mm <command> --help\` (e.g. \`mm ci --help\`).`;
|
|
893
|
+
|
|
894
|
+
/** Per-command usage. `mm <cmd> --help` / `mm help <cmd>` prints these; anything
|
|
895
|
+
* not listed falls back to the global HELP. Keep flags here in sync with parseArgs. */
|
|
896
|
+
const COMMAND_HELP = {
|
|
897
|
+
status: `mm status [dir] Offline, account-less model-health check (free).
|
|
898
|
+
|
|
899
|
+
Pulls the signed registry, scans the dir locally, prints each model in use with
|
|
900
|
+
its health + replacement, then the custom/unrecognized ids. Always exits 0 (it
|
|
901
|
+
informs; use \`mm ci\` to gate a build).
|
|
902
|
+
|
|
903
|
+
--json machine-readable {registry, scanned, references, files, models[], custom[], needs_attention}
|
|
904
|
+
--offline use the cached registry only (no network)
|
|
905
|
+
--sources <list> detection sources (default: filesystem + enabled integrations; "all" for everything)
|
|
906
|
+
--dir <path> directory to scan (alternative to the positional arg)`,
|
|
907
|
+
fix: `mm fix [dir] Rewrite dying model ids to their registry replacement, in place.
|
|
908
|
+
|
|
909
|
+
Boundary-safe, style-preserving, chain-resolved. Only filesystem refs with a known
|
|
910
|
+
replacement are touched. Asks before writing (a non-TTY needs --yes).
|
|
911
|
+
|
|
912
|
+
--dry-run preview the rewrites, write nothing
|
|
913
|
+
--json machine output ({planned, dryRun} for --dry-run; {applied, stale, failed} on apply)
|
|
914
|
+
--yes skip the confirmation prompt
|
|
915
|
+
--model <slug> only fix this one model (full provider/slug)
|
|
916
|
+
--offline use the cached registry only`,
|
|
917
|
+
ci: `mm ci [dir] CI gate: fail the build on deprecated/retiring models.
|
|
918
|
+
|
|
919
|
+
Exits non-zero when a finding is at/above --fail-on. Emits GitHub annotations +
|
|
920
|
+
a step summary under GITHUB_ACTIONS. Offline-capable, no account.
|
|
921
|
+
|
|
922
|
+
--fail-on <none|deprecating|retiring|retired> threshold (default: retired)
|
|
923
|
+
--json print the full report to stdout
|
|
924
|
+
--json-out <file> write clean findings JSON to a file (stdout stays annotations-only)
|
|
925
|
+
--diff <base> limit findings to files changed vs base (auto on PRs via GITHUB_BASE_REF)
|
|
926
|
+
--report (Pro) sync this run's usages + a CI-run row to your account
|
|
927
|
+
--offline use the cached registry only`,
|
|
928
|
+
scan: `mm scan [dir] Scan for model usage and upload to your account's inventory (needs login).
|
|
929
|
+
|
|
930
|
+
On a TTY with no flags it opens the interactive Scan tab. --ci/--json/--yes run
|
|
931
|
+
non-interactively.
|
|
932
|
+
|
|
933
|
+
--dry-run show exactly what WOULD upload, upload nothing
|
|
934
|
+
--json / --ci machine output (--ci implies --yes + --json)
|
|
935
|
+
--yes upload without the interactive TUI
|
|
936
|
+
--project <id|name> route everything to one project (created if the name is new)
|
|
937
|
+
--sources <list> detection sources ("all" for everything)
|
|
938
|
+
--dir <path> directory to scan`,
|
|
939
|
+
clear: `mm clear Delete tracked usages from your cloud inventory (DESTRUCTIVE, needs login).
|
|
940
|
+
|
|
941
|
+
Requires an explicit --yes or --force (it will NOT proceed on --ci alone).
|
|
942
|
+
|
|
943
|
+
--all also wipe projects, alert rules + the in-app feed (a full reset)
|
|
944
|
+
--yes / --force confirm the delete (required on a non-TTY)
|
|
945
|
+
--json print the result counts`,
|
|
946
|
+
upgrade: `mm upgrade Open Stripe checkout and poll until your plan is active (needs login).
|
|
947
|
+
|
|
948
|
+
--lifetime buy the one-time Lifetime plan ($29) instead of Pro ($5/yr)`,
|
|
949
|
+
integrations: `mm integrations [list | enable <id> | disable <id> | env <id> <tag>]
|
|
950
|
+
|
|
951
|
+
Manage the local on/off state of the live integrations (the gate for what
|
|
952
|
+
\`mm scan\`/\`mm status\` run by default). Ids: aws-lambda, vercel, supabase-edge,
|
|
953
|
+
github-actions. \`env <id> <prod|staging|dev|unknown>\` sets a declared env.
|
|
954
|
+
|
|
955
|
+
--json (list only) machine-readable integration state`,
|
|
956
|
+
config: `mm config [analytics on|off] View or change local settings.
|
|
957
|
+
|
|
958
|
+
Bare \`mm config\` lists settings (analytics, update channel, config path).
|
|
959
|
+
\`mm config analytics on|off\` toggles anonymous usage analytics (also honored:
|
|
960
|
+
MM_NO_ANALYTICS=1, DO_NOT_TRACK=1, CI=1).`,
|
|
961
|
+
login: `mm login [api_key] Sign in. With no key, opens the browser and polls; or paste a key.
|
|
962
|
+
|
|
963
|
+
--key <key> paste an API key directly
|
|
964
|
+
--api <url> override the API base`,
|
|
965
|
+
};
|
|
879
966
|
|
|
880
967
|
/** Awaits the updater promise; prints a one-liner if an update completed. Never throws. */
|
|
881
968
|
async function maybePrintUpdate(promise) {
|
|
@@ -932,8 +1019,10 @@ async function main() {
|
|
|
932
1019
|
|
|
933
1020
|
// --help / -h / help: print usage + exit. MUST come before the no-arg → TUI
|
|
934
1021
|
// fallthrough below (a bare `mm` launches the TUI, but `mm --help` must not).
|
|
1022
|
+
// `mm <cmd> --help` and `mm help <cmd>` print per-command usage when we have it.
|
|
935
1023
|
if (cmd === "help" || flags.help || flags.h) {
|
|
936
|
-
|
|
1024
|
+
const topic = cmd === "help" ? positional[1] : cmd;
|
|
1025
|
+
console.log((topic && COMMAND_HELP[topic]) || HELP);
|
|
937
1026
|
return;
|
|
938
1027
|
}
|
|
939
1028
|
|
|
@@ -1007,8 +1096,13 @@ async function main() {
|
|
|
1007
1096
|
flags.dir = cmd;
|
|
1008
1097
|
await launchTui(positional[1], flags);
|
|
1009
1098
|
}
|
|
1010
|
-
else
|
|
1011
|
-
|
|
1099
|
+
else {
|
|
1100
|
+
// Unknown command / non-existent path: name it on stderr + exit non-zero so
|
|
1101
|
+
// a typo in a script fails loudly instead of silently printing help.
|
|
1102
|
+
console.error(`Unknown command or path: ${cmd}\n`);
|
|
1103
|
+
console.log(HELP);
|
|
1104
|
+
process.exit(1);
|
|
1105
|
+
}
|
|
1012
1106
|
} catch (e) {
|
|
1013
1107
|
console.error(`Error: ${e?.message ?? e}`);
|
|
1014
1108
|
await maybePrintUpdate(updatePromise);
|
package/src/tui/game/loop.js
CHANGED
|
@@ -139,7 +139,9 @@ export function runGame({ width, height, level = 1, scanStore = null, onExit, in
|
|
|
139
139
|
if (highSaved) return;
|
|
140
140
|
highSaved = true;
|
|
141
141
|
const score = (game && Math.max(game.best || 0, game.score || 0)) || 0;
|
|
142
|
-
|
|
142
|
+
// _inject.noPersist (the __bench_frames seam) keeps a measurement run from
|
|
143
|
+
// writing dkHighScore to the user's config.
|
|
144
|
+
if (score > best && !_inject.noPersist) {
|
|
143
145
|
try { setConfigValue("dkHighScore", score); best = score; } catch { /* best effort */ }
|
|
144
146
|
}
|
|
145
147
|
}
|
|
@@ -7,6 +7,7 @@ import React from "react";
|
|
|
7
7
|
import { Box, Text, useInput } from "ink";
|
|
8
8
|
import {
|
|
9
9
|
h, C, GLYPH, SubTabs, ListRow, StateLine, cellE, clampCursor, SPINNER, useTick, useAsync,
|
|
10
|
+
useSearch, SearchBar,
|
|
10
11
|
} from "../ui.js";
|
|
11
12
|
import { collectFrom } from "../../sources/index.js";
|
|
12
13
|
import { loadConfig, setConfigValue } from "../../config.js";
|
|
@@ -21,11 +22,11 @@ const TABS = ["Registry", "Alerts", "Drift", "Fixes", "Releases"];
|
|
|
21
22
|
// Notifications: mark read · Drift: rescan + archive). Published via ui.setKeys()
|
|
22
23
|
// so the keybar never advertises a key that's dead on the current section.
|
|
23
24
|
const TAB_KEYS = [
|
|
24
|
-
[{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "m", label: "mark all seen" }, { k: "g", label: "refresh" }],
|
|
25
|
-
[{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "o", label: "mark read" }, { k: "g", label: "refresh" }],
|
|
26
|
-
[{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "r", label: "rescan" }, { k: "a", label: "archive" }],
|
|
27
|
-
[{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "o", label: "open in editor" }, { k: "g", label: "refresh" }],
|
|
28
|
-
[{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }],
|
|
25
|
+
[{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "/", label: "search" }, { k: "m", label: "mark all seen" }, { k: "g", label: "refresh" }],
|
|
26
|
+
[{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "/", label: "search" }, { k: "o", label: "mark read" }, { k: "g", label: "refresh" }],
|
|
27
|
+
[{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "/", label: "search" }, { k: "r", label: "rescan" }, { k: "a", label: "archive" }],
|
|
28
|
+
[{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "/", label: "search" }, { k: "o", label: "open in editor" }, { k: "g", label: "refresh" }],
|
|
29
|
+
[{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "/", label: "search" }],
|
|
29
30
|
];
|
|
30
31
|
|
|
31
32
|
// The static fallback must match the default (Registry) section exactly —
|
|
@@ -35,8 +36,12 @@ export const meta = { keys: TAB_KEYS[0] };
|
|
|
35
36
|
export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14 }) {
|
|
36
37
|
const [tab, setTab] = React.useState(0);
|
|
37
38
|
const [cursor, setCursor] = React.useState(0);
|
|
39
|
+
const search = useSearch();
|
|
38
40
|
const lastSeen = loadConfig().lastEventsSeenAt || null;
|
|
39
41
|
React.useEffect(() => { ui?.setKeys?.(TAB_KEYS[tab]); }, [tab, ui]);
|
|
42
|
+
// Re-window from the top whenever the filter changes so the cursor never sits
|
|
43
|
+
// below the (now shorter) visible list.
|
|
44
|
+
React.useEffect(() => { setCursor(0); }, [search.query]);
|
|
40
45
|
|
|
41
46
|
const reg = useAsync(async () => {
|
|
42
47
|
const [ev, m, p] = await Promise.all([
|
|
@@ -89,11 +94,46 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
89
94
|
}
|
|
90
95
|
}
|
|
91
96
|
|
|
97
|
+
// One `/` filter, applied per-section over the fields each row actually shows
|
|
98
|
+
// (so e.g. typing "deprecated" on Registry narrows to model_deprecated events,
|
|
99
|
+
// "claude" to a provider's models). Computed once here and reused by both the
|
|
100
|
+
// key handler — so m/o/a act on the row you can SEE — and the render below.
|
|
101
|
+
const q = search.query.toLowerCase().trim();
|
|
102
|
+
const regEvents = reg.data?.events || [];
|
|
103
|
+
const regName = (e) => (e.model_id ? reg.data?.models.get(e.model_id) : reg.data?.provs.get(e.provider_id)) || "";
|
|
104
|
+
const regFiltered = q ? regEvents.filter((e) => `${e.event_type} ${regName(e)} ${String(e.published_at || "").slice(0, 10)}`.toLowerCase().includes(q)) : regEvents;
|
|
105
|
+
const notifAll = notif.data || [];
|
|
106
|
+
const notifFiltered = q ? notifAll.filter((n) => `${n.title} ${String(n.when || "").slice(0, 10)}`.toLowerCase().includes(q)) : notifAll;
|
|
107
|
+
const fixesFiltered = q ? fixes.filter((f) => `${path.basename(f.dir)} ${f.file} ${f.from} ${f.to}`.toLowerCase().includes(q)) : fixes;
|
|
108
|
+
const relFiltered = q ? CHANGELOG.filter((e) => `v${e.version} ${e.date} ${e.title} ${(e.items || []).join(" ")}`.toLowerCase().includes(q)) : CHANGELOG;
|
|
109
|
+
const driftAdded = q && drift?.added ? drift.added.filter((c) => `${c.display || c.model_string} ${c.location_label || ""}`.toLowerCase().includes(q)) : drift?.added || [];
|
|
110
|
+
const driftGone = q && drift?.gone ? drift.gone.filter((u) => `${u.model_display || u.custom_model_name || ""} ${u.source_path || ""}`.toLowerCase().includes(q)) : drift?.gone || [];
|
|
111
|
+
|
|
92
112
|
useInput(
|
|
93
113
|
(input, key) => {
|
|
94
114
|
if (!active) return;
|
|
95
|
-
|
|
96
|
-
|
|
115
|
+
// While typing a filter, keys feed the query — not the section commands.
|
|
116
|
+
// No trailing return: ↑↓ still scroll the filtered list as you refine it
|
|
117
|
+
// (matches the Scan/Inventory search feel). Mirrors the useSearch ref guard.
|
|
118
|
+
if (search.isSearchingNow()) {
|
|
119
|
+
if (key.escape) { search.clear(); ui?.setCapturing?.(false); return; }
|
|
120
|
+
if (key.return) { search.confirm(); ui?.setCapturing?.(false); return; }
|
|
121
|
+
if (key.backspace || key.delete) return search.backspace();
|
|
122
|
+
if (input && !key.ctrl && !key.meta && !key.leftArrow && !key.rightArrow) return search.type(input);
|
|
123
|
+
}
|
|
124
|
+
// "/" opens the filter (any trailing chars typed in the same burst seed it).
|
|
125
|
+
if (typeof input === "string" && input.startsWith("/")) {
|
|
126
|
+
search.open();
|
|
127
|
+
ui?.setCapturing?.(true);
|
|
128
|
+
const rest = input.slice(1);
|
|
129
|
+
if (rest) search.type(rest);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (key.leftArrow) return (setTab((t) => (t + TABS.length - 1) % TABS.length), setCursor(0), search.clear());
|
|
133
|
+
if (key.rightArrow) return (setTab((t) => (t + 1) % TABS.length), setCursor(0), search.clear());
|
|
134
|
+
// esc / backspace drops a confirmed filter (when not mid-typing — that's
|
|
135
|
+
// handled above) before falling through to the section commands.
|
|
136
|
+
if ((key.escape || key.backspace || key.delete) && search.query) return search.clear();
|
|
97
137
|
if (key.downArrow || input === "j") return setCursor((c) => c + 1);
|
|
98
138
|
if (key.upArrow || input === "k") return setCursor((c) => Math.max(0, c - 1));
|
|
99
139
|
// g refreshes whichever section is showing — the universal retry across tabs
|
|
@@ -111,7 +151,7 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
111
151
|
ui.showToast("marked all as seen");
|
|
112
152
|
}
|
|
113
153
|
} else if (tab === 1) {
|
|
114
|
-
const list =
|
|
154
|
+
const list = notifFiltered;
|
|
115
155
|
const cur = list[clampCursor(cursor, list.length)];
|
|
116
156
|
if (input === "o" && cur)
|
|
117
157
|
client.readNotification(cur.id).then(() => {
|
|
@@ -119,14 +159,14 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
119
159
|
notif.reload();
|
|
120
160
|
}).catch((e) => ui.showToast(e.message, "red"));
|
|
121
161
|
} else if (tab === 3) {
|
|
122
|
-
const cur =
|
|
162
|
+
const cur = fixesFiltered[clampCursor(cursor, fixesFiltered.length)];
|
|
123
163
|
if (input === "o" && cur) {
|
|
124
164
|
openLocation(path.resolve(cur.dir, cur.file), cur.line);
|
|
125
165
|
ui.showToast(`opened ${cur.file}:${cur.line}`);
|
|
126
166
|
}
|
|
127
167
|
} else if (tab === 2) {
|
|
128
168
|
if (input === "r") return runDrift();
|
|
129
|
-
const gone =
|
|
169
|
+
const gone = driftGone;
|
|
130
170
|
// Same visible-rows clamp as the render — archive only what's on screen.
|
|
131
171
|
const cur = gone[clampCursor(cursor, Math.min(gone.length, 7))];
|
|
132
172
|
if (input === "a" && cur)
|
|
@@ -157,8 +197,8 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
157
197
|
if (reg.loading) body = h(StateLine, { kind: "loading", spin, text: "loading registry changes…" });
|
|
158
198
|
else if (reg.error) body = h(StateLine, { kind: "error", text: `couldn't load registry changes — ${reg.error}`, hint: "g retries" });
|
|
159
199
|
else {
|
|
160
|
-
const events =
|
|
161
|
-
if (!events.length) body = h(Text, { color: C.FG_DIM }, " No registry changes recorded yet.");
|
|
200
|
+
const events = regFiltered;
|
|
201
|
+
if (!events.length) body = h(Text, { color: C.FG_DIM }, q ? ` No changes match "${search.query}".` : " No registry changes recorded yet.");
|
|
162
202
|
else {
|
|
163
203
|
const start = clampCursor(cursor, events.length);
|
|
164
204
|
body = h(
|
|
@@ -181,10 +221,10 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
181
221
|
}
|
|
182
222
|
}
|
|
183
223
|
} else if (tab === 1) {
|
|
184
|
-
const list =
|
|
224
|
+
const list = notifFiltered;
|
|
185
225
|
if (notif.loading) body = h(StateLine, { kind: "loading", spin, text: "loading alerts…" });
|
|
186
226
|
else if (notif.error) body = h(StateLine, { kind: "error", text: `couldn't load alerts — ${notif.error}`, hint: "g retries" });
|
|
187
|
-
else if (!list.length) body = h(Text, { color: C.FG_DIM }, " No alerts yet. Press 6 to set up alert rules.");
|
|
227
|
+
else if (!list.length) body = h(Text, { color: C.FG_DIM }, q ? ` No alerts match "${search.query}".` : " No alerts yet. Press 6 to set up alert rules.");
|
|
188
228
|
else {
|
|
189
229
|
// Window the list around the cursor so ↑↓ can reach every row — a fixed
|
|
190
230
|
// slice(0, ROWS) lets the selection walk below the visible page and `o`
|
|
@@ -206,14 +246,15 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
206
246
|
);
|
|
207
247
|
}
|
|
208
248
|
} else if (tab === 3) {
|
|
209
|
-
if (!
|
|
210
|
-
body = h(Text, { color: C.FG_DIM }, " No fixes applied yet. Press f on the Here tab (or run mm fix) to rewrite dying model ids.");
|
|
249
|
+
if (!fixesFiltered.length) {
|
|
250
|
+
body = h(Text, { color: C.FG_DIM }, q ? ` No fixes match "${search.query}".` : " No fixes applied yet. Press f on the Here tab (or run mm fix) to rewrite dying model ids.");
|
|
211
251
|
} else {
|
|
212
252
|
// Windowed like the Alerts section so ↑↓ reaches every row — minus 4 rows
|
|
213
253
|
// reserved for the selected fix's diff underneath.
|
|
254
|
+
const fixesV = fixesFiltered;
|
|
214
255
|
const FROWS = Math.max(3, ROWS - 4);
|
|
215
|
-
const cur = clampCursor(cursor,
|
|
216
|
-
const start = Math.max(0, Math.min(cur - FROWS + 1,
|
|
256
|
+
const cur = clampCursor(cursor, fixesV.length);
|
|
257
|
+
const start = Math.max(0, Math.min(cur - FROWS + 1, fixesV.length - FROWS));
|
|
217
258
|
const ago = (ts) => {
|
|
218
259
|
const m = Math.max(0, Math.round((Date.now() - ts) / 60000));
|
|
219
260
|
if (m < 1) return "now";
|
|
@@ -225,7 +266,7 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
225
266
|
body = h(
|
|
226
267
|
Box,
|
|
227
268
|
{ flexDirection: "column" },
|
|
228
|
-
...
|
|
269
|
+
...fixesV.slice(start, start + FROWS).map((f, i) => {
|
|
229
270
|
const cells = [
|
|
230
271
|
{ text: `${GLYPH.check} `, color: "#16a34a" },
|
|
231
272
|
{ text: cellE(`${path.basename(f.dir)} · ${f.file}:${f.line}`, 38), color: C.FG },
|
|
@@ -238,7 +279,7 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
238
279
|
// The selected fix's diff — same red/green visual as the f preview + the fix PR.
|
|
239
280
|
h(Text, { key: "drule" }, ""),
|
|
240
281
|
...(() => {
|
|
241
|
-
const sel =
|
|
282
|
+
const sel = fixesV[cur];
|
|
242
283
|
if (!sel) return [];
|
|
243
284
|
if (!sel.before || !sel.after) return [h(Text, { key: "dnone", color: C.FG_DIM }, " (diff not recorded for this entry — older fix)")];
|
|
244
285
|
const w = Math.max(20, width - 4);
|
|
@@ -251,14 +292,17 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
251
292
|
);
|
|
252
293
|
}
|
|
253
294
|
} else if (tab === 4) {
|
|
295
|
+
const relV = relFiltered;
|
|
296
|
+
if (!relV.length) { body = h(Text, { color: C.FG_DIM }, ` No releases match "${search.query}".`); }
|
|
297
|
+
else {
|
|
254
298
|
const RROWS = Math.max(3, ROWS - 5); // reserve rows for the selected entry's bullets
|
|
255
|
-
const cur = clampCursor(cursor,
|
|
256
|
-
const start = Math.max(0, Math.min(cur - RROWS + 1,
|
|
257
|
-
const sel =
|
|
299
|
+
const cur = clampCursor(cursor, relV.length);
|
|
300
|
+
const start = Math.max(0, Math.min(cur - RROWS + 1, relV.length - RROWS));
|
|
301
|
+
const sel = relV[cur];
|
|
258
302
|
body = h(
|
|
259
303
|
Box,
|
|
260
304
|
{ flexDirection: "column" },
|
|
261
|
-
...
|
|
305
|
+
...relV.slice(start, start + RROWS).map((e, i) => {
|
|
262
306
|
const cells = [
|
|
263
307
|
{ text: cellE(`v${e.version}`, 16), color: C.ACCENT },
|
|
264
308
|
{ text: cellE(e.date, 12), color: C.FG_FAINT },
|
|
@@ -272,12 +316,14 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
272
316
|
h(Text, { key: `rb${i}`, color: C.FG_DIM }, cellE(` • ${it.replace(/\`/g, "")}`, Math.max(24, width - 2))))
|
|
273
317
|
: []),
|
|
274
318
|
);
|
|
319
|
+
}
|
|
275
320
|
} else {
|
|
276
321
|
if (!drift) body = h(Text, { color: C.FG_DIM }, ` Press r to scan ${dir} and compare against tracked usages.`);
|
|
277
322
|
else if (drift.loading) body = h(StateLine, { kind: "loading", spin, text: "scanning for drift…" });
|
|
278
323
|
else if (drift.error) body = h(StateLine, { kind: "error", text: `drift scan failed — ${drift.error}`, hint: "r rescans" });
|
|
279
324
|
else {
|
|
280
|
-
const
|
|
325
|
+
const added = driftAdded;
|
|
326
|
+
const gone = driftGone;
|
|
281
327
|
// Clamp the archive cursor to the VISIBLE rows — `a` must never act on a
|
|
282
328
|
// row below the 7 shown.
|
|
283
329
|
const curGone = clampCursor(cursor, Math.min(gone.length, 7));
|
|
@@ -287,12 +333,13 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
287
333
|
h(
|
|
288
334
|
Text,
|
|
289
335
|
{},
|
|
290
|
-
h(Text, { color: "#16a34a" }, `${
|
|
336
|
+
h(Text, { color: "#16a34a" }, `${added.length} new in code`),
|
|
291
337
|
h(Text, { color: C.FG_DIM }, ` · ${drift.present} still present · `),
|
|
292
338
|
h(Text, { color: "#dc2626" }, `${gone.length} gone from code`),
|
|
339
|
+
q ? h(Text, { color: C.FG_FAINT }, ` · filter "${search.query}"`) : null,
|
|
293
340
|
),
|
|
294
341
|
h(Text, {}, ""),
|
|
295
|
-
...
|
|
342
|
+
...added.slice(0, 5).map((c, i) => {
|
|
296
343
|
const cells = [
|
|
297
344
|
{ text: "+ ", color: "#16a34a" },
|
|
298
345
|
{ text: cellE(c.display || c.model_string, 24), color: "#16a34a" },
|
|
@@ -301,7 +348,7 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
301
348
|
];
|
|
302
349
|
return h(ListRow, { key: "a" + i, active: false, cells, width });
|
|
303
350
|
}),
|
|
304
|
-
|
|
351
|
+
added.length > 5 ? h(Text, { color: C.FG_DIM }, ` … ${added.length - 5} more new`) : null,
|
|
305
352
|
...gone.slice(0, 7).map((u, i) => {
|
|
306
353
|
const cells = [
|
|
307
354
|
{ text: "- ", color: "#dc2626" },
|
|
@@ -316,11 +363,18 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
|
|
|
316
363
|
}
|
|
317
364
|
}
|
|
318
365
|
|
|
366
|
+
const visibleCount = tab === 0 ? regFiltered.length
|
|
367
|
+
: tab === 1 ? notifFiltered.length
|
|
368
|
+
: tab === 2 ? driftAdded.length + driftGone.length
|
|
369
|
+
: tab === 3 ? fixesFiltered.length
|
|
370
|
+
: relFiltered.length;
|
|
371
|
+
|
|
319
372
|
return h(
|
|
320
373
|
Box,
|
|
321
374
|
{ flexDirection: "column" },
|
|
322
375
|
h(SubTabs, { idx: tab, tabs: TABS }),
|
|
323
376
|
h(Text, {}, ""),
|
|
324
377
|
body,
|
|
378
|
+
search.active ? h(SearchBar, { searching: search.searching, query: search.query, count: visibleCount }) : null,
|
|
325
379
|
);
|
|
326
380
|
}
|
package/src/upgrade.js
CHANGED
|
@@ -4,8 +4,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
4
4
|
|
|
5
5
|
/** Open Stripe checkout and poll /me until the plan flips off "free". Mirrors
|
|
6
6
|
* the browser-login poll UX. Returns the new plan, or null on timeout. */
|
|
7
|
-
export async function upgradeViaBrowser({ client, log = console.error, onTick }) {
|
|
8
|
-
const { url } = await client.checkout();
|
|
7
|
+
export async function upgradeViaBrowser({ client, plan, log = console.error, onTick }) {
|
|
8
|
+
const { url } = await client.checkout(plan);
|
|
9
9
|
if (!url) throw new Error("Could not start checkout.");
|
|
10
10
|
log(`\n Opening checkout in your browser…`);
|
|
11
11
|
log(` If it doesn't open, visit:\n ${url}\n`);
|