@modelstatus/cli 0.1.35 → 0.1.37

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.
@@ -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 };
@@ -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 via
9
- * useStreamingScan (scan-stream.js) → scanFilesystemStreaming, which yields the
10
- * event loop on a time+count budget (filesystem.js) so the Ink renderer + input
11
- * loop stay responsive. Because the scan lives entirely in that async loop and
12
- * is independent of which keys this view consumes, a "Play Donkey Kong while you
13
- * wait" overlay (DkGame) can mount, capture input, and run its own useTick frame
14
- * loop CONCURRENTLY candidates keep streaming into scan.candidates in the
15
- * background, and the game reads (never mutates) the live scan fields for its
16
- * HUD. Mid-scan only: P opens the game; q/esc/⌫/↵ closes it. */
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" overlay. gameRef is the synchronous mirror
68
- // (same stale-closure-safe pattern as refIdxRef / useSearch) so a key in the
69
- // same tick that opens the game is routed correctly. The board fits only if
70
- // the terminal is large enough (boardSize ≥ MIN_W×MIN_H), so the affordance is
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
- function openGame() {
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
- gameRef.current = true;
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
- function closeGame() {
86
- gameRef.current = false;
87
- setGameMode(false);
88
- ui?.setCapturing?.(false);
89
- // setHandlesBack is re-asserted by the effect below (keyed on gameMode/focus/
90
- // search.query) once gameMode flips back to false.
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, an active filter, or the game overlay is up) rather than stepping
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?.(gameMode || focus === "refs" || !!search.query);
224
+ setHandlesBack?.(focus === "refs" || !!search.query);
185
225
  return () => setHandlesBack?.(false);
186
- }, [setHandlesBack, gameMode, focus, search.query]);
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"; otherwise it
298
- // keeps its original meaning (create a new upload-target project).
299
- if (running) return openGame();
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(