@modelstatus/cli 0.1.34 → 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.
@@ -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 };
@@ -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