@modelstatus/cli 0.1.34 → 0.1.36

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.
@@ -3,9 +3,23 @@
3
3
  * retirement display, and lets you select + upload to your inventory. Server
4
4
  * model_ids are resolved only at upload time so uploads stay correct.
5
5
  * Scrollable (j/k/↑↓), filterable (/), with a full-width detail panel; ↵ drills
6
- * into the highlighted model's usage locations and ↵ opens one in your editor. */
6
+ * into the highlighted model's usage locations and ↵ opens one in your editor.
7
+ *
8
+ * BACKGROUND RUNNER: the live view's walk runs on the shared cooperative engine
9
+ * via useStreamingScan (scan-stream.js) → scanFilesystemStreaming.
10
+ *
11
+ * PLAY-WHILE-YOU-WAIT: P launches Donkey Kong via a clean Ink↔game handoff (NOT
12
+ * an Ink overlay anymore). We (1) start a TRUE background scan SUBPROCESS
13
+ * (scan-process.js) that survives Ink being unmounted because it's a separate OS
14
+ * process, (2) UNMOUNT the whole Ink tree (releasing raw mode + stdin), (3) run
15
+ * the direct-ANSI 60Hz game loop (loop.js — its own raw mode + alt screen +
16
+ * diff renderer, zero flicker), then (4) on game exit REMOUNT the TUI at the
17
+ * Scan tab. When the subprocess scan finishes during play we persist its
18
+ * candidates to the on-disk scan cache, so the remounted Scan view loads the
19
+ * fresh results on its fast path. Mid-scan only: P opens the game. */
7
20
  import React from "react";
8
21
  import fs from "node:fs";
22
+ import os from "node:os";
9
23
  import path from "node:path";
10
24
  import { Box, Text, useInput } from "ink";
11
25
  import {
@@ -20,6 +34,7 @@ import { readSnippet } from "../snippet.js";
20
34
  import { buildUsages, assignProjects } from "../../upload.js";
21
35
  import { track } from "../../telemetry.js";
22
36
  import { openUrl, openLocation } from "../../openUrl.js";
37
+ import { boardSize, MIN_W, MIN_H } from "../game/dk-core.js";
23
38
 
24
39
  export const meta = {
25
40
  keys: [
@@ -30,12 +45,13 @@ export const meta = {
30
45
  { k: "/", label: "search" },
31
46
  { k: "g", label: "rescan" },
32
47
  { k: "u", label: "upload all" },
48
+ { k: "P", label: "play" }, // only meaningful mid-scan; shown when running
33
49
  ],
34
50
  };
35
51
 
36
52
  const PANEL_H = 6;
37
53
 
38
- export function ScanView({ client, dir, ui, active, width = 78, height = 14, fresh = false }) {
54
+ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fresh = false, launchGameImpl = null }) {
39
55
  // Reuse the SHARED cached scan engine (same module-level cache as the Here
40
56
  // tab) instead of re-walking the repo on every visit. Switching to Scan after
41
57
  // Here is now instant; `g` forces a fresh walk for both.
@@ -51,6 +67,85 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
51
67
  const tick = useTick(80, running || busy);
52
68
  const search = useSearch();
53
69
 
70
+ // "Play Donkey Kong while you wait." The board fits only if the terminal is
71
+ // large enough (boardSize ≥ MIN_W×MIN_H), so the affordance is hidden on tiny
72
+ // terminals and the open is a no-op there. launchingRef guards against a
73
+ // double-launch (the handoff is async).
74
+ const { BOARD_W, BOARD_H } = boardSize(width, height);
75
+ const canPlay = BOARD_W >= MIN_W && BOARD_H >= MIN_H;
76
+ const launchingRef = React.useRef(false);
77
+
78
+ async function launchGame() {
79
+ if (launchingRef.current) return;
80
+ if (!canPlay) return ui?.showToast?.("terminal too small for the game — resize a bit", "#d97706");
81
+ launchingRef.current = true;
82
+ track("game_opened", { game: "donkey_kong", scan_phase: scan.phase });
83
+ // Test seam: a caller may inject the launcher so the Ink↔game handoff (which
84
+ // grabs the real terminal + spawns a subprocess) can be asserted without
85
+ // either. Production passes nothing → the real handoff below runs.
86
+ if (launchGameImpl) {
87
+ try { await launchGameImpl({ dir, width, height }); }
88
+ finally { launchingRef.current = false; }
89
+ return;
90
+ }
91
+ try {
92
+ const [{ runGame }, { startScanProcess }, { appController }, { writeDiskScan, loadRegistry }] = await Promise.all([
93
+ import("../game/loop.js"),
94
+ import("../../sources/scan-process.js"),
95
+ import("../app.js"),
96
+ import("../scan-stream.js"),
97
+ ]);
98
+
99
+ // (1) Start a TRUE background scan SUBPROCESS over the same dir. It survives
100
+ // the Ink unmount (separate OS process). Pre-fetch + cache the registry so
101
+ // the worker skips the network; on done, persist candidates to the disk
102
+ // cache so the remounted Scan view loads the fresh results.
103
+ let cacheFile = null;
104
+ try {
105
+ const snapshot = await loadRegistry().catch(() => null);
106
+ if (snapshot) {
107
+ cacheFile = path.join(os.tmpdir(), `mm-reg-game-${process.pid}.json`);
108
+ fs.writeFileSync(cacheFile, JSON.stringify(snapshot));
109
+ }
110
+ } catch { /* worker fetches itself */ }
111
+
112
+ let handle = null;
113
+ try {
114
+ handle = startScanProcess(
115
+ { root: dir, registryCachePath: cacheFile || undefined },
116
+ { onDone: (r) => { try { if (r?.candidates?.length) writeDiskScan(dir, r.candidates); } catch { /* best effort */ } } },
117
+ );
118
+ } catch { handle = null; }
119
+
120
+ // (2) Unmount the Ink tree (releases raw mode + stdin). Wait a tick so the
121
+ // teardown fully settles before the game grabs raw mode + alt screen.
122
+ appController.unmount();
123
+ await new Promise((r) => setImmediate(r));
124
+
125
+ // (3) Run the game. Its loop owns raw mode / alt screen / the diff renderer
126
+ // and restores the terminal (cooked, main buffer, cursor shown) on exit.
127
+ try {
128
+ await runGame({
129
+ width: process.stdout.columns || width,
130
+ height: process.stdout.rows || height,
131
+ level: 1,
132
+ scanStore: handle,
133
+ });
134
+ } finally {
135
+ try { handle && handle.abort(); } catch { /* ignore */ }
136
+ try { if (cacheFile) fs.unlinkSync(cacheFile); } catch { /* ignore */ }
137
+ }
138
+
139
+ // (4) Remount the TUI at the Scan tab. fresh:false → the view loads the
140
+ // (now-updated) disk scan cache on its fast path.
141
+ appController.remount({ initialView: "scan", fresh: false });
142
+ } catch (e) {
143
+ ui?.showToast?.(`couldn't start the game: ${e?.message || e}`, "#dc2626");
144
+ } finally {
145
+ launchingRef.current = false;
146
+ }
147
+ }
148
+
54
149
  // Upload-target projects (cheap; independent of the scan).
55
150
  const projQ = useAsync(() => client.listProjects().then((r) => r.data || []).catch(() => []), []);
56
151
  const projects = projQ.data || [];
@@ -123,7 +218,7 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
123
218
  }, [items, selCount, project, ui]);
124
219
 
125
220
  // Tell the shell when backspace should back out *within* this view (drilled
126
- // into refs, or an active filter) rather than stepping to the previous tab.
221
+ // into refs or an active filter) rather than stepping to the previous tab.
127
222
  const setHandlesBack = ui?.setHandlesBack;
128
223
  React.useEffect(() => {
129
224
  setHandlesBack?.(focus === "refs" || !!search.query);
@@ -227,7 +322,13 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
227
322
  if (input === "a") return setItems((its) => its.map((it) => ({ ...it, selected: true })));
228
323
  if (input === "x") return setItems((its) => its.map((it) => ({ ...it, selected: false })));
229
324
  if (input === "p") return setProjectIdx((i) => (i + 1) % projOptions.length);
230
- if (input === "P") return ui.askPrompt("New project", { onSubmit: createProject });
325
+ if (input === "P") {
326
+ // Mid-scan, P launches "Play Donkey Kong while you wait" (a clean Ink↔
327
+ // game handoff); otherwise it keeps its original meaning (create a new
328
+ // upload-target project).
329
+ if (running) { launchGame(); return; }
330
+ return ui.askPrompt("New project", { onSubmit: createProject });
331
+ }
231
332
  if (input === "g") return scan.reload();
232
333
  if (input === "u") return upload();
233
334
  if (input === "l") {
@@ -239,7 +340,13 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
239
340
  );
240
341
 
241
342
  if (scan.phase === "error") return h(StateLine, { kind: "error", text: scan.error });
242
- if (running && !items.length) return h(StateLine, { kind: "scanning", spin: SPINNER[tick % SPINNER.length], text: `scanning ${dir}…` });
343
+ if (running && !items.length)
344
+ return h(
345
+ Box,
346
+ { flexDirection: "column" },
347
+ h(StateLine, { kind: "scanning", spin: SPINNER[tick % SPINNER.length], text: `scanning ${dir}…` }),
348
+ canPlay ? h(Text, { color: C.FG_FAINT }, ` ${GLYPH.spark} press P to play Donkey Kong while you wait`) : null,
349
+ );
243
350
  if (!items.length)
244
351
  return h(EmptyCard, { icon: GLYPH.spark, title: `No models found in ${path.basename(dir)} — yet`, lines: ["We looked through code, config, and prompt files for model ids.", "Press g to rescan, 1 Here to try another project, or 5 Add to enter one by hand."], width });
245
352
 
@@ -264,10 +371,13 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
264
371
  return h(ListRow, { key: it.key + realIdx, active: isCur, selected: it.selected, cells, width });
265
372
  });
266
373
 
374
+ // While the walk is still running (and the terminal fits a board), nudge the
375
+ // "Play Donkey Kong while you wait" affordance on the showing line.
376
+ const playHint = running && canPlay ? " · P play" : "";
267
377
  const showingLine = h(
268
378
  Text,
269
379
  { color: C.FG_FAINT },
270
- ` ${fmtNum(filtered.length)} ref${filtered.length === 1 ? "" : "s"}${filtered.length > pageSize ? ` · ${nav.start + 1}-${Math.min(nav.end, filtered.length)}` : ""}${search.query ? ` · filter "${search.query}"` : ""}${scan.fromCache ? " · cached" : ""} · ${selCount === items.length ? `all ${selCount}` : `${selCount}/${items.length}`} selected · u uploads them all`,
380
+ ` ${fmtNum(filtered.length)} ref${filtered.length === 1 ? "" : "s"}${filtered.length > pageSize ? ` · ${nav.start + 1}-${Math.min(nav.end, filtered.length)}` : ""}${search.query ? ` · filter "${search.query}"` : ""}${scan.fromCache ? " · cached" : ""} · ${selCount === items.length ? `all ${selCount}` : `${selCount}/${items.length}`} selected · u uploads them all${playHint}`,
271
381
  );
272
382
  const footer = busy
273
383
  ? h(Text, { color: C.ACCENT }, ` ${SPINNER[tick % SPINNER.length]} uploading…`)