@modelstatus/cli 0.1.34 → 0.1.35

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,114 @@
1
+ /* Donkey Kong — PURE renderer. No state, no React, no ink. Maps a game state
2
+ * (from dk-core.js) into an array of EXACTLY BOARD_H strings, each hard-padded /
3
+ * sliced to BOARD_W (the `cell` discipline), plus a colorize() that turns one
4
+ * row into {char,color} spans so the Ink wrapper can emit colored <Text>.
5
+ *
6
+ * Glyphs come from a GAME_GLYPH map with a full ASCII mirror, selected by the
7
+ * SAME flags ui.js uses (MM_ASCII=1 / TERM=dumb), so the board degrades to clean
8
+ * monochrome ASCII on dumb terminals exactly like the rest of the TUI. */
9
+
10
+ const ASCII = process.env.MM_ASCII === "1" || process.env.TERM === "dumb";
11
+
12
+ const G_UNICODE = {
13
+ jumpman: "☻",
14
+ dk: "@",
15
+ princess: "♥",
16
+ barrel: "O",
17
+ girder: "═",
18
+ ladder: "‖",
19
+ empty: " ",
20
+ };
21
+ const G_ASCII = {
22
+ jumpman: "P",
23
+ dk: "K",
24
+ princess: "V",
25
+ barrel: "o",
26
+ girder: "=",
27
+ ladder: "H",
28
+ empty: " ",
29
+ };
30
+ export const GAME_GLYPH = ASCII ? G_ASCII : G_UNICODE;
31
+
32
+ // Entity color keys (resolved against the C palette by the wrapper; kept here as
33
+ // literal hex so the renderer stays dependency-free + the colorize output is
34
+ // directly usable by tests without importing the design system).
35
+ export const GAME_COLORS = {
36
+ jumpman: "#22d3ee", // C.ACCENT
37
+ barrel: "#ea580c", // retiring-orange
38
+ girder: "#243042", // C.BORDER
39
+ ladder: "#8b98a5", // C.FG_DIM
40
+ dk: "#dc2626", // retired-red
41
+ princess: "#a78bfa", // violet
42
+ empty: "#5b6673", // C.FG_FAINT
43
+ };
44
+
45
+ /** Build a BOARD_H × BOARD_W matrix of cell descriptors {ch, kind}. Drawing
46
+ * order (low → high priority): girders, ladders, princess, DK, barrels, jumpman
47
+ * — so the player + hazards are never hidden by scenery. */
48
+ function buildGrid(state, glyph) {
49
+ const { BOARD_W, BOARD_H, platforms, ladders, jumpman, donkeyKong, princess, barrels } = state;
50
+ const grid = [];
51
+ for (let y = 0; y < BOARD_H; y++) {
52
+ const row = new Array(BOARD_W);
53
+ for (let x = 0; x < BOARD_W; x++) row[x] = { ch: glyph.empty, kind: "empty" };
54
+ grid.push(row);
55
+ }
56
+ const put = (x, y, ch, kind) => {
57
+ if (y < 0 || y >= BOARD_H || x < 0 || x >= BOARD_W) return;
58
+ grid[y][x] = { ch, kind };
59
+ };
60
+
61
+ // girders (per-column slope row)
62
+ for (const p of platforms) {
63
+ for (let x = 0; x < BOARD_W; x++) put(x, p.slopeOffsets[x], glyph.girder, "girder");
64
+ }
65
+ // ladders
66
+ for (const l of ladders) {
67
+ for (let y = l.yTop; y <= l.yBottom; y++) put(l.col, y, glyph.ladder, "ladder");
68
+ }
69
+ // goal + actors
70
+ put(princess.x, princess.y, glyph.princess, "princess");
71
+ put(donkeyKong.x, donkeyKong.y, glyph.dk, "dk");
72
+ for (const b of barrels) put(b.x, b.y, glyph.barrel, "barrel");
73
+ put(jumpman.x, jumpman.y, glyph.jumpman, "jumpman");
74
+ return grid;
75
+ }
76
+
77
+ /**
78
+ * renderGame(state, opts) -> string[] of exactly BOARD_H rows, each BOARD_W wide.
79
+ * Plain monochrome text — the wrapper uses colorize() when it wants color.
80
+ */
81
+ export function renderGame(state, { glyph = GAME_GLYPH } = {}) {
82
+ if (!state || state.tooSmall) return [];
83
+ const grid = buildGrid(state, glyph);
84
+ return grid.map((row) => {
85
+ let s = "";
86
+ for (const cell of row) s += cell.ch;
87
+ return s.slice(0, state.BOARD_W).padEnd(state.BOARD_W);
88
+ });
89
+ }
90
+
91
+ /**
92
+ * colorize(state, opts) -> array (length BOARD_H) of span arrays, where each
93
+ * span is { text, color }. Adjacent same-color cells are merged into one span
94
+ * so the wrapper emits a handful of <Text> per row instead of one-per-cell.
95
+ * `bgOn` is accepted for parity with ui.js (no background wash is used today —
96
+ * the board reads cleanest as foreground glyphs on the app background).
97
+ */
98
+ export function colorize(state, { glyph = GAME_GLYPH, colors = GAME_COLORS } = {}) {
99
+ if (!state || state.tooSmall) return [];
100
+ const grid = buildGrid(state, glyph);
101
+ return grid.map((row) => {
102
+ const spans = [];
103
+ for (const cell of row) {
104
+ const color = colors[cell.kind] || colors.empty;
105
+ const last = spans[spans.length - 1];
106
+ if (last && last.color === color) last.text += cell.ch;
107
+ else spans.push({ text: cell.ch, color });
108
+ }
109
+ // Pad to width (defensive — buildGrid already fills the full row).
110
+ const used = spans.reduce((n, sp) => n + sp.text.length, 0);
111
+ if (used < state.BOARD_W) spans.push({ text: " ".repeat(state.BOARD_W - used), color: colors.empty });
112
+ return spans;
113
+ });
114
+ }
@@ -2,7 +2,7 @@ import React from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
3
  import { h, C, GLYPH, useAsync, clampCursor } from "../ui.js";
4
4
 
5
- const ENV_ORDER = ["prod", "staging", "dev", "other"];
5
+ const ENV_ORDER = ["prod", "staging", "dev", "unknown"];
6
6
 
7
7
  export const meta = {
8
8
  keys: [
@@ -0,0 +1,224 @@
1
+ import React from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import {
4
+ h, C, GLYPH, ListRow, EmptyCard, envTag as envTagSeg,
5
+ cell, cellE, SPINNER, useTick, useAsync, clampCursor,
6
+ } from "../ui.js";
7
+ import { getSource } from "../../sources/index.js";
8
+ import {
9
+ INTEGRATION_IDS, INTEGRATION_META, readIntegrations,
10
+ setEnabled, setEnvTag, getEnvTag,
11
+ } from "../../integrations.js";
12
+
13
+ const ENV_TAGS = ["prod", "staging", "dev", "unknown"];
14
+
15
+ // kind → accent pill color (mirrors the channel KIND_COLOR taste in alerts.js).
16
+ const KIND_COLOR = {
17
+ "aws-lambda": "#ff9900", // AWS orange
18
+ vercel: C.FG_STRONG,
19
+ "supabase-edge": "#3ecf8e", // Supabase green
20
+ "github-actions": "#a78bfa",
21
+ };
22
+
23
+ /** A compact provider pill text (kept ≤ the 14-col column). */
24
+ const PILL = {
25
+ "aws-lambda": "aws",
26
+ vercel: "vercel",
27
+ "supabase-edge": "supabase",
28
+ "github-actions": "github",
29
+ };
30
+
31
+ /**
32
+ * Integrations view: a single fixed list of the 4 LIVE integration sources, with
33
+ * a per-source enabled toggle (the authoritative local switch that drives what
34
+ * `mm scan` runs by default) + a declared env label. Authorization is NOT stored
35
+ * here — it's the user's already-authenticated local CLI (aws/gh/vercel/supabase);
36
+ * `t` runs a one-off read-only identity probe and toasts the result.
37
+ *
38
+ * needsAuth:false in app.js — the toggle is local-first and scanning is free; the
39
+ * web row (best-effort PATCH) is only for cross-device visibility + plan display.
40
+ * No idle timers / setState-on-tick (the spinner ticks ONLY while a probe runs).
41
+ */
42
+ export function IntegrationsView({ client, me, ui, active, width = 78, height = 14 }) {
43
+ const ROWS = Math.max(3, height - 3); // hint line + a little overhead
44
+ const [cursor, setCursor] = React.useState(0);
45
+ // Local toggle state, re-read from integrations.json. `gen` bumps to force a
46
+ // re-read after a write (the file is the source of truth, not React state).
47
+ const [gen, setGen] = React.useState(0);
48
+ const local = React.useMemo(() => readIntegrations(), [gen]);
49
+ // Probing state: which id is mid-probe (drives the spinner) + last results.
50
+ const [probing, setProbing] = React.useState(null);
51
+ const [probed, setProbed] = React.useState({}); // id -> { connected, account?, reason? }
52
+ const tick = useTick(80, probing != null);
53
+
54
+ // Availability is the cheap PATH check (no spawn) per source.
55
+ const avail = useAsync(
56
+ async () => {
57
+ const out = {};
58
+ for (const id of INTEGRATION_IDS) {
59
+ const src = getSource(id);
60
+ out[id] = src ? await src.available({}) : false;
61
+ }
62
+ return out;
63
+ },
64
+ [gen],
65
+ );
66
+ const availMap = avail.data || {};
67
+
68
+ const rows = INTEGRATION_IDS.map((id) => ({
69
+ id,
70
+ meta: INTEGRATION_META[id],
71
+ enabled: !!local[id]?.enabled,
72
+ env: getEnvTag(id) || "unknown",
73
+ hasCmd: !!availMap[id],
74
+ probe: probed[id] || null,
75
+ }));
76
+ const cur = rows[clampCursor(cursor, rows.length)] || null;
77
+ const enabledCount = rows.filter((r) => r.enabled).length;
78
+
79
+ React.useEffect(
80
+ () => ui?.reportStatus?.({ context: `${enabledCount}/${rows.length} on` }),
81
+ [enabledCount, rows.length, ui],
82
+ );
83
+
84
+ function refresh() {
85
+ setProbed({});
86
+ setGen((g) => g + 1);
87
+ avail.reload();
88
+ }
89
+
90
+ function toggle(r) {
91
+ const next = !r.enabled;
92
+ setEnabled(r.id, next); // local write is authoritative + instant
93
+ setGen((g) => g + 1);
94
+ ui.showToast(`${r.id} ${next ? "enabled" : "disabled"}`);
95
+ // Best-effort cloud mirror (cross-device + plan-gating). The local toggle
96
+ // already applied; a 402/anything else just toasts and leaves it local.
97
+ if (me) {
98
+ client
99
+ .setIntegration({ action: next ? "enable" : "disable", kind: r.id, env: r.env })
100
+ .catch((e) => {
101
+ if (e.status === 402) ui.showToast("Live integrations sync is a Pro feature (toggle still applies locally)", "yellow");
102
+ });
103
+ }
104
+ }
105
+
106
+ function setEnv(r) {
107
+ ui.askPrompt(`Env for ${r.id} (${ENV_TAGS.join("/")})`, {
108
+ initial: r.env,
109
+ onSubmit: (val) => {
110
+ const tag = (val || "").trim().toLowerCase();
111
+ if (!ENV_TAGS.includes(tag)) return ui.showToast("env must be prod/staging/dev/unknown", "red");
112
+ try {
113
+ setEnvTag(r.id, tag); // "unknown" clears the override (guessEnvFrom resumes)
114
+ setGen((g) => g + 1);
115
+ ui.showToast(tag === "unknown" ? `${r.id} env override cleared` : `${r.id} env → ${tag}`);
116
+ if (me) client.setIntegration({ action: r.enabled ? "enable" : "disable", kind: r.id, env: tag }).catch(() => {});
117
+ } catch (e) {
118
+ ui.showToast(e.message, "red");
119
+ }
120
+ },
121
+ });
122
+ }
123
+
124
+ async function testAuth(r) {
125
+ if (!r.hasCmd) return ui.showToast(`${r.meta.requiresCmd} not installed`, "red");
126
+ const src = getSource(r.id);
127
+ if (!src || typeof src.authState !== "function") return ui.showToast("no auth probe for this source", "yellow");
128
+ setProbing(r.id);
129
+ try {
130
+ const st = await src.authState({ region: local[r.id]?.ref });
131
+ setProbed((p) => ({ ...p, [r.id]: st }));
132
+ if (st.connected) ui.showToast(`${r.id} authorized${st.account ? ` · ${st.account}` : ""}`, "#16a34a");
133
+ else ui.showToast(`${r.id}: ${st.reason || "not authorized"}`, "red");
134
+ } catch (e) {
135
+ setProbed((p) => ({ ...p, [r.id]: { connected: false, reason: e.message } }));
136
+ ui.showToast(e.message, "red");
137
+ } finally {
138
+ setProbing(null);
139
+ }
140
+ }
141
+
142
+ useInput(
143
+ (input, key) => {
144
+ if (!active) return;
145
+ if (key.downArrow || input === "j") return setCursor((c) => clampCursor(c + 1, rows.length));
146
+ if (key.upArrow || input === "k") return setCursor((c) => clampCursor(c - 1, rows.length));
147
+ if (input === "g") return refresh();
148
+ if (!cur) return;
149
+ if (input === " " || key.return) return toggle(cur);
150
+ if (input === "e") return setEnv(cur);
151
+ if (input === "t") return testAuth(cur);
152
+ },
153
+ { isActive: active },
154
+ );
155
+
156
+ // Column budget: rail(1) + toggle(2) + label + gap(1) + kind(10) + status(rest) + env(8).
157
+ const ENV_W = 8;
158
+ const KIND_W = 10;
159
+ const LABEL_W = 30;
160
+ const restW = Math.max(8, width - 1 - 2 - LABEL_W - 1 - KIND_W - ENV_W);
161
+
162
+ // Status string per row: not-installed › off/available › on › on·authorized / auth failed.
163
+ function statusFor(r) {
164
+ if (!r.hasCmd) return { text: `${r.meta.requiresCmd} not installed`, color: C.FG_FAINT };
165
+ if (r.probe && !r.probe.connected) return { text: "auth failed", color: "#dc2626" };
166
+ if (r.probe && r.probe.connected) {
167
+ const who = r.probe.account ? ` · ${r.probe.account}` : "";
168
+ return { text: `authorized${who}`, color: "#16a34a" };
169
+ }
170
+ if (r.enabled) return { text: "on", color: C.ACCENT };
171
+ return { text: "available · off", color: C.FG_DIM };
172
+ }
173
+
174
+ const curIdx = clampCursor(cursor, rows.length);
175
+ let body;
176
+ if (avail.loading) {
177
+ body = h(Text, {}, h(Text, { color: C.ACCENT }, ` ${SPINNER[tick % SPINNER.length]} `), h(Text, { color: C.FG_DIM }, "checking installed CLIs…"));
178
+ } else if (!rows.length) {
179
+ body = h(EmptyCard, {
180
+ icon: GLYPH.spark,
181
+ title: "Connect your live deployments",
182
+ lines: ["Scan AWS Lambda, Vercel, Supabase Edge and GitHub Actions for the models you actually ship.", "Toggle one on with space — it then scans by default. Authorization is your own CLI; we never store a token."],
183
+ width,
184
+ });
185
+ } else {
186
+ body = h(
187
+ Box,
188
+ { flexDirection: "column" },
189
+ ...rows.slice(0, ROWS).map((r, i) => {
190
+ const isCur = i === curIdx;
191
+ const st = statusFor(r);
192
+ const spin = probing === r.id ? `${SPINNER[tick % SPINNER.length]} ` : "";
193
+ const et = envTagSeg(r.env);
194
+ const cells = [
195
+ { text: `${r.enabled ? GLYPH.check : GLYPH.dot} `, color: r.enabled ? C.ACCENT : C.FG_FAINT },
196
+ { text: cellE(r.meta.label, LABEL_W), color: isCur ? C.FG_STRONG : r.enabled ? C.FG : C.FG_FAINT, bold: isCur },
197
+ { text: " ", color: C.FG },
198
+ { text: cell(PILL[r.id] || "", KIND_W), color: KIND_COLOR[r.id] || C.FG_DIM, bold: true },
199
+ { text: cellE(spin + st.text, restW), color: st.color },
200
+ { text: cell(et.text, ENV_W), color: et.color },
201
+ ];
202
+ return h(ListRow, { key: r.id, active: isCur, cells, width });
203
+ }),
204
+ );
205
+ }
206
+
207
+ const hint = " Toggle which live deployments scan by default · space on/off · t authorize · e env. Secrets stay on your machine.";
208
+ return h(
209
+ Box,
210
+ { flexDirection: "column" },
211
+ h(Text, { color: C.FG_FAINT }, cellE(hint, width)),
212
+ body,
213
+ );
214
+ }
215
+
216
+ export const meta = {
217
+ keys: [
218
+ { k: "↑↓", label: "nav" },
219
+ { k: "space", label: "toggle" },
220
+ { k: "e", label: "env" },
221
+ { k: "t", label: "test" },
222
+ { k: "g", label: "refresh" },
223
+ ],
224
+ };
@@ -11,12 +11,13 @@ import {
11
11
  } from "../ui.js";
12
12
  import { readSnippet, findSourceFile } from "../snippet.js";
13
13
 
14
- const ENV_ORDER = ["prod", "staging", "dev", "other"];
14
+ const ENV_ORDER = ["prod", "staging", "dev", "unknown"];
15
15
 
16
16
  export const meta = {
17
17
  keys: [
18
18
  { k: "↑↓", label: "nav" },
19
19
  { k: "e", label: "env" },
20
+ { k: "t", label: "tag untagged" },
20
21
  { k: "c", label: "critical" },
21
22
  { k: "d", label: "delete" },
22
23
  { k: "C", label: "clear all" },
@@ -26,6 +27,10 @@ export const meta = {
26
27
  ],
27
28
  };
28
29
 
30
+ // A row whose env is genuinely unknown — disk had no signal and nobody tagged it.
31
+ // Legacy null/empty + the old "other" bucket count as untagged too.
32
+ const isUntagged = (u) => !u.environment || u.environment === "unknown" || u.environment === "other";
33
+
29
34
  export function InventoryView({ client, ui, dir = ".", active, width = 78, height = 14 }) {
30
35
  const q = useAsync(async () => {
31
36
  const [u, p] = await Promise.all([client.listUsages(), client.listProjects()]);
@@ -82,6 +87,27 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
82
87
  if (input === "g") return q.reload();
83
88
  if (input === "r") return ui.switchTo("scan");
84
89
  if (input === "n") return ui.switchTo("add");
90
+ if (input === "t") {
91
+ const untagged = usages.filter(isUntagged);
92
+ if (!untagged.length) return ui.showToast("nothing untagged");
93
+ return ui.askPrompt(`Tag ${untagged.length} untagged → type prod / staging / dev`, {
94
+ onSubmit: async (v) => {
95
+ const env = String(v || "").trim().toLowerCase();
96
+ if (!["prod", "staging", "dev"].includes(env)) return ui.showToast("tag cancelled");
97
+ let n = 0;
98
+ for (const u of untagged) {
99
+ try {
100
+ await client.patchUsage(u.id, { environment: env });
101
+ n += 1;
102
+ } catch {
103
+ /* skip the row that failed; report the rest */
104
+ }
105
+ }
106
+ ui.showToast(`${GLYPH.check} tagged ${n} → ${env}`);
107
+ q.reload();
108
+ },
109
+ });
110
+ }
85
111
  if (input === "C" && usages.length) {
86
112
  return ui.askPrompt(`Type "clear" to delete ALL ${usages.length} usages`, {
87
113
  onSubmit: (v) => {
@@ -98,7 +124,10 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
98
124
  });
99
125
  }
100
126
  if (!cur) return;
101
- if (input === "e") return patch(cur, { environment: ENV_ORDER[(ENV_ORDER.indexOf(cur.environment) + 1) % 4] }, "env updated");
127
+ if (input === "e") {
128
+ const i = ENV_ORDER.indexOf(cur.environment);
129
+ return patch(cur, { environment: ENV_ORDER[(i + 1) % ENV_ORDER.length] }, "env updated");
130
+ }
102
131
  if (input === "c") return patch(cur, { is_critical: !cur.is_critical }, "critical toggled");
103
132
  if (input === "d")
104
133
  return client
@@ -3,7 +3,17 @@
3
3
  * retirement display, and lets you select + upload to your inventory. Server
4
4
  * model_ids are resolved only at upload time so uploads stay correct.
5
5
  * Scrollable (j/k/↑↓), filterable (/), with a full-width detail panel; ↵ drills
6
- * into the highlighted model's usage locations and ↵ opens one in your editor. */
6
+ * into the highlighted model's usage locations and ↵ opens one in your editor.
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. */
7
17
  import React from "react";
8
18
  import fs from "node:fs";
9
19
  import path from "node:path";
@@ -20,6 +30,8 @@ import { readSnippet } from "../snippet.js";
20
30
  import { buildUsages, assignProjects } from "../../upload.js";
21
31
  import { track } from "../../telemetry.js";
22
32
  import { openUrl, openLocation } from "../../openUrl.js";
33
+ import { DonkeyKong } from "../game/DkGame.js";
34
+ import { boardSize, MIN_W, MIN_H } from "../game/dk-core.js";
23
35
 
24
36
  export const meta = {
25
37
  keys: [
@@ -30,6 +42,7 @@ export const meta = {
30
42
  { k: "/", label: "search" },
31
43
  { k: "g", label: "rescan" },
32
44
  { k: "u", label: "upload all" },
45
+ { k: "P", label: "play" }, // only meaningful mid-scan; shown when running
33
46
  ],
34
47
  };
35
48
 
@@ -51,6 +64,32 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
51
64
  const tick = useTick(80, running || busy);
52
65
  const search = useSearch();
53
66
 
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);
74
+ const { BOARD_W, BOARD_H } = boardSize(width, height);
75
+ const canPlay = BOARD_W >= MIN_W && BOARD_H >= MIN_H;
76
+ function openGame() {
77
+ 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.
83
+ 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.
91
+ }
92
+
54
93
  // Upload-target projects (cheap; independent of the scan).
55
94
  const projQ = useAsync(() => client.listProjects().then((r) => r.data || []).catch(() => []), []);
56
95
  const projects = projQ.data || [];
@@ -122,13 +161,29 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
122
161
  ui?.reportStatus?.({ counts, context: ctx });
123
162
  }, [items, selCount, project, ui]);
124
163
 
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
+
125
178
  // Tell the shell when backspace should back out *within* this view (drilled
126
- // into refs, or an active filter) rather than stepping to the previous tab.
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.
127
182
  const setHandlesBack = ui?.setHandlesBack;
128
183
  React.useEffect(() => {
129
- setHandlesBack?.(focus === "refs" || !!search.query);
184
+ setHandlesBack?.(gameMode || focus === "refs" || !!search.query);
130
185
  return () => setHandlesBack?.(false);
131
- }, [setHandlesBack, focus, search.query]);
186
+ }, [setHandlesBack, gameMode, focus, search.query]);
132
187
 
133
188
  function openRef(r) {
134
189
  if (!r) return;
@@ -189,6 +244,17 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
189
244
  useInput(
190
245
  (input, key) => {
191
246
  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
+ }
192
258
  if (search.isSearchingNow()) {
193
259
  if (key.escape) { search.clear(); ui?.setCapturing?.(false); return; }
194
260
  if (key.return) { search.confirm(); ui?.setCapturing?.(false); return; }
@@ -227,7 +293,12 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
227
293
  if (input === "a") return setItems((its) => its.map((it) => ({ ...it, selected: true })));
228
294
  if (input === "x") return setItems((its) => its.map((it) => ({ ...it, selected: false })));
229
295
  if (input === "p") return setProjectIdx((i) => (i + 1) % projOptions.length);
230
- if (input === "P") return ui.askPrompt("New project", { onSubmit: createProject });
296
+ 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();
300
+ return ui.askPrompt("New project", { onSubmit: createProject });
301
+ }
231
302
  if (input === "g") return scan.reload();
232
303
  if (input === "u") return upload();
233
304
  if (input === "l") {
@@ -238,8 +309,30 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
238
309
  { isActive: active },
239
310
  );
240
311
 
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
+
241
328
  if (scan.phase === "error") return h(StateLine, { kind: "error", text: scan.error });
242
- if (running && !items.length) return h(StateLine, { kind: "scanning", spin: SPINNER[tick % SPINNER.length], text: `scanning ${dir}…` });
329
+ if (running && !items.length)
330
+ return h(
331
+ Box,
332
+ { flexDirection: "column" },
333
+ h(StateLine, { kind: "scanning", spin: SPINNER[tick % SPINNER.length], text: `scanning ${dir}…` }),
334
+ canPlay ? h(Text, { color: C.FG_FAINT }, ` ${GLYPH.spark} press P to play Donkey Kong while you wait`) : null,
335
+ );
243
336
  if (!items.length)
244
337
  return h(EmptyCard, { icon: GLYPH.spark, title: `No models found in ${path.basename(dir)} — yet`, lines: ["We looked through code, config, and prompt files for model ids.", "Press g to rescan, 1 Here to try another project, or 5 Add to enter one by hand."], width });
245
338
 
@@ -264,10 +357,13 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
264
357
  return h(ListRow, { key: it.key + realIdx, active: isCur, selected: it.selected, cells, width });
265
358
  });
266
359
 
360
+ // While the walk is still running (and the terminal fits a board), nudge the
361
+ // "Play Donkey Kong while you wait" affordance on the showing line.
362
+ const playHint = running && canPlay ? " · P play" : "";
267
363
  const showingLine = h(
268
364
  Text,
269
365
  { color: C.FG_FAINT },
270
- ` ${fmtNum(filtered.length)} ref${filtered.length === 1 ? "" : "s"}${filtered.length > pageSize ? ` · ${nav.start + 1}-${Math.min(nav.end, filtered.length)}` : ""}${search.query ? ` · filter "${search.query}"` : ""}${scan.fromCache ? " · cached" : ""} · ${selCount === items.length ? `all ${selCount}` : `${selCount}/${items.length}`} selected · u uploads them all`,
366
+ ` ${fmtNum(filtered.length)} ref${filtered.length === 1 ? "" : "s"}${filtered.length > pageSize ? ` · ${nav.start + 1}-${Math.min(nav.end, filtered.length)}` : ""}${search.query ? ` · filter "${search.query}"` : ""}${scan.fromCache ? " · cached" : ""} · ${selCount === items.length ? `all ${selCount}` : `${selCount}/${items.length}`} selected · u uploads them all${playHint}`,
271
367
  );
272
368
  const footer = busy
273
369
  ? h(Text, { color: C.ACCENT }, ` ${SPINNER[tick % SPINNER.length]} uploading…`)