@modelstatus/cli 0.1.36 → 0.1.38

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.36",
3
+ "version": "0.1.38",
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
 
@@ -157,9 +157,11 @@ function girderIndexAt(platforms, x, y) {
157
157
  return -1;
158
158
  }
159
159
 
160
- /** The ladder occupying column x within [yTop..yBottom] at row y, or null. */
160
+ /** Ladders are DOUBLE-WIDE: they occupy columns [col, col+1]. The ladder at
161
+ * column x within [yTop..yBottom] at row y, or null. The 2-col catch makes
162
+ * mounting forgiving (a single-col ladder was too narrow to latch reliably). */
161
163
  function ladderAt(ladders, x, y) {
162
- for (const l of ladders) if (l.col === x && y >= l.yTop && y <= l.yBottom) return l;
164
+ for (const l of ladders) if ((x === l.col || x === l.col + 1) && y >= l.yTop && y <= l.yBottom) return l;
163
165
  return null;
164
166
  }
165
167
 
@@ -184,7 +186,10 @@ export function initGame({
184
186
  const { BOARD_W, BOARD_H } = boardSize(width, height);
185
187
  if (BOARD_W < MIN_W || BOARD_H < MIN_H) return { tooSmall: true, BOARD_W, BOARD_H };
186
188
 
187
- const N = Math.max(4, Math.min(5, Math.floor(BOARD_H / 3)));
189
+ // Fewer girders bigger vertical gaps → real jump headroom (a jump head-bonks
190
+ // the girder overhead, so girders ~3 rows apart capped jumps at ~1 cell; ~4+
191
+ // rows lets the higher JUMP_VY actually read as a tall jump).
192
+ const N = Math.max(3, Math.min(4, Math.floor(BOARD_H / 4)));
188
193
  const gap = Math.floor((BOARD_H - 2) / N);
189
194
  const amp = Math.max(0, Math.min(2, Math.floor((gap - 1) / 2)));
190
195
  const platforms = [];
@@ -394,12 +399,20 @@ function stepPlaying(s, input, dt) {
394
399
  // (and isn't trying to jump). Up only latches if there's run above; down only
395
400
  // if there's run below — so standing at an endpoint and pressing "off" doesn't
396
401
  // glue you to the ladder.
397
- const wantUp = input.up && onLad && row > onLad.yTop;
398
- const wantDown = input.down && onLad && row < onLad.yBottom;
402
+ // Gate on the FIXED-POINT position, not the rounded cell: once the player rounds
403
+ // into the top cell (cell(py)===yTop) but py hasn't yet reached the exact snap
404
+ // point toFx(yTop), `cell(py) > yTop` is false — the climb would stall a fraction
405
+ // below the girder AND the unlatch can't fire while up is held → a deadlock at
406
+ // the top of every ladder. Comparing px/py in fx lets the climb finish onto the
407
+ // girder (the step-off below snaps + unlatches). Same fix for descending.
408
+ const wantUp = input.up && onLad && p.py > toFx(onLad.yTop);
409
+ const wantDown = input.down && onLad && p.py < toFx(onLad.yBottom);
399
410
  if ((wantUp || wantDown) && !input.jump) {
400
411
  p.onLadder = true;
401
412
  p.vx = 0; p.vy = 0;
402
- p.px = toFx(onLad.col); // snap to the ladder column while latched
413
+ // Snap to whichever of the two rails the player mounted (double-wide ladder).
414
+ const railCol = cell(p.px) >= onLad.col + 1 ? onLad.col + 1 : onLad.col;
415
+ p.px = toFx(railCol);
403
416
  if (wantUp) p.py = Math.max(toFx(onLad.yTop), p.py - CLIMB_SPEED);
404
417
  else p.py = Math.min(toFx(onLad.yBottom), p.py + CLIMB_SPEED);
405
418
  // Reached an endpoint EXACTLY → step off onto that girder (off the ladder).
@@ -407,7 +420,7 @@ function stepPlaying(s, input, dt) {
407
420
  else if (p.py >= toFx(onLad.yBottom)) { p.onLadder = false; p.onGround = true; p.py = toFx(onLad.yBottom); }
408
421
  } else if (p.onLadder) {
409
422
  // Left the column, pressed jump, or no climb input → unlatch.
410
- const stillOn = onLad && cell(p.px) === onLad.col;
423
+ const stillOn = onLad && (cell(p.px) === onLad.col || cell(p.px) === onLad.col + 1);
411
424
  if (!stillOn || input.jump || !(input.up || input.down)) {
412
425
  p.onLadder = false;
413
426
  // settle onto a girder if one is at/below this cell; else gravity takes over.
@@ -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") {