@modelstatus/cli 0.1.86 → 0.1.88

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/src/tui/app.js CHANGED
@@ -371,9 +371,20 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
371
371
  }
372
372
 
373
373
  /** Top-level TUI entry — always renders App; auth unlocks tabs in-place. */
374
- function Bootstrap(props) {
374
+ export function Bootstrap(props) {
375
375
  const [apiKey, setApiKey] = React.useState(props.apiKey);
376
- return h(App, { ...props, apiKey, onSignedIn: (k) => { track("signed_in"); setApiKey(k); } });
376
+ return h(App, {
377
+ ...props,
378
+ apiKey,
379
+ onSignedIn: (k) => {
380
+ track("signed_in");
381
+ // Keep the module-level controller in sync: a game round-trip remounts
382
+ // from _opts (captured at runApp time), and a stale null apiKey there
383
+ // would silently sign the session back out after the game.
384
+ if (appController._opts) appController._opts = { ...appController._opts, apiKey: k };
385
+ setApiKey(k);
386
+ },
387
+ });
377
388
  }
378
389
 
379
390
  // Module-level controller so a view (the Scan tab) can UNMOUNT the whole Ink
@@ -383,6 +394,22 @@ function Bootstrap(props) {
383
394
  export const appController = {
384
395
  _instance: null,
385
396
  _opts: null,
397
+ // A game handoff is in flight: the coming unmount is NOT a real quit. Set by
398
+ // launch.js BEFORE unmount(); cleared by remount() (or endHandoff() when the
399
+ // game fails to come back). runApp's exit waiter checks it to decide whether
400
+ // to keep the alt screen + promise alive or treat the unmount as a quit.
401
+ _handoff: false,
402
+ _handoffWaiter: null, // runApp's deferred quit-or-rearm check (see runApp)
403
+ /** Mark the next unmount as a game handoff (a remount will follow). */
404
+ beginHandoff() { this._handoff = true; },
405
+ /** End a handoff — normally via remount(); called directly (without a
406
+ * remount) when the game errored, so runApp treats the unmount as a quit. */
407
+ endHandoff() {
408
+ this._handoff = false;
409
+ const w = this._handoffWaiter;
410
+ this._handoffWaiter = null;
411
+ if (w) w();
412
+ },
386
413
  /** Tear down the current Ink tree (releases raw mode + stdin listeners). */
387
414
  unmount() {
388
415
  try { this._instance && this._instance.unmount(); } catch { /* already gone */ }
@@ -398,6 +425,10 @@ export const appController = {
398
425
  // right before this, so by now the terminal reports its stable full height.
399
426
  try { process.stdout.write("\x1b[2J\x1b[H"); } catch { /* ignore */ }
400
427
  this._instance = render(h(Bootstrap, opts));
428
+ // launch.js re-entered the alt screen right before remounting — record it so
429
+ // the final quit's leaveAlt() actually restores the host screen + scrollback.
430
+ this._inAlt = true;
431
+ this.endHandoff(); // flush runApp's waiter → it re-arms on the new instance
401
432
  return this._instance;
402
433
  },
403
434
  };
@@ -427,20 +458,31 @@ export function runApp(opts) {
427
458
  appController._opts = opts;
428
459
  const app = render(h(Bootstrap, opts));
429
460
  appController._instance = app;
430
- // waitUntilExit resolves when the CURRENT instance unmounts. A Scan-tab game
431
- // launch unmounts + remounts, which would resolve this early; so we re-arm on
432
- // each remount and only resolve when an instance exits WITHOUT a pending
433
- // remount (i.e. a real quit). The `_exiting` flag is set by a clean exit().
461
+ // waitUntilExit resolves when the CURRENT instance unmounts including the
462
+ // deliberate unmount at the start of a game handoff (launch.js), which nulls
463
+ // _instance and resolves this promise BEFORE the remount happens. So a game
464
+ // launch must not read as a quit: launch.js sets appController._handoff before
465
+ // unmounting, and while it's set we park the decision in _handoffWaiter.
466
+ // remount()/endHandoff() flush the waiter — a remount re-arms on the new
467
+ // instance; a failed handoff (no remount) falls through to the real-quit path.
434
468
  return new Promise((resolve) => {
435
469
  const arm = (inst) => {
436
470
  inst.waitUntilExit().then(() => {
437
- // If a remount happened (a new instance is live and differs), keep waiting.
438
- if (appController._instance && appController._instance !== inst) {
439
- arm(appController._instance);
440
- } else {
441
- leaveAlt(); // real quit → restore the host screen + scrollback
442
- resolve();
443
- }
471
+ const settle = () => {
472
+ if (appController._handoff) {
473
+ // Game round-trip in flight — decide after remount()/endHandoff().
474
+ appController._handoffWaiter = settle;
475
+ return;
476
+ }
477
+ // If a remount happened (a new instance is live and differs), keep waiting.
478
+ if (appController._instance && appController._instance !== inst) {
479
+ arm(appController._instance);
480
+ } else {
481
+ leaveAlt(); // real quit → restore the host screen + scrollback
482
+ resolve();
483
+ }
484
+ };
485
+ settle();
444
486
  });
445
487
  };
446
488
  arm(app);
@@ -26,13 +26,15 @@ export async function playGameInTui({ dir, width, height, initialView = "scan",
26
26
  if (launching) return;
27
27
  launching = true;
28
28
  track("game_opened", { game: "donkey_kong", scan_phase: scanPhase });
29
+ let appController = null; // hoisted so the catch below can end a dangling handoff
29
30
  try {
30
- const [{ runGame }, { startScanProcess }, { appController }, { writeDiskScan, loadRegistry }] = await Promise.all([
31
+ const [{ runGame }, { startScanProcess }, app, { writeDiskScan, loadRegistry }] = await Promise.all([
31
32
  import("./loop.js"),
32
33
  import("../../sources/scan-process.js"),
33
34
  import("../app.js"),
34
35
  import("../scan-stream.js"),
35
36
  ]);
37
+ appController = app.appController;
36
38
 
37
39
  // (1) Start a TRUE background scan SUBPROCESS over `dir`. It survives the Ink
38
40
  // unmount (separate OS process). Pre-fetch + cache the registry so the worker
@@ -56,6 +58,11 @@ export async function playGameInTui({ dir, width, height, initialView = "scan",
56
58
  } catch { handle = null; }
57
59
 
58
60
  // (2) Unmount the Ink tree (releases raw mode + stdin). Let teardown settle.
61
+ // beginHandoff FIRST: unmount() resolves ink's exit promise synchronously,
62
+ // and without the flag runApp's exit waiter would read this as a real quit —
63
+ // dropping the alt screen mid-game and resolving runApp early (main() then
64
+ // races the game for stdout). See appController/runApp in ../app.js.
65
+ appController.beginHandoff();
59
66
  appController.unmount();
60
67
  await new Promise((r) => setImmediate(r));
61
68
 
@@ -90,8 +97,13 @@ export async function playGameInTui({ dir, width, height, initialView = "scan",
90
97
  // useTermDims — we no longer poll for it).
91
98
  try { process.stdout.write("\x1b[?1049l\x1b[?1049h\x1b[2J\x1b[H"); } catch { /* ignore */ }
92
99
  await new Promise((r) => setImmediate(r));
93
- appController.remount({ initialView, fresh: false });
100
+ appController.remount({ initialView, fresh: false }); // remount ends the handoff
94
101
  } catch (e) {
102
+ // The handoff died without a remount (e.g. the game threw). End it so
103
+ // runApp's parked waiter treats the earlier unmount as a real quit and
104
+ // restores the host screen instead of waiting forever. No-op when the
105
+ // error happened before beginHandoff (tree still mounted → toast shows).
106
+ if (appController) appController.endHandoff();
95
107
  if (onError) onError(e); else throw e;
96
108
  } finally {
97
109
  launching = false;
package/src/tui/signin.js CHANGED
@@ -12,6 +12,19 @@ import { h } from "./ui.js";
12
12
 
13
13
  const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
14
14
 
15
+ // One in-flight device-auth session per apiBase, held OUTSIDE the component.
16
+ // SignIn is remounted on every signed-out Account-tab visit (and after a game
17
+ // round-trip); without this, each mount minted a NEW device code and popped
18
+ // ANOTHER browser tab — and an approval made in the browser after the user
19
+ // switched tabs landed on a code nobody was polling. A remount now resumes the
20
+ // pending session (same code, same URL, no extra tab) and picks the approval up.
21
+ // Sessions clear on approval/denial/expiry/start-failure (and g re-mints one).
22
+ // apiBase -> { device_code, user_code, verification_url, interval, deadline, opened }
23
+ const authSessions = new Map();
24
+
25
+ /** Test hook: forget all pending device-auth sessions. */
26
+ export function _resetAuthSessions() { authSessions.clear(); }
27
+
15
28
  export function SignIn({ apiBase, onSuccess, onSkip }) {
16
29
  const { exit } = useApp();
17
30
  const [phase, setPhase] = React.useState("starting"); // starting | polling | error
@@ -35,19 +48,38 @@ export function SignIn({ apiBase, onSuccess, onSkip }) {
35
48
 
36
49
  const start = async () => {
37
50
  try {
38
- const s = await client.authStart({ client_name: "mm CLI" });
51
+ // Resume the session a previous mount left in flight (tab switch, game
52
+ // round-trip) — same code, same browser tab, and an approval made while
53
+ // we weren't looking gets picked up on the first poll. Mint a fresh one
54
+ // only when none is pending or the old one passed its deadline.
55
+ let s = authSessions.get(apiBase);
56
+ if (!s || Date.now() > s.deadline) {
57
+ const fresh = await client.authStart({ client_name: "mm CLI" });
58
+ s = {
59
+ device_code: fresh.device_code,
60
+ user_code: fresh.user_code,
61
+ verification_url: fresh.verification_url,
62
+ interval: Math.max(1, fresh.interval || 3) * 1000,
63
+ deadline: Date.now() + (fresh.expires_in || 600) * 1000,
64
+ opened: false,
65
+ };
66
+ // Cache even if this mount was cancelled mid-start — the code is
67
+ // already minted, so let the next visit resume it instead of orphaning it.
68
+ authSessions.set(apiBase, s);
69
+ }
39
70
  if (cancelled) return;
40
71
  setCode(s.user_code);
41
72
  setUrl(s.verification_url);
42
73
  setPhase("polling");
43
- openUrl(s.verification_url); // best-effort, no error if it fails
44
-
45
- const interval = Math.max(1, s.interval || 3) * 1000;
46
- const deadline = Date.now() + (s.expires_in || 600) * 1000;
74
+ if (!s.opened) {
75
+ s.opened = true;
76
+ openUrl(s.verification_url); // best-effort, ONCE per session (o re-opens)
77
+ }
47
78
 
48
79
  const poll = async () => {
49
80
  if (cancelled) return;
50
- if (Date.now() > deadline) {
81
+ if (Date.now() > s.deadline) {
82
+ authSessions.delete(apiBase);
51
83
  setError("The login request expired.");
52
84
  setPhase("error");
53
85
  return;
@@ -60,6 +92,7 @@ export function SignIn({ apiBase, onSuccess, onSkip }) {
60
92
  }
61
93
  if (cancelled) return;
62
94
  if (res?.status === "approved") {
95
+ authSessions.delete(apiBase);
63
96
  const cfg = loadConfig();
64
97
  cfg.apiKey = res.api_key;
65
98
  cfg.apiBase = apiBase;
@@ -68,20 +101,23 @@ export function SignIn({ apiBase, onSuccess, onSkip }) {
68
101
  return;
69
102
  }
70
103
  if (res?.status === "denied") {
104
+ authSessions.delete(apiBase);
71
105
  setError("Authorization was denied.");
72
106
  setPhase("error");
73
107
  return;
74
108
  }
75
109
  if (res?.status === "expired") {
110
+ authSessions.delete(apiBase);
76
111
  setError("The login request expired. Press q and run mm login again.");
77
112
  setPhase("error");
78
113
  return;
79
114
  }
80
- pollTimer = setTimeout(poll, interval);
115
+ pollTimer = setTimeout(poll, s.interval);
81
116
  };
82
117
  poll();
83
118
  } catch (e) {
84
119
  if (cancelled) return;
120
+ authSessions.delete(apiBase); // a failed start isn't resumable
85
121
  setError(e?.message || String(e));
86
122
  setPhase("error");
87
123
  }
@@ -28,6 +28,18 @@ export function AccountView({ client, me, refreshMe, apiBase, ui, active }) {
28
28
 
29
29
  React.useEffect(() => ui?.reportStatus?.({ context: `plan: ${me?.plan ?? "…"}` }), [me, ui]);
30
30
 
31
+ // The upgrade poll below is a self-rescheduling setTimeout chain — without
32
+ // cleanup it outlives the view (keeps hitting /me every 4s for up to 10min,
33
+ // sets state on an unmounted component, and holds the event loop open AFTER
34
+ // quit, hanging the user's shell). Cancel it the moment the view unmounts.
35
+ const pollTimerRef = React.useRef(null);
36
+ const mountedRef = React.useRef(true);
37
+ React.useEffect(() => () => {
38
+ mountedRef.current = false;
39
+ if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
40
+ pollTimerRef.current = null;
41
+ }, []);
42
+
31
43
  async function upgrade() {
32
44
  // Without a loaded account a checkout would just fire at an unreachable
33
45
  // endpoint and die with a raw fetch error — say what to do instead.
@@ -40,9 +52,11 @@ export function AccountView({ client, me, refreshMe, apiBase, ui, active }) {
40
52
  setStatus("Complete checkout in your browser… (polling /me)");
41
53
  const deadline = Date.now() + 10 * 60 * 1000;
42
54
  const tick = async () => {
55
+ if (!mountedRef.current) return;
43
56
  if (Date.now() > deadline) return setStatus("Timed out — press u to retry.");
44
57
  try {
45
58
  const m = await client.me();
59
+ if (!mountedRef.current) return;
46
60
  if (m?.account?.plan && m.account.plan !== "free") {
47
61
  setStatus(null);
48
62
  ui.showToast(`upgraded to ${m.account.plan}!`);
@@ -52,9 +66,12 @@ export function AccountView({ client, me, refreshMe, apiBase, ui, active }) {
52
66
  } catch {
53
67
  /* keep polling */
54
68
  }
55
- setTimeout(tick, 4000);
69
+ if (!mountedRef.current) return;
70
+ pollTimerRef.current = setTimeout(tick, 4000);
56
71
  };
57
- setTimeout(tick, 4000);
72
+ // A second u press replaces (not stacks) any poll chain already running.
73
+ if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
74
+ pollTimerRef.current = setTimeout(tick, 4000);
58
75
  } catch (e) {
59
76
  setStatus(e.status === 503 ? "Billing isn't configured on this server." : `checkout failed: ${e.message} — press u to retry.`);
60
77
  }
@@ -125,11 +125,20 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
125
125
  .patchRule(cur.id, { delivery: DELIVERY[(DELIVERY.indexOf(cur.delivery) + 1) % 3] })
126
126
  .then(() => rules.reload())
127
127
  .catch((e) => ui.showToast(e.message, "red"));
128
- if (input === "d")
129
- return client.deleteRule(cur.id).then(() => {
130
- ui.showToast("rule deleted");
131
- rules.reload();
132
- }).catch((e) => ui.showToast(e.message, "red"));
128
+ if (input === "d") {
129
+ // One stray keypress shouldn't silently delete a rule — confirm with a
130
+ // single y, same pattern as InventoryView's d (inventory.js).
131
+ return ui.askPrompt(`Delete ${ruleLabel(cur)}? type y`, {
132
+ onSubmit: (v) => {
133
+ if (String(v || "").trim().toLowerCase() !== "y") return ui.showToast("delete cancelled");
134
+ client.deleteRule(cur.id).then(() => {
135
+ ui.showToast("rule deleted");
136
+ setCursor((c) => clampCursor(c, ruleList.length - 1));
137
+ rules.reload();
138
+ }).catch((e) => ui.showToast(e.message, "red"));
139
+ },
140
+ });
141
+ }
133
142
  } else {
134
143
  if (input === "n") return addChannel();
135
144
  if (input === "t" && cur) return testChannel(cur);
@@ -150,14 +159,18 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
150
159
  if (!ruleList.length) {
151
160
  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 });
152
161
  } else {
162
+ // Window the list around the cursor (same as whatsnew.js) — a fixed
163
+ // slice(0, ROWS) lets the selection walk below the visible page and
164
+ // space/c/d act on a row the user can't see.
153
165
  const curIdx = clampCursor(cursor, ruleList.length);
166
+ const start = Math.max(0, Math.min(curIdx - ROWS + 1, ruleList.length - ROWS));
154
167
  const fixed = 2 + 26 + 1 + 10 + 1; // glyph + name + gap + delivery + gap
155
168
  const rest = Math.max(8, width - 1 - fixed);
156
169
  body = h(
157
170
  Box,
158
171
  { flexDirection: "column" },
159
- ...ruleList.slice(0, ROWS).map((r, i) => {
160
- const isCur = i === curIdx;
172
+ ...ruleList.slice(start, start + ROWS).map((r, i) => {
173
+ const isCur = start + i === curIdx;
161
174
  const enabled = !!r.enabled;
162
175
  const leads = r.leadTimes || r.lead_times || [];
163
176
  const chanText = [(r.channels || []).join(","), leads.length ? leads.join("/") + "d" : ""].filter(Boolean).join(" · ");
@@ -182,13 +195,15 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
182
195
  h(Text, { color: "#d97706" }, " Slack/Discord/SMS/webhook channels need Pro — press 7 → u to upgrade."),
183
196
  );
184
197
  } else {
198
+ // Windowed around the cursor like the rules list above.
185
199
  const curIdx = clampCursor(cursor, chanList.length);
200
+ const start = Math.max(0, Math.min(curIdx - ROWS + 1, chanList.length - ROWS));
186
201
  const rest = Math.max(8, width - 1 - 10); // after the 10-wide kind pill
187
202
  body = h(
188
203
  Box,
189
204
  { flexDirection: "column" },
190
- ...chanList.slice(0, ROWS).map((c, i) => {
191
- const isCur = i === curIdx;
205
+ ...chanList.slice(start, start + ROWS).map((c, i) => {
206
+ const isCur = start + i === curIdx;
192
207
  const cells = [
193
208
  { text: cell(c.kind, 10), color: KIND_COLOR[c.kind] || C.FG_DIM, bold: true },
194
209
  { text: cellE(c.label || c.value, rest), color: C.FG },
@@ -24,6 +24,7 @@ export const meta = {
24
24
  { k: "g", label: "refresh" },
25
25
  { k: "r", label: "rescan" },
26
26
  { k: "n", label: "new" },
27
+ { k: "p", label: "llm prompt" },
27
28
  { k: "e", label: "env" },
28
29
  { k: "t", label: "tag" },
29
30
  { k: "c", label: "critical" },
@@ -100,6 +101,61 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
100
101
  ui?.reportStatus?.({ counts, context: query ? `${filtered.length} of ${usages.length}` : `${usages.length} tracked` });
101
102
  }, [usages, ui, query, filtered.length, q.error, q.loading]);
102
103
 
104
+ /** p: build the LLM fix prompt for every dying usage in the CURRENT view
105
+ * (the / filter scopes it — search "prod", press p, get a prod-only prompt)
106
+ * and put it on the clipboard (file fallback). Inventory rows can point at
107
+ * code on other machines, which is exactly why the prompt exists: paste it
108
+ * into the agent that lives WITH that code. Registry lookup is best-effort
109
+ * (cached snapshot) to turn replacement slugs into real API ids; without a
110
+ * cache the slug is still an unambiguous instruction for an agent. */
111
+ async function makeLlmPrompt() {
112
+ const dying = filtered.filter((u) => u.health && u.health !== "ok" && u.health !== "custom");
113
+ if (!dying.length) return ui.showToast(query ? "no matching usages need fixing" : "all current — nothing to fix", "#16a34a");
114
+ try {
115
+ const [{ terminalReplacement }, { buildFixPrompt }, { deliverText }, { computeHealth }] = await Promise.all([
116
+ import("../../fix.js"), import("../../fix-prompt.js"), import("../../clipboard.js"), import("../../registry/local.js"),
117
+ ]);
118
+ const today = new Date();
119
+ let bySlug = new Map();
120
+ try {
121
+ const { getRegistry } = await import("../../registry/fetch.js");
122
+ const snap = await getRegistry({ offline: true, cacheFile: process.env.LLMSTATUS_REGISTRY_CACHE || undefined });
123
+ bySlug = new Map((snap.models || []).map((m) => [m.slug, m]));
124
+ } catch { /* no cached registry — replacement slugs still name the target */ }
125
+ const isCurrent = (m) => computeHealth(m, 90, today) === "ok";
126
+ const byModel = new Map(); // one section per model, refs merged across projects
127
+ for (const u of dying) {
128
+ const key = u.model_display || u.custom_model_name || u.canonical_id || "?";
129
+ const e = byModel.get(key) || {
130
+ slug: u.canonical_id || key,
131
+ display: u.model_display || key,
132
+ health: u.health,
133
+ retires_date: u.retires_date ? String(u.retires_date).slice(0, 10) : null,
134
+ replacement: u.replacement_slug
135
+ ? terminalReplacement(u.replacement_slug, (slug) => bySlug.get(slug) ?? null, isCurrent)
136
+ : null,
137
+ refs: [],
138
+ };
139
+ e.refs.push({
140
+ file: u.source_path || u.location_label || "(added manually — find its usages)",
141
+ line: u.source_line,
142
+ matched: u.custom_model_name || u.canonical_id,
143
+ repo: u.source_repo || undefined,
144
+ });
145
+ byModel.set(key, e);
146
+ }
147
+ const text = buildFixPrompt([...byModel.values()], { date: today });
148
+ if (!text) return ui.showToast("no locatable references to hand off", "#d97706");
149
+ const nRefs = [...byModel.values()].reduce((n, f) => n + f.refs.length, 0);
150
+ const res = deliverText(text);
151
+ if (res.method === "clipboard") ui.showToast(`${GLYPH.check} LLM fix prompt copied (${byModel.size} model${byModel.size === 1 ? "" : "s"} · ${nRefs} refs) — paste into your AI agent`);
152
+ else if (res.method === "file") ui.showToast(`${GLYPH.check} no clipboard tool — prompt saved to ${res.path}`);
153
+ else ui.showToast("couldn't copy or save the prompt", "#dc2626");
154
+ } catch (e) {
155
+ ui.showToast(e.message, "red");
156
+ }
157
+ }
158
+
103
159
  async function patch(u, body, label) {
104
160
  try {
105
161
  await client.patchUsage(u.id, body);
@@ -128,6 +184,7 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
128
184
  if (input === "g") return q.reload();
129
185
  if (input === "r") return ui.switchTo("scan");
130
186
  if (input === "n") return ui.switchTo("add");
187
+ if (input === "p") return makeLlmPrompt();
131
188
  if (input === "t") {
132
189
  const untagged = usages.filter(isUntagged);
133
190
  if (!untagged.length) return ui.showToast("nothing untagged");
@@ -29,6 +29,7 @@ export const meta = {
29
29
  { k: "↑↓", label: "nav" },
30
30
  { k: "↵", label: "refs" },
31
31
  { k: "f", label: "fix all" },
32
+ { k: "p", label: "llm prompt" },
32
33
  { k: "u", label: "push → Inv" },
33
34
  { k: "g", label: "rescan" },
34
35
  { k: "/", label: "search" },
@@ -134,6 +135,39 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
134
135
  });
135
136
  }
136
137
 
138
+ /** p (while idle): build the LLM fix prompt for EVERY dying model found here
139
+ * and put it on the clipboard (file fallback) — for the refs `mm fix` can't
140
+ * safely touch (pinned ids, config indirection) hand the whole job to the
141
+ * user's AI coding agent instead. Same builder as `mm prompt`. */
142
+ function makeLlmPrompt() {
143
+ const dying = items.filter((it) => it.model && it.health !== "ok" && it.health !== "custom");
144
+ if (!dying.length) return ui?.showToast?.("all current — nothing to fix", "#16a34a");
145
+ Promise.all([import("../../fix.js"), import("../../fix-prompt.js"), import("../../clipboard.js")]).then(
146
+ ([{ terminalReplacement }, { buildFixPrompt }, { deliverText }]) => {
147
+ const bySlug = new Map((scan.snapshot?.models || []).map((m) => [m.slug, m]));
148
+ const today = new Date();
149
+ const isCurrent = (m) => computeHealth(m, 90, today) === "ok";
150
+ const findings = dying.map((it) => ({
151
+ slug: it.model.slug,
152
+ display: it.model.display,
153
+ health: it.health,
154
+ retires_date: it.model.retires_date,
155
+ replacement: it.model.replacement_slug
156
+ ? terminalReplacement(it.model.replacement_slug, (slug) => bySlug.get(slug) ?? null, isCurrent)
157
+ : null,
158
+ refs: distinctRefs(it.refs).map((r) => ({ file: r.source_path || r.location_label, line: r.source_line, matched: r.model_string })),
159
+ }));
160
+ const text = buildFixPrompt(findings, { date: today });
161
+ if (!text) return ui?.showToast?.("no file references to hand off", "#d97706");
162
+ const nRefs = findings.reduce((n, f) => n + f.refs.length, 0);
163
+ const res = deliverText(text);
164
+ if (res.method === "clipboard") ui?.showToast?.(`${GLYPH.check} LLM fix prompt copied (${dying.length} model${dying.length === 1 ? "" : "s"} · ${nRefs} refs) — paste into your AI agent`);
165
+ else if (res.method === "file") ui?.showToast?.(`${GLYPH.check} no clipboard tool — prompt saved to ${res.path}`);
166
+ else ui?.showToast?.("couldn't copy or save the prompt", "#dc2626");
167
+ },
168
+ );
169
+ }
170
+
137
171
  const tick = useTick(80, running || pushing);
138
172
  const spin = SPINNER[tick % SPINNER.length];
139
173
  const search = useSearch();
@@ -281,7 +315,9 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
281
315
  if (key.upArrow || input === "k") return nav.up();
282
316
  if (input === "e") return excludeRef(drefs[0]); // exclude the highlighted model's location (editable)
283
317
  if (input === "f") return fixRefs(cur?.refs || [], `all ${cur?.count ?? 0} references`);
284
- if (input === "p" && running) return scan.togglePause();
318
+ // p is overloaded by state: pausing only means anything mid-scan, and the
319
+ // prompt is only trustworthy once the scan is done — no key collision.
320
+ if (input === "p") return running ? scan.togglePause() : makeLlmPrompt();
285
321
  if (input === "g") { justReloadedRef.current = true; return scan.reload(); }
286
322
  if (input === "u" && !pushing) return pushToInventory();
287
323
  },