@modelstatus/cli 0.1.37 → 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 +1 -1
- package/src/tui/app.js +14 -1
- package/src/tui/game/dk-core.js +13 -6
- package/src/tui/game/dk-render.js +3 -2
- package/src/tui/game/launch.js +83 -0
- package/src/tui/views/scan.js +13 -67
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modelstatus/cli",
|
|
3
|
-
"version": "0.1.
|
|
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
|
|
package/src/tui/game/dk-core.js
CHANGED
|
@@ -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 = -
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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 = [];
|
|
@@ -405,7 +410,9 @@ function stepPlaying(s, input, dt) {
|
|
|
405
410
|
if ((wantUp || wantDown) && !input.jump) {
|
|
406
411
|
p.onLadder = true;
|
|
407
412
|
p.vx = 0; p.vy = 0;
|
|
408
|
-
|
|
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);
|
|
409
416
|
if (wantUp) p.py = Math.max(toFx(onLad.yTop), p.py - CLIMB_SPEED);
|
|
410
417
|
else p.py = Math.min(toFx(onLad.yBottom), p.py + CLIMB_SPEED);
|
|
411
418
|
// Reached an endpoint EXACTLY → step off onto that girder (off the ladder).
|
|
@@ -413,7 +420,7 @@ function stepPlaying(s, input, dt) {
|
|
|
413
420
|
else if (p.py >= toFx(onLad.yBottom)) { p.onLadder = false; p.onGround = true; p.py = toFx(onLad.yBottom); }
|
|
414
421
|
} else if (p.onLadder) {
|
|
415
422
|
// Left the column, pressed jump, or no climb input → unlatch.
|
|
416
|
-
const stillOn = onLad && cell(p.px) === onLad.col;
|
|
423
|
+
const stillOn = onLad && (cell(p.px) === onLad.col || cell(p.px) === onLad.col + 1);
|
|
417
424
|
if (!stillOn || input.jump || !(input.up || input.down)) {
|
|
418
425
|
p.onLadder = false;
|
|
419
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
|
+
}
|
package/src/tui/views/scan.js
CHANGED
|
@@ -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" }, //
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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") {
|