@modelstatus/cli 0.1.35 → 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.
- package/package.json +1 -1
- package/src/index.js +113 -0
- package/src/sources/scan-process.js +238 -0
- package/src/sources/scan-worker.js +148 -0
- package/src/tui/app.js +40 -1
- package/src/tui/game/DkGame.js +18 -184
- package/src/tui/game/dk-core.js +501 -226
- package/src/tui/game/dk-render.js +46 -0
- package/src/tui/game/input.js +169 -0
- package/src/tui/game/loop.js +337 -0
- package/src/tui/game/term.js +330 -0
- package/src/tui/views/scan.js +94 -80
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/* Direct-ANSI terminal control + double-buffered cell renderer for the game.
|
|
2
|
+
* NO Ink, NO React. This is the renderer half of the DECISION: a diff-based
|
|
3
|
+
* cell buffer that emits ONLY changed cells (cursor-move on run-break + sticky
|
|
4
|
+
* SGR fg) so a near-static frame writes ~0 bytes and the screen is NEVER cleared
|
|
5
|
+
* during play — that is what removes the old Ink clearTerminal flicker.
|
|
6
|
+
*
|
|
7
|
+
* The pieces here are deliberately split so they're unit-testable WITHOUT a TTY:
|
|
8
|
+
* - Backbuffer: pure cell store + diff() string builder (front/back typed
|
|
9
|
+
* arrays). diff-minimality is asserted directly in tests.
|
|
10
|
+
* - Term: the IO shell (alt-screen, raw mode, hide cursor, SIGWINCH) with a
|
|
11
|
+
* SINGLE idempotent restore() wired to every exit path — the #1 safety risk.
|
|
12
|
+
*
|
|
13
|
+
* Color: GAME_COLORS are hex strings ("#rrggbb"); we pack to a 24-bit int once
|
|
14
|
+
* and emit truecolor SGR (\x1b[38;2;r;g;bm), sticky so a run of one color emits
|
|
15
|
+
* one SGR. When color is off (NO_COLOR / MM_ASCII / TERM=dumb) we skip ALL SGR
|
|
16
|
+
* and just place glyphs — matching the rest of the TUI's degrade discipline. */
|
|
17
|
+
|
|
18
|
+
// ---- env flags (read once, mirror ui.js / dk-render.js) --------------------
|
|
19
|
+
const ASCII = process.env.MM_ASCII === "1" || process.env.TERM === "dumb";
|
|
20
|
+
export const COLOR_ON = !ASCII && process.env.NO_COLOR == null;
|
|
21
|
+
|
|
22
|
+
// ---- ANSI primitives -------------------------------------------------------
|
|
23
|
+
export const ESC = "\x1b";
|
|
24
|
+
const CSI = ESC + "[";
|
|
25
|
+
export const ALT_ENTER = CSI + "?1049h"; // enter alternate screen buffer
|
|
26
|
+
export const ALT_LEAVE = CSI + "?1049l"; // leave it (restores prior screen)
|
|
27
|
+
export const CURSOR_HIDE = CSI + "?25l";
|
|
28
|
+
export const CURSOR_SHOW = CSI + "?25h";
|
|
29
|
+
export const SGR_RESET = CSI + "0m";
|
|
30
|
+
const CLEAR_SCREEN = CSI + "2J" + CSI + "H";
|
|
31
|
+
|
|
32
|
+
/** 1-based cursor positioning (terminals are 1-indexed). */
|
|
33
|
+
export function cursorTo(x, y) {
|
|
34
|
+
return CSI + (y + 1) + ";" + (x + 1) + "H";
|
|
35
|
+
}
|
|
36
|
+
/** Truecolor foreground SGR for a packed 24-bit RGB int. */
|
|
37
|
+
export function fgSeq(rgb) {
|
|
38
|
+
return CSI + "38;2;" + ((rgb >> 16) & 255) + ";" + ((rgb >> 8) & 255) + ";" + (rgb & 255) + "m";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** "#rrggbb" | "#rgb" | "rrggbb" -> packed 24-bit int. Unparseable -> -1
|
|
42
|
+
* (sentinel = "no color", so the diff path skips SGR for that cell). */
|
|
43
|
+
export function packHex(hex) {
|
|
44
|
+
if (typeof hex !== "string") return -1;
|
|
45
|
+
let h = hex[0] === "#" ? hex.slice(1) : hex;
|
|
46
|
+
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
|
47
|
+
if (h.length !== 6) return -1;
|
|
48
|
+
const n = parseInt(h, 16);
|
|
49
|
+
return Number.isNaN(n) ? -1 : n & 0xffffff;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const SPACE = 32; // char code for a blank cell
|
|
53
|
+
const NO_FG = -1; // packed-fg sentinel for "default / no SGR"
|
|
54
|
+
const DIRTY = -2; // front-buffer sentinel forcing every cell to differ once
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Double-buffered cell grid. Cells are two flat typed arrays:
|
|
58
|
+
* ch : Uint16Array of char codes (BMP glyphs — DK's glyph set is all BMP)
|
|
59
|
+
* fg : Int32Array of packed 24-bit RGB (or NO_FG = -1 = default)
|
|
60
|
+
* `front` is what's on screen; you write the next frame into `back` (via set /
|
|
61
|
+
* clear / setCell or fillFromCells), then diff() returns the minimal escape
|
|
62
|
+
* string and (when applied) swaps the buffers.
|
|
63
|
+
*
|
|
64
|
+
* Pure + TTY-free: construct, write cells, call diff() — assert the string.
|
|
65
|
+
*/
|
|
66
|
+
export class Backbuffer {
|
|
67
|
+
constructor(width, height, { color = COLOR_ON } = {}) {
|
|
68
|
+
this.color = color;
|
|
69
|
+
this.resize(width, height);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
resize(width, height) {
|
|
73
|
+
this.width = Math.max(0, width | 0);
|
|
74
|
+
this.height = Math.max(0, height | 0);
|
|
75
|
+
this.n = this.width * this.height;
|
|
76
|
+
this.frontCh = new Uint16Array(this.n);
|
|
77
|
+
this.frontFg = new Int32Array(this.n);
|
|
78
|
+
this.backCh = new Uint16Array(this.n);
|
|
79
|
+
this.backFg = new Int32Array(this.n);
|
|
80
|
+
this.frontCh.fill(SPACE);
|
|
81
|
+
this.backCh.fill(SPACE);
|
|
82
|
+
this.frontFg.fill(NO_FG);
|
|
83
|
+
this.backFg.fill(NO_FG);
|
|
84
|
+
// Force the very next diff() to be a full paint (front all-dirty).
|
|
85
|
+
this.markAllDirty();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Poison the front buffer so EVERY cell differs from back -> full repaint on
|
|
89
|
+
* the next diff(). Used at game start and after SIGWINCH (size changed). */
|
|
90
|
+
markAllDirty() {
|
|
91
|
+
this.frontCh.fill(0xffff);
|
|
92
|
+
this.frontFg.fill(DIRTY);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Reset the BACK buffer to blank before composing a frame. */
|
|
96
|
+
clearBack(fg = NO_FG) {
|
|
97
|
+
this.backCh.fill(SPACE);
|
|
98
|
+
this.backFg.fill(fg);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Write one cell into the back buffer. Out-of-bounds is a safe no-op so the
|
|
102
|
+
* caller never has to bounds-check (e.g. a HUD string longer than the row). */
|
|
103
|
+
setCell(x, y, chCode, fg = NO_FG) {
|
|
104
|
+
if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
|
|
105
|
+
const i = y * this.width + x;
|
|
106
|
+
this.backCh[i] = chCode & 0xffff;
|
|
107
|
+
this.backFg[i] = this.color ? fg : NO_FG;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Write a string starting at (x,y), one column per code unit, clipped to the
|
|
111
|
+
* row. fg applies to the whole run. Returns the x after the last glyph. */
|
|
112
|
+
setText(x, y, text, fg = NO_FG) {
|
|
113
|
+
for (let k = 0; k < text.length; k++) {
|
|
114
|
+
this.setCell(x + k, y, text.charCodeAt(k), fg);
|
|
115
|
+
}
|
|
116
|
+
return x + text.length;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Fill a rectangle of the back buffer from a flat cell descriptor produced by
|
|
121
|
+
* dk-render.fillCells: arrays `ch` (char codes) and `fg` (packed RGB) of
|
|
122
|
+
* length w*h, placed with the top-left at (ox, oy). Lets the renderer hand the
|
|
123
|
+
* board straight into the buffer with no per-cell call overhead.
|
|
124
|
+
*/
|
|
125
|
+
blit(cells, ox, oy, w, h) {
|
|
126
|
+
const { ch, fg } = cells;
|
|
127
|
+
for (let cy = 0; cy < h; cy++) {
|
|
128
|
+
const dy = oy + cy;
|
|
129
|
+
if (dy < 0 || dy >= this.height) continue;
|
|
130
|
+
const srcRow = cy * w;
|
|
131
|
+
const dstRow = dy * this.width;
|
|
132
|
+
for (let cx = 0; cx < w; cx++) {
|
|
133
|
+
const dx = ox + cx;
|
|
134
|
+
if (dx < 0 || dx >= this.width) continue;
|
|
135
|
+
const di = dstRow + dx;
|
|
136
|
+
this.backCh[di] = ch[srcRow + cx] & 0xffff;
|
|
137
|
+
this.backFg[di] = this.color ? fg[srcRow + cx] : NO_FG;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Build the MINIMAL escape string that turns the on-screen (front) buffer into
|
|
144
|
+
* the composed (back) buffer:
|
|
145
|
+
* - skip cells that are unchanged (no bytes)
|
|
146
|
+
* - emit a cursor-move ONLY when the cursor isn't already where it needs to
|
|
147
|
+
* be (i.e. only at the start of a changed run / after a skipped cell)
|
|
148
|
+
* - emit an fg SGR ONLY when the color changes (sticky across the frame)
|
|
149
|
+
* - reset SGR once at the very end (and only if any color was emitted)
|
|
150
|
+
* Does NOT mutate buffers; call swap() after writing the result to apply.
|
|
151
|
+
*/
|
|
152
|
+
diff() {
|
|
153
|
+
let out = "";
|
|
154
|
+
let cx = -1, cy = -1; // tracked cursor position (-1 = unknown)
|
|
155
|
+
let curFg = -1; // currently-active SGR fg (-1 = none set)
|
|
156
|
+
let emittedColor = false;
|
|
157
|
+
const { width, n, backCh, backFg, frontCh, frontFg, color } = this;
|
|
158
|
+
for (let i = 0; i < n; i++) {
|
|
159
|
+
if (backCh[i] === frontCh[i] && backFg[i] === frontFg[i]) {
|
|
160
|
+
continue; // unchanged — emit nothing; the next change will reposition
|
|
161
|
+
}
|
|
162
|
+
const y = (i / width) | 0;
|
|
163
|
+
const x = i - y * width;
|
|
164
|
+
if (x !== cx || y !== cy) {
|
|
165
|
+
out += cursorTo(x, y);
|
|
166
|
+
cx = x; cy = y;
|
|
167
|
+
}
|
|
168
|
+
if (color) {
|
|
169
|
+
const fg = backFg[i];
|
|
170
|
+
if (fg !== curFg) {
|
|
171
|
+
if (fg === NO_FG) {
|
|
172
|
+
out += SGR_RESET;
|
|
173
|
+
curFg = NO_FG;
|
|
174
|
+
emittedColor = false; // reset clears the sticky color
|
|
175
|
+
} else {
|
|
176
|
+
out += fgSeq(fg);
|
|
177
|
+
curFg = fg;
|
|
178
|
+
emittedColor = true;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
out += String.fromCharCode(backCh[i]);
|
|
183
|
+
cx += 1; // we advanced one column by printing the glyph
|
|
184
|
+
}
|
|
185
|
+
if (emittedColor) out += SGR_RESET;
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Apply: copy back -> front so the next frame diffs against what's now shown.
|
|
190
|
+
* (Call after writing diff() output to the terminal.) */
|
|
191
|
+
swap() {
|
|
192
|
+
this.frontCh.set(this.backCh);
|
|
193
|
+
this.frontFg.set(this.backFg);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Convenience for headless tests: returns the diff string AND applies it. */
|
|
197
|
+
render() {
|
|
198
|
+
const s = this.diff();
|
|
199
|
+
this.swap();
|
|
200
|
+
return s;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Term: the IO shell that owns alt-screen, raw mode, the hidden cursor, resize,
|
|
206
|
+
* and — most importantly — a SINGLE idempotent restore() registered on every
|
|
207
|
+
* exit path so a crash mid-game can NEVER leave the user in raw mode + alt
|
|
208
|
+
* screen + hidden cursor (the "broken terminal").
|
|
209
|
+
*
|
|
210
|
+
* Injectable streams/hooks (`out`, `inp`, `proc`) make it fully testable: tests
|
|
211
|
+
* pass fakes and assert the exact teardown sequence without touching a TTY.
|
|
212
|
+
*/
|
|
213
|
+
export class Term {
|
|
214
|
+
constructor({
|
|
215
|
+
out = process.stdout,
|
|
216
|
+
inp = process.stdin,
|
|
217
|
+
proc = process,
|
|
218
|
+
color = COLOR_ON,
|
|
219
|
+
} = {}) {
|
|
220
|
+
this.out = out;
|
|
221
|
+
this.inp = inp;
|
|
222
|
+
this.proc = proc;
|
|
223
|
+
this.color = color;
|
|
224
|
+
this.started = false;
|
|
225
|
+
this.restored = false;
|
|
226
|
+
this._wasRaw = false;
|
|
227
|
+
this._signalHandlers = null;
|
|
228
|
+
this._onResize = null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
write(s) {
|
|
232
|
+
if (s) this.out.write(s);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Enter the game's terminal mode: alt screen, hide cursor, raw stdin, and
|
|
236
|
+
* wire bulletproof teardown. Idempotent. */
|
|
237
|
+
start() {
|
|
238
|
+
if (this.started) return;
|
|
239
|
+
this.started = true;
|
|
240
|
+
this.restored = false;
|
|
241
|
+
|
|
242
|
+
// Enter alt screen + hide cursor ONCE (never per frame — that's the anti-
|
|
243
|
+
// flicker invariant). Clear the alt buffer so a stale prior frame can't show
|
|
244
|
+
// through before the first diff paints.
|
|
245
|
+
this.write(ALT_ENTER + CURSOR_HIDE + CLEAR_SCREEN);
|
|
246
|
+
|
|
247
|
+
// Raw mode so we get bytes immediately (no line buffering / no echo). Save
|
|
248
|
+
// the prior state so restore() returns the terminal exactly as it was.
|
|
249
|
+
const inp = this.inp;
|
|
250
|
+
if (inp && typeof inp.setRawMode === "function" && inp.isTTY) {
|
|
251
|
+
this._wasRaw = !!inp.isRaw;
|
|
252
|
+
inp.setRawMode(true);
|
|
253
|
+
}
|
|
254
|
+
if (inp && typeof inp.resume === "function") inp.resume();
|
|
255
|
+
|
|
256
|
+
// Bulletproof teardown: a single restore() on EVERY way out.
|
|
257
|
+
const restore = () => this.restore();
|
|
258
|
+
const handlers = {
|
|
259
|
+
exit: restore,
|
|
260
|
+
SIGINT: () => { this.restore(); this.proc.exit(130); },
|
|
261
|
+
SIGTERM: () => { this.restore(); this.proc.exit(143); },
|
|
262
|
+
SIGHUP: () => { this.restore(); this.proc.exit(129); },
|
|
263
|
+
uncaughtException: (err) => {
|
|
264
|
+
this.restore();
|
|
265
|
+
// Surface the error on the now-restored terminal, then exit non-zero.
|
|
266
|
+
try { (this.proc.stderr || process.stderr).write(String(err && err.stack || err) + "\n"); } catch {}
|
|
267
|
+
this.proc.exit(1);
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
this._signalHandlers = handlers;
|
|
271
|
+
for (const [evt, fn] of Object.entries(handlers)) this.proc.on(evt, fn);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Register a resize callback. On SIGWINCH we recompute nothing here (the loop
|
|
275
|
+
* owns board math) — we just notify; the loop reallocs + forces a full repaint
|
|
276
|
+
* via Backbuffer.markAllDirty(). Debounced is the loop's concern; we pass the
|
|
277
|
+
* raw signal. */
|
|
278
|
+
onResize(cb) {
|
|
279
|
+
this._onResize = cb;
|
|
280
|
+
const handler = () => { if (this._onResize) this._onResize(this.size()); };
|
|
281
|
+
this._resizeHandler = handler;
|
|
282
|
+
this.proc.on("SIGWINCH", handler);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
size() {
|
|
286
|
+
return {
|
|
287
|
+
width: (this.out && this.out.columns) || 80,
|
|
288
|
+
height: (this.out && this.out.rows) || 24,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* IDEMPOTENT restore: leave alt screen, show cursor, reset SGR, drop raw mode
|
|
294
|
+
* back to its prior state, and unhook every signal handler. Safe to call
|
|
295
|
+
* twice (double-restore is a no-op) — which it WILL be (try/finally in the loop
|
|
296
|
+
* plus the exit handler both call it).
|
|
297
|
+
*/
|
|
298
|
+
restore() {
|
|
299
|
+
if (this.restored) return;
|
|
300
|
+
this.restored = true;
|
|
301
|
+
|
|
302
|
+
// Order matters: reset SGR + show cursor + leave alt LAST so the chrome
|
|
303
|
+
// lands back on the normal buffer with a visible cooked cursor.
|
|
304
|
+
try { this.write(SGR_RESET + CURSOR_SHOW + ALT_LEAVE); } catch {}
|
|
305
|
+
|
|
306
|
+
const inp = this.inp;
|
|
307
|
+
try {
|
|
308
|
+
if (inp && typeof inp.setRawMode === "function" && inp.isTTY) {
|
|
309
|
+
inp.setRawMode(this._wasRaw);
|
|
310
|
+
}
|
|
311
|
+
} catch {}
|
|
312
|
+
// Don't pause stdin here — the caller (TUI remount) may want to keep reading.
|
|
313
|
+
|
|
314
|
+
// Unhook our signal/exit handlers so a subsequent normal exit doesn't double-
|
|
315
|
+
// fire and so the process can exit cleanly when nothing else is listening.
|
|
316
|
+
if (this._signalHandlers) {
|
|
317
|
+
for (const [evt, fn] of Object.entries(this._signalHandlers)) {
|
|
318
|
+
try { this.proc.removeListener(evt, fn); } catch {}
|
|
319
|
+
}
|
|
320
|
+
this._signalHandlers = null;
|
|
321
|
+
}
|
|
322
|
+
if (this._resizeHandler) {
|
|
323
|
+
try { this.proc.removeListener("SIGWINCH", this._resizeHandler); } catch {}
|
|
324
|
+
this._resizeHandler = null;
|
|
325
|
+
}
|
|
326
|
+
this.started = false;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export const _internals = { SPACE, NO_FG, DIRTY, CLEAR_SCREEN };
|
package/src/tui/views/scan.js
CHANGED
|
@@ -5,17 +5,21 @@
|
|
|
5
5
|
* Scrollable (j/k/↑↓), filterable (/), with a full-width detail panel; ↵ drills
|
|
6
6
|
* into the highlighted model's usage locations and ↵ opens one in your editor.
|
|
7
7
|
*
|
|
8
|
-
* BACKGROUND RUNNER: the walk runs on the shared cooperative engine
|
|
9
|
-
* useStreamingScan (scan-stream.js) → scanFilesystemStreaming
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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. */
|
|
17
20
|
import React from "react";
|
|
18
21
|
import fs from "node:fs";
|
|
22
|
+
import os from "node:os";
|
|
19
23
|
import path from "node:path";
|
|
20
24
|
import { Box, Text, useInput } from "ink";
|
|
21
25
|
import {
|
|
@@ -30,7 +34,6 @@ import { readSnippet } from "../snippet.js";
|
|
|
30
34
|
import { buildUsages, assignProjects } from "../../upload.js";
|
|
31
35
|
import { track } from "../../telemetry.js";
|
|
32
36
|
import { openUrl, openLocation } from "../../openUrl.js";
|
|
33
|
-
import { DonkeyKong } from "../game/DkGame.js";
|
|
34
37
|
import { boardSize, MIN_W, MIN_H } from "../game/dk-core.js";
|
|
35
38
|
|
|
36
39
|
export const meta = {
|
|
@@ -48,7 +51,7 @@ export const meta = {
|
|
|
48
51
|
|
|
49
52
|
const PANEL_H = 6;
|
|
50
53
|
|
|
51
|
-
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 }) {
|
|
52
55
|
// Reuse the SHARED cached scan engine (same module-level cache as the Here
|
|
53
56
|
// tab) instead of re-walking the repo on every visit. Switching to Scan after
|
|
54
57
|
// Here is now instant; `g` forces a fresh walk for both.
|
|
@@ -64,30 +67,83 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
|
|
|
64
67
|
const tick = useTick(80, running || busy);
|
|
65
68
|
const search = useSearch();
|
|
66
69
|
|
|
67
|
-
// "Play Donkey Kong while you wait"
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
// the
|
|
71
|
-
// hidden on tiny terminals and the open is a no-op there.
|
|
72
|
-
const [gameMode, setGameMode] = React.useState(false);
|
|
73
|
-
const gameRef = React.useRef(false);
|
|
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
74
|
const { BOARD_W, BOARD_H } = boardSize(width, height);
|
|
75
75
|
const canPlay = BOARD_W >= MIN_W && BOARD_H >= MIN_H;
|
|
76
|
-
|
|
76
|
+
const launchingRef = React.useRef(false);
|
|
77
|
+
|
|
78
|
+
async function launchGame() {
|
|
79
|
+
if (launchingRef.current) return;
|
|
77
80
|
if (!canPlay) return ui?.showToast?.("terminal too small for the game — resize a bit", "#d97706");
|
|
78
|
-
|
|
79
|
-
setGameMode(true);
|
|
80
|
-
ui?.setCapturing?.(true); // stop app-shell number/Tab/q keys leaking while playing
|
|
81
|
-
// setHandlesBack(true) is asserted by the effect (keyed on gameMode); the
|
|
82
|
-
// game's own useInput swallows backspace to close itself.
|
|
81
|
+
launchingRef.current = true;
|
|
83
82
|
track("game_opened", { game: "donkey_kong", scan_phase: scan.phase });
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
+
}
|
|
91
147
|
}
|
|
92
148
|
|
|
93
149
|
// Upload-target projects (cheap; independent of the scan).
|
|
@@ -161,29 +217,13 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
|
|
|
161
217
|
ui?.reportStatus?.({ counts, context: ctx });
|
|
162
218
|
}, [items, selCount, project, ui]);
|
|
163
219
|
|
|
164
|
-
// While playing, surface scan completion once (toast) so the player knows the
|
|
165
|
-
// results are ready to review (Enter/q/esc back out). The DkGame HUD also flips
|
|
166
|
-
// to a "scan complete · N models · q to view results" line — this is the
|
|
167
|
-
// shell-level nudge for players watching the toast row, not the board.
|
|
168
|
-
const completeToastedRef = React.useRef(false);
|
|
169
|
-
React.useEffect(() => {
|
|
170
|
-
if (gameMode && scan.phase === "done" && !completeToastedRef.current) {
|
|
171
|
-
completeToastedRef.current = true;
|
|
172
|
-
const n = scan.candidateCount ?? scan.candidates?.length ?? 0;
|
|
173
|
-
ui?.showToast?.(`${GLYPH.check} scan complete — ${fmtNum(n)} model${n === 1 ? "" : "s"}, press Enter to review`);
|
|
174
|
-
}
|
|
175
|
-
if (running) completeToastedRef.current = false; // arm again for a g-rescan
|
|
176
|
-
}, [gameMode, scan.phase, running]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
177
|
-
|
|
178
220
|
// Tell the shell when backspace should back out *within* this view (drilled
|
|
179
|
-
// into refs
|
|
180
|
-
// to the previous tab. Includes gameMode so closing the game restores the
|
|
181
|
-
// correct list/refs/filter back-handling without a stale value.
|
|
221
|
+
// into refs or an active filter) rather than stepping to the previous tab.
|
|
182
222
|
const setHandlesBack = ui?.setHandlesBack;
|
|
183
223
|
React.useEffect(() => {
|
|
184
|
-
setHandlesBack?.(
|
|
224
|
+
setHandlesBack?.(focus === "refs" || !!search.query);
|
|
185
225
|
return () => setHandlesBack?.(false);
|
|
186
|
-
}, [setHandlesBack,
|
|
226
|
+
}, [setHandlesBack, focus, search.query]);
|
|
187
227
|
|
|
188
228
|
function openRef(r) {
|
|
189
229
|
if (!r) return;
|
|
@@ -244,17 +284,6 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
|
|
|
244
284
|
useInput(
|
|
245
285
|
(input, key) => {
|
|
246
286
|
if (!active || busy) return;
|
|
247
|
-
// GAME MODE: while the Donkey Kong overlay is up, DkGame owns its own
|
|
248
|
-
// useInput (gated on active && gameMode) and handles movement / jump /
|
|
249
|
-
// pause / quit (q · esc · ⌫). Branch FIRST (synchronous gameRef so a
|
|
250
|
-
// same-tick keypress routes correctly). We only add ↵ here: once the scan
|
|
251
|
-
// has finished, Enter "review results" exits the game (DkGame doesn't bind
|
|
252
|
-
// ↵, so it falls through to us). Everything else is swallowed — the scan
|
|
253
|
-
// keeps streaming in the background regardless.
|
|
254
|
-
if (gameRef.current) {
|
|
255
|
-
if (key.return && !running) return closeGame();
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
287
|
if (search.isSearchingNow()) {
|
|
259
288
|
if (key.escape) { search.clear(); ui?.setCapturing?.(false); return; }
|
|
260
289
|
if (key.return) { search.confirm(); ui?.setCapturing?.(false); return; }
|
|
@@ -294,9 +323,10 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
|
|
|
294
323
|
if (input === "x") return setItems((its) => its.map((it) => ({ ...it, selected: false })));
|
|
295
324
|
if (input === "p") return setProjectIdx((i) => (i + 1) % projOptions.length);
|
|
296
325
|
if (input === "P") {
|
|
297
|
-
// Mid-scan, P launches "Play Donkey Kong while you wait"
|
|
298
|
-
// keeps its original meaning (create a new
|
|
299
|
-
|
|
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; }
|
|
300
330
|
return ui.askPrompt("New project", { onSubmit: createProject });
|
|
301
331
|
}
|
|
302
332
|
if (input === "g") return scan.reload();
|
|
@@ -309,22 +339,6 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
|
|
|
309
339
|
{ isActive: active },
|
|
310
340
|
);
|
|
311
341
|
|
|
312
|
-
// GAME OVERLAY — takes precedence over every scan-state render (including the
|
|
313
|
-
// "scanning…" placeholder), since you most want a distraction while the walk
|
|
314
|
-
// is still running and the list is empty. DkGame renders a FIXED-height block
|
|
315
|
-
// (BOARD_H + 3 == height) so the surrounding window chrome never jumps, reads
|
|
316
|
-
// the live scan fields for its HUD, and never touches the scan itself.
|
|
317
|
-
if (gameMode)
|
|
318
|
-
return h(DonkeyKong, {
|
|
319
|
-
width,
|
|
320
|
-
height,
|
|
321
|
-
scan, // read-only: filesScanned / candidates / dirsSeen / phase
|
|
322
|
-
ui,
|
|
323
|
-
onExit: closeGame,
|
|
324
|
-
active: active && gameMode,
|
|
325
|
-
level: 1,
|
|
326
|
-
});
|
|
327
|
-
|
|
328
342
|
if (scan.phase === "error") return h(StateLine, { kind: "error", text: scan.error });
|
|
329
343
|
if (running && !items.length)
|
|
330
344
|
return h(
|