@modelstatus/cli 0.1.37 → 0.1.39

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.37",
3
+ "version": "0.1.39",
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
@@ -17,6 +17,7 @@ import { AlertsView, meta as alertsMeta } from "./views/alerts.js";
17
17
  import { AccountView, meta as accountMeta } from "./views/account.js";
18
18
  import { IntegrationsView, meta as integrationsMeta } from "./views/integrations.js";
19
19
  import { SignIn } from "./signin.js";
20
+ import { playGameInTui, gameFitsTerminal } from "./game/launch.js";
20
21
 
21
22
  // `needsAuth: true` views show a sign-in card when there's no apiKey; Local +
22
23
  // What's New are public (signed registry over HTTP, no account needed).
@@ -141,6 +142,18 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
141
142
  if (handlesBackRef.current) return undefined;
142
143
  return setIdx((i) => (i - 1 + VIEWS.length) % VIEWS.length);
143
144
  }
145
+ // Shift-P: play Donkey Kong from ANY tab, any time. The Scan tab owns its
146
+ // own P (same launcher) so it isn't double-fired here.
147
+ if (input === "P" && curKeyRef.current !== "scan") {
148
+ const w = (stdout && stdout.columns) || outer;
149
+ const hh = (stdout && stdout.rows) || termRows;
150
+ if (!gameFitsTerminal(w, hh)) return showToast("terminal too small for the game — resize a bit", "#d97706");
151
+ playGameInTui({
152
+ dir, width: w, height: hh, initialView: curKeyRef.current, scanPhase: "play",
153
+ onError: (e) => showToast(`couldn't start the game: ${e?.message || e}`, "#dc2626"),
154
+ });
155
+ return;
156
+ }
144
157
  if (input === "q") return exit();
145
158
  },
146
159
  { isActive: !prompt && !capturing },
@@ -209,7 +222,7 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
209
222
  h(Text, {}, ""),
210
223
  toast ? h(Text, { color: toast.color }, ` ${toast.msg}`) : h(Text, {}, ""),
211
224
  h(StatusBar, { segsLeft, segsRight, width: W }),
212
- h(KeyBar, { keys, width: W }),
225
+ h(KeyBar, { keys: keys.some((k) => k.k === "P") ? keys : [...keys, { k: "P", label: "play" }], width: W }),
213
226
  );
214
227
  }
215
228
 
@@ -57,7 +57,7 @@ export const AIR_MAX = 130;
57
57
  export const GRAVITY = 14; // fx/tick added to vy while airborne (full jump → apex ≈ 3 cells)
58
58
  export const JUMP_CUT_GRAVITY = 42; // stronger gravity while RISING after release → short hop (~1.5 cells)
59
59
  export const TERMINAL_VY = 220; // fx/tick max fall speed
60
- export const JUMP_VY = -150; // initial up-velocity → apex ≈ 3 cells, hang ≈ 21 ticks
60
+ export const JUMP_VY = -180; // initial up-velocity → apex ≈ 4.5 cells (higher, snappier)
61
61
  export const COYOTE_TICKS = 5; // jump still allowed N ticks after walking off
62
62
  export const JUMP_BUFFER_TICKS = 6; // a press N ticks before landing still fires on land
63
63
 
@@ -96,10 +96,6 @@ export function cooldownForLevel(level) {
96
96
  export function barrelSpeedForLevel(level) {
97
97
  return Math.min(170, BARREL_ROLL + (level - 1) * 12);
98
98
  }
99
- /** Probability a rolling barrel takes a ladder down per eligibility check. */
100
- export function ladderChanceForLevel(level) {
101
- return Math.min(0.6, 0.18 + (level - 1) * 0.1);
102
- }
103
99
  /** By construction the jump apex (~3 cells) clears a 1-cell barrel + player at
104
100
  * every level — exposed so a test can assert the invariant numerically. */
105
101
  export function jumpClearsBarrel(/* level */) {
@@ -157,9 +153,11 @@ function girderIndexAt(platforms, x, y) {
157
153
  return -1;
158
154
  }
159
155
 
160
- /** The ladder occupying column x within [yTop..yBottom] at row y, or null. */
156
+ /** Ladders are DOUBLE-WIDE: they occupy columns [col, col+1]. The ladder at
157
+ * column x within [yTop..yBottom] at row y, or null. The 2-col catch makes
158
+ * mounting forgiving (a single-col ladder was too narrow to latch reliably). */
161
159
  function ladderAt(ladders, x, y) {
162
- for (const l of ladders) if (l.col === x && y >= l.yTop && y <= l.yBottom) return l;
160
+ for (const l of ladders) if ((x === l.col || x === l.col + 1) && y >= l.yTop && y <= l.yBottom) return l;
163
161
  return null;
164
162
  }
165
163
 
@@ -184,7 +182,10 @@ export function initGame({
184
182
  const { BOARD_W, BOARD_H } = boardSize(width, height);
185
183
  if (BOARD_W < MIN_W || BOARD_H < MIN_H) return { tooSmall: true, BOARD_W, BOARD_H };
186
184
 
187
- const N = Math.max(4, Math.min(5, Math.floor(BOARD_H / 3)));
185
+ // Fewer girders bigger vertical gaps → real jump headroom (a jump head-bonks
186
+ // the girder overhead, so girders ~3 rows apart capped jumps at ~1 cell; ~4+
187
+ // rows lets the higher JUMP_VY actually read as a tall jump).
188
+ const N = Math.max(3, Math.min(4, Math.floor(BOARD_H / 4)));
188
189
  const gap = Math.floor((BOARD_H - 2) / N);
189
190
  const amp = Math.max(0, Math.min(2, Math.floor((gap - 1) / 2)));
190
191
  const platforms = [];
@@ -335,7 +336,7 @@ function stepDying(s, _dt) {
335
336
  p.vy = Math.min(p.vy + GRAVITY, TERMINAL_VY);
336
337
  p.py = clampFx(p.py + p.vy, 0, toFx(s.BOARD_H - 1));
337
338
  syncCell(p);
338
- advanceBarrels(s, makeRnd(s)); // juice: hazards stay live during the death beat
339
+ advanceBarrels(s); // juice: hazards stay live during the death beat
339
340
  s.statusTimer -= 1;
340
341
  if (s.statusTimer <= 0) {
341
342
  if (s.lives > 0) {
@@ -405,7 +406,9 @@ function stepPlaying(s, input, dt) {
405
406
  if ((wantUp || wantDown) && !input.jump) {
406
407
  p.onLadder = true;
407
408
  p.vx = 0; p.vy = 0;
408
- p.px = toFx(onLad.col); // snap to the ladder column while latched
409
+ // Snap to whichever of the two rails the player mounted (double-wide ladder).
410
+ const railCol = cell(p.px) >= onLad.col + 1 ? onLad.col + 1 : onLad.col;
411
+ p.px = toFx(railCol);
409
412
  if (wantUp) p.py = Math.max(toFx(onLad.yTop), p.py - CLIMB_SPEED);
410
413
  else p.py = Math.min(toFx(onLad.yBottom), p.py + CLIMB_SPEED);
411
414
  // Reached an endpoint EXACTLY → step off onto that girder (off the ladder).
@@ -413,7 +416,7 @@ function stepPlaying(s, input, dt) {
413
416
  else if (p.py >= toFx(onLad.yBottom)) { p.onLadder = false; p.onGround = true; p.py = toFx(onLad.yBottom); }
414
417
  } else if (p.onLadder) {
415
418
  // Left the column, pressed jump, or no climb input → unlatch.
416
- const stillOn = onLad && cell(p.px) === onLad.col;
419
+ const stillOn = onLad && (cell(p.px) === onLad.col || cell(p.px) === onLad.col + 1);
417
420
  if (!stillOn || input.jump || !(input.up || input.down)) {
418
421
  p.onLadder = false;
419
422
  // settle onto a girder if one is at/below this cell; else gravity takes over.
@@ -535,7 +538,7 @@ function stepPlaying(s, input, dt) {
535
538
  }
536
539
 
537
540
  // 6/7. BARRELS -------------------------------------------------------------
538
- advanceBarrels(s, rnd);
541
+ advanceBarrels(s);
539
542
 
540
543
  // 8. COLLISIONS + SCORING --------------------------------------------------
541
544
  const pCol = cell(p.px), pRow = cell(p.py);
@@ -594,10 +597,11 @@ function stepPlaying(s, input, dt) {
594
597
  return finalize(s, s.rngSeed);
595
598
  }
596
599
 
597
- /** Advance every barrel one tick (roll / fall / descend ladders) in fx. */
598
- function advanceBarrels(s, rnd) {
599
- const { platforms, ladders, BOARD_W, BOARD_H } = s;
600
- const ladderChance = ladderChanceForLevel(s.level);
600
+ /** Advance every barrel one tick (roll along a girder / fall to the one below) in
601
+ * fx. Barrels NEVER take ladders — random mid-board drops were unreadable/unfair;
602
+ * a barrel rolls to the wall, bounces, and falls, a predictable zig-zag cascade. */
603
+ function advanceBarrels(s) {
604
+ const { platforms, BOARD_W, BOARD_H } = s;
601
605
  const maxPx = toFx(BOARD_W - 1);
602
606
  const surviving = [];
603
607
  for (const b of s.barrels) {
@@ -620,33 +624,25 @@ function advanceBarrels(s, rnd) {
620
624
  b.py = nextPy;
621
625
  }
622
626
  } else {
623
- // On a girder: maybe descend a ladder it's over.
624
- const lad = ladderAt(ladders, c, cell(b.py) + 1);
625
- if (lad && rnd() < ladderChance) {
627
+ // On a girder: roll along it (never down a ladder). Reaching a wall bounces
628
+ // the barrel and drops it to the girder below.
629
+ const nextPx = b.px + b.vx;
630
+ const nc = cell(nextPx);
631
+ if (nextPx < 0 || nextPx > maxPx) {
632
+ b.vx = -b.vx; // bounce off the wall and fall to the girder below
626
633
  b.falling = true;
627
- b.onLadder = true;
628
- b.px = toFx(lad.col);
629
634
  b.vy = GRAVITY;
630
635
  b.py = b.py + b.vy;
631
636
  } else {
632
- const nextPx = b.px + b.vx;
633
- const nc = cell(nextPx);
634
- if (nextPx < 0 || nextPx > maxPx) {
635
- b.vx = -b.vx; // bounce off the wall and fall to the girder below
637
+ const giHere = girderIndexAt(platforms, c, cell(b.py) + 1);
638
+ if (giHere >= 0 && nc >= 0 && nc < BOARD_W && platforms[giHere].slopeOffsets[nc] !== undefined) {
639
+ b.px = nextPx;
640
+ b.py = toFx(platforms[giHere].slopeOffsets[nc] - 1); // follow slope
641
+ } else {
642
+ b.px = nextPx; // rolled off the end → fall
636
643
  b.falling = true;
637
644
  b.vy = GRAVITY;
638
645
  b.py = b.py + b.vy;
639
- } else {
640
- const giHere = girderIndexAt(platforms, c, cell(b.py) + 1);
641
- if (giHere >= 0 && nc >= 0 && nc < BOARD_W && platforms[giHere].slopeOffsets[nc] !== undefined) {
642
- b.px = nextPx;
643
- b.py = toFx(platforms[giHere].slopeOffsets[nc] - 1); // follow slope
644
- } else {
645
- b.px = nextPx; // rolled off the end → fall
646
- b.falling = true;
647
- b.vy = GRAVITY;
648
- b.py = b.py + b.vy;
649
- }
650
646
  }
651
647
  }
652
648
  }
@@ -62,9 +62,10 @@ function buildGrid(state, glyph) {
62
62
  for (const p of platforms) {
63
63
  for (let x = 0; x < BOARD_W; x++) put(x, p.slopeOffsets[x], glyph.girder, "girder");
64
64
  }
65
- // ladders
65
+ // ladders — DOUBLE-WIDE: draw both rails (col and col+1) to match the 2-col
66
+ // climb hit-test in dk-core.ladderAt (easier to mount, reads as a real ladder).
66
67
  for (const l of ladders) {
67
- for (let y = l.yTop; y <= l.yBottom; y++) put(l.col, y, glyph.ladder, "ladder");
68
+ for (let y = l.yTop; y <= l.yBottom; y++) { put(l.col, y, glyph.ladder, "ladder"); put(l.col + 1, y, glyph.ladder, "ladder"); }
68
69
  }
69
70
  // goal + actors
70
71
  put(princess.x, princess.y, glyph.princess, "princess");
@@ -0,0 +1,83 @@
1
+ /* Shared TUI -> game handoff. Used by the global Shift-P shortcut (app.js, any
2
+ * tab) and the Scan tab. Unmounts Ink (releasing raw mode + stdin), runs the
3
+ * direct-ANSI game loop (which owns its own raw mode + alt screen), then remounts
4
+ * the TUI at the requested tab. A TRUE background scan SUBPROCESS feeds the game
5
+ * HUD and survives the unmount because it's a separate OS process. */
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { track } from "../../telemetry.js";
10
+ import { boardSize, MIN_W, MIN_H } from "./dk-core.js";
11
+
12
+ let launching = false; // module-level re-entrancy guard (one game at a time)
13
+
14
+ /** Does the board fit this terminal? (mirrors the loop's too-small refusal). */
15
+ export function gameFitsTerminal(width, height) {
16
+ const { BOARD_W, BOARD_H } = boardSize(width, height);
17
+ return BOARD_W >= MIN_W && BOARD_H >= MIN_H;
18
+ }
19
+
20
+ /**
21
+ * Launch Donkey Kong from inside the running TUI.
22
+ * { dir, width, height, initialView, scanPhase, onError }
23
+ * Returns when the player quits the game and the TUI has been remounted.
24
+ */
25
+ export async function playGameInTui({ dir, width, height, initialView = "scan", scanPhase = "idle", onError } = {}) {
26
+ if (launching) return;
27
+ launching = true;
28
+ track("game_opened", { game: "donkey_kong", scan_phase: scanPhase });
29
+ try {
30
+ const [{ runGame }, { startScanProcess }, { appController }, { writeDiskScan, loadRegistry }] = await Promise.all([
31
+ import("./loop.js"),
32
+ import("../../sources/scan-process.js"),
33
+ import("../app.js"),
34
+ import("../scan-stream.js"),
35
+ ]);
36
+
37
+ // (1) Start a TRUE background scan SUBPROCESS over `dir`. It survives the Ink
38
+ // unmount (separate OS process). Pre-fetch + cache the registry so the worker
39
+ // skips the network; on done, persist candidates to the disk cache so the
40
+ // remounted view loads fresh results.
41
+ let cacheFile = null;
42
+ try {
43
+ const snapshot = await loadRegistry().catch(() => null);
44
+ if (snapshot) {
45
+ cacheFile = path.join(os.tmpdir(), `mm-reg-game-${process.pid}.json`);
46
+ fs.writeFileSync(cacheFile, JSON.stringify(snapshot));
47
+ }
48
+ } catch { /* worker fetches itself */ }
49
+
50
+ let handle = null;
51
+ try {
52
+ handle = startScanProcess(
53
+ { root: dir, registryCachePath: cacheFile || undefined },
54
+ { onDone: (r) => { try { if (r?.candidates?.length) writeDiskScan(dir, r.candidates); } catch { /* best effort */ } } },
55
+ );
56
+ } catch { handle = null; }
57
+
58
+ // (2) Unmount the Ink tree (releases raw mode + stdin). Let teardown settle.
59
+ appController.unmount();
60
+ await new Promise((r) => setImmediate(r));
61
+
62
+ // (3) Run the game. Its loop owns raw mode / alt screen / the diff renderer
63
+ // and restores the terminal (cooked, main buffer, cursor shown) on exit.
64
+ try {
65
+ await runGame({
66
+ width: process.stdout.columns || width,
67
+ height: process.stdout.rows || height,
68
+ level: 1,
69
+ scanStore: handle,
70
+ });
71
+ } finally {
72
+ try { handle && handle.abort(); } catch { /* ignore */ }
73
+ try { if (cacheFile) fs.unlinkSync(cacheFile); } catch { /* ignore */ }
74
+ }
75
+
76
+ // (4) Remount the TUI where the player was (so quitting returns you there).
77
+ appController.remount({ initialView, fresh: false });
78
+ } catch (e) {
79
+ if (onError) onError(e); else throw e;
80
+ } finally {
81
+ launching = false;
82
+ }
83
+ }
@@ -45,7 +45,8 @@ export const meta = {
45
45
  { k: "/", label: "search" },
46
46
  { k: "g", label: "rescan" },
47
47
  { k: "u", label: "upload all" },
48
- { k: "P", label: "play" }, // only meaningful mid-scan; shown when running
48
+ { k: "P", label: "play" }, // launches anytime (also the global Shift-P)
49
+ { k: "N", label: "new project" },
49
50
  ],
50
51
  };
51
52
 
@@ -79,68 +80,16 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
79
80
  if (launchingRef.current) return;
80
81
  if (!canPlay) return ui?.showToast?.("terminal too small for the game — resize a bit", "#d97706");
81
82
  launchingRef.current = true;
82
- track("game_opened", { game: "donkey_kong", scan_phase: scan.phase });
83
83
  // Test seam: a caller may inject the launcher so the Ink↔game handoff (which
84
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
- }
85
+ // either. Production passes nothing → the shared launcher runs.
91
86
  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");
87
+ if (launchGameImpl) { await launchGameImpl({ dir, width, height }); return; }
88
+ const { playGameInTui } = await import("../game/launch.js");
89
+ await playGameInTui({
90
+ dir, width, height, initialView: "scan", scanPhase: scan.phase,
91
+ onError: (e) => ui?.showToast?.(`couldn't start the game: ${e?.message || e}`, "#dc2626"),
92
+ });
144
93
  } finally {
145
94
  launchingRef.current = false;
146
95
  }
@@ -322,13 +271,10 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
322
271
  if (input === "a") return setItems((its) => its.map((it) => ({ ...it, selected: true })));
323
272
  if (input === "x") return setItems((its) => its.map((it) => ({ ...it, selected: false })));
324
273
  if (input === "p") return setProjectIdx((i) => (i + 1) % projOptions.length);
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
- }
274
+ // P always launches Donkey Kong (any time, not just mid-scan) to match the
275
+ // global Shift-P shortcut. "New project" moved to N.
276
+ if (input === "P") { launchGame(); return; }
277
+ if (input === "N") return ui.askPrompt("New project", { onSubmit: createProject });
332
278
  if (input === "g") return scan.reload();
333
279
  if (input === "u") return upload();
334
280
  if (input === "l") {