@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.
- package/package.json +1 -1
- package/src/api.js +6 -0
- package/src/ci.js +2 -2
- package/src/index.js +106 -12
- package/src/integrations.js +121 -0
- package/src/sources/aws-lambda.js +95 -0
- package/src/sources/configscan.js +8 -2
- package/src/sources/filesystem.js +0 -0
- package/src/sources/github-actions.js +156 -0
- package/src/sources/index.js +70 -13
- package/src/sources/scan-runner.js +127 -0
- package/src/sources/supabase-edge.js +183 -0
- package/src/sources/supabase.js +5 -0
- package/src/sources/vercel.js +74 -0
- package/src/tui/app.js +5 -1
- package/src/tui/game/DkGame.js +187 -0
- package/src/tui/game/dk-core.js +413 -0
- package/src/tui/game/dk-render.js +114 -0
- package/src/tui/views/add.js +1 -1
- package/src/tui/views/integrations.js +224 -0
- package/src/tui/views/inventory.js +31 -2
- package/src/tui/views/scan.js +103 -7
|
@@ -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
|
+
}
|
package/src/tui/views/add.js
CHANGED
|
@@ -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", "
|
|
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", "
|
|
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")
|
|
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
|
package/src/tui/views/scan.js
CHANGED
|
@@ -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,
|
|
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")
|
|
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)
|
|
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…`)
|