@modelstatus/cli 0.1.1 → 0.1.26

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,142 @@
1
+ /* TUI sign-in screen — shown by the Bootstrap wrapper (app.js) when the user
2
+ * has no saved API key. Replaces the old text-only "Waiting for authorization…"
3
+ * polling loop with an actual interactive TUI: verification code is BIG, the
4
+ * URL is visible, a spinner animates, and the user can quit or skip without
5
+ * waiting for the browser auth. */
6
+ import React from "react";
7
+ import { Box, Text, useApp, useInput } from "ink";
8
+ import { createClient } from "../api.js";
9
+ import { loadConfig, saveConfig } from "../config.js";
10
+ import { openUrl } from "../openUrl.js";
11
+ import { h } from "./ui.js";
12
+
13
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
14
+
15
+ export function SignIn({ apiBase, onSuccess, onSkip }) {
16
+ const { exit } = useApp();
17
+ const [phase, setPhase] = React.useState("starting"); // starting | polling | error
18
+ const [code, setCode] = React.useState(null);
19
+ const [url, setUrl] = React.useState(null);
20
+ const [error, setError] = React.useState(null);
21
+ const [tick, setTick] = React.useState(0);
22
+
23
+ // Spinner animation (80ms tick).
24
+ React.useEffect(() => {
25
+ const t = setInterval(() => setTick((i) => (i + 1) % SPINNER.length), 80);
26
+ return () => clearInterval(t);
27
+ }, []);
28
+
29
+ // Device-auth flow: start → poll until approved/denied/expired.
30
+ React.useEffect(() => {
31
+ let cancelled = false;
32
+ let pollTimer = null;
33
+ const client = createClient({ apiBase });
34
+
35
+ const start = async () => {
36
+ try {
37
+ const s = await client.authStart({ client_name: "mm CLI" });
38
+ if (cancelled) return;
39
+ setCode(s.user_code);
40
+ setUrl(s.verification_url);
41
+ setPhase("polling");
42
+ openUrl(s.verification_url); // best-effort, no error if it fails
43
+
44
+ const interval = Math.max(1, s.interval || 3) * 1000;
45
+ const deadline = Date.now() + (s.expires_in || 600) * 1000;
46
+
47
+ const poll = async () => {
48
+ if (cancelled) return;
49
+ if (Date.now() > deadline) {
50
+ setError("The login request expired. Press q and run mm login again.");
51
+ setPhase("error");
52
+ return;
53
+ }
54
+ let res = null;
55
+ try {
56
+ res = await client.authPoll(s.device_code);
57
+ } catch {
58
+ /* transient network — keep polling */
59
+ }
60
+ if (cancelled) return;
61
+ if (res?.status === "approved") {
62
+ const cfg = loadConfig();
63
+ cfg.apiKey = res.api_key;
64
+ cfg.apiBase = apiBase;
65
+ saveConfig(cfg);
66
+ onSuccess(res.api_key);
67
+ return;
68
+ }
69
+ if (res?.status === "denied") {
70
+ setError("Authorization was denied. Press q to quit.");
71
+ setPhase("error");
72
+ return;
73
+ }
74
+ if (res?.status === "expired") {
75
+ setError("The login request expired. Press q and run mm login again.");
76
+ setPhase("error");
77
+ return;
78
+ }
79
+ pollTimer = setTimeout(poll, interval);
80
+ };
81
+ poll();
82
+ } catch (e) {
83
+ if (cancelled) return;
84
+ setError(e?.message || String(e));
85
+ setPhase("error");
86
+ }
87
+ };
88
+ start();
89
+ return () => {
90
+ cancelled = true;
91
+ if (pollTimer) clearTimeout(pollTimer);
92
+ };
93
+ }, [apiBase, onSuccess]);
94
+
95
+ useInput((input, key) => {
96
+ if (key.ctrl && input === "c") return exit();
97
+ if (input === "q") return exit();
98
+ if (input === "s" && onSkip) return onSkip();
99
+ if (input === "o" && url) openUrl(url); // re-open the browser if it didn't fire
100
+ });
101
+
102
+ if (phase === "starting") {
103
+ return h(
104
+ Box,
105
+ { padding: 1 },
106
+ h(Text, { color: "cyan" }, `${SPINNER[tick]} starting sign-in…`),
107
+ );
108
+ }
109
+ if (phase === "error") {
110
+ return h(
111
+ Box,
112
+ { flexDirection: "column", padding: 1 },
113
+ h(Text, { color: "red", bold: true }, " ✕ Sign-in failed"),
114
+ h(Text, { color: "gray" }, ` ${error}`),
115
+ h(Text, { color: "gray", dimColor: true }, ""),
116
+ h(Text, { color: "gray", dimColor: true }, " press q to quit"),
117
+ );
118
+ }
119
+ // polling
120
+ return h(
121
+ Box,
122
+ { flexDirection: "column", padding: 1 },
123
+ h(Text, { bold: true, color: "cyan" }, " LLM Status — Sign in"),
124
+ h(Text, {}, ""),
125
+ h(Text, { color: "gray" }, " Open this URL in your browser:"),
126
+ h(Text, { color: "white" }, ` ${url}`),
127
+ h(Text, {}, ""),
128
+ h(Text, { color: "gray" }, " Or enter this code there:"),
129
+ h(
130
+ Box,
131
+ { paddingLeft: 4, marginTop: 0, marginBottom: 1 },
132
+ h(Text, { bold: true, color: "cyan", backgroundColor: "blackBright" }, ` ${code} `),
133
+ ),
134
+ h(Text, { color: "yellow" }, ` ${SPINNER[tick]} Waiting for authorization…`),
135
+ h(Text, {}, ""),
136
+ h(
137
+ Text,
138
+ { color: "gray", dimColor: true },
139
+ " press o to re-open the browser · q to quit",
140
+ ),
141
+ );
142
+ }
@@ -0,0 +1,127 @@
1
+ /* Lightweight, dependency-free syntax highlighter for the detail-panel code
2
+ * preview. Per-extension keyword sets; tokenizes strings / comments / keywords /
3
+ * numbers and tags the matched model string. Returns colored segment arrays the
4
+ * ink renderer (CodeSnippet in ui.js) can paint — good enough for a few-line
5
+ * preview without pulling in highlight.js (which bloats the bun-compiled binary). */
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { C } from "./ui.js";
9
+
10
+ const KW = (s) => new Set(s.split(/\s+/));
11
+ const JS = {
12
+ line: "//", block: ["/*", "*/"],
13
+ kw: KW("const let var function return if else for while do switch case break continue new class extends implements import export from default async await yield typeof instanceof in of try catch finally throw this super null undefined true false void delete static get set public private readonly type interface enum namespace as satisfies"),
14
+ };
15
+ const PY = { line: "#", block: null, kw: KW("def class return if elif else for while import from as with try except finally raise lambda None True False and or not in is global nonlocal yield await async pass break continue assert del print self") };
16
+ const GO = { line: "//", block: ["/*", "*/"], kw: KW("func return if else for range var const type struct interface map chan go defer package import nil true false switch case break continue default select make new") };
17
+ const CSTYLE = { line: "//", block: ["/*", "*/"], kw: KW("public private protected class struct interface enum return if else for while do switch case break continue new void int string bool true false null var let const fn impl pub use mod match async await namespace using") };
18
+ const GENERIC = { line: "#", block: null, kw: KW("true false null") };
19
+
20
+ function langFor(ext) {
21
+ ext = (ext || "").toLowerCase();
22
+ if ([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"].includes(ext)) return JS;
23
+ if (ext === ".py" || ext === ".ipynb") return PY;
24
+ if (ext === ".go") return GO;
25
+ if ([".java", ".rs", ".php", ".cs"].includes(ext)) return CSTYLE;
26
+ if ([".rb"].includes(ext)) return { ...PY, kw: KW("def class module return if elsif else end for while do begin rescue ensure raise yield nil true false and or not require") };
27
+ return GENERIC;
28
+ }
29
+
30
+ const TOK_COLOR = { string: "#a3e635", comment: C.FG_FAINT, keyword: "#c084fc", number: "#fbbf24", ident: C.FG, plain: C.FG };
31
+
32
+ function tokenize(line, lang) {
33
+ const out = [];
34
+ let i = 0;
35
+ while (i < line.length) {
36
+ const rest = line.slice(i);
37
+ if (lang.line && rest.startsWith(lang.line)) { out.push([rest, "comment"]); break; }
38
+ if (lang.block && rest.startsWith(lang.block[0])) {
39
+ const end = rest.indexOf(lang.block[1], lang.block[0].length);
40
+ const t = end >= 0 ? rest.slice(0, end + lang.block[1].length) : rest;
41
+ out.push([t, "comment"]); i += t.length; continue;
42
+ }
43
+ const ch = line[i];
44
+ if (ch === '"' || ch === "'" || ch === "`") {
45
+ let j = i + 1;
46
+ while (j < line.length) { if (line[j] === "\\") { j += 2; continue; } if (line[j] === ch) { j++; break; } j++; }
47
+ out.push([line.slice(i, j), "string"]); i = j; continue;
48
+ }
49
+ const w = rest.match(/^[A-Za-z_$][\w$]*/);
50
+ if (w) { out.push([w[0], lang.kw.has(w[0]) ? "keyword" : "ident"]); i += w[0].length; continue; }
51
+ const num = rest.match(/^\d[\d_.xXa-fA-F]*/);
52
+ if (num) { out.push([num[0], "number"]); i += num[0].length; continue; }
53
+ out.push([ch, "plain"]); i += 1;
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /** line → [{ text, color, bold?, bg? }]; if matchStr is set, its first occurrence
59
+ * on the FULL line is accent-highlighted. The match is located over the whole
60
+ * line (not per-token) so UNQUOTED hyphenated/dotted ids — gpt-4o, claude-opus-4-1,
61
+ * `model: gpt-4o` in yaml/env/k8s — highlight too, not just quoted strings. */
62
+ function highlightLine(line, ext, matchStr) {
63
+ const lang = langFor(ext);
64
+ const segs = [];
65
+ for (const [text, type] of tokenize(line, lang)) segs.push({ text, color: TOK_COLOR[type] || C.FG });
66
+ if (!matchStr) return segs;
67
+ const at = line.toLowerCase().indexOf(matchStr.toLowerCase());
68
+ if (at < 0) return segs;
69
+ const mEnd = at + matchStr.length;
70
+ // Overlay the accent range [at, mEnd) onto the token segs, splitting any seg
71
+ // that straddles a boundary. Offsets use .length (UTF-16) consistently with
72
+ // indexOf, so they line up; display-width clipping happens later in ui.js.
73
+ const out = [];
74
+ let col = 0;
75
+ for (const s of segs) {
76
+ const sStart = col, sEnd = col + s.text.length;
77
+ col = sEnd;
78
+ if (sEnd <= at || sStart >= mEnd) { out.push(s); continue; }
79
+ const a = Math.max(at, sStart) - sStart;
80
+ const b = Math.min(mEnd, sEnd) - sStart;
81
+ if (a > 0) out.push({ text: s.text.slice(0, a), color: s.color });
82
+ out.push({ text: s.text.slice(a, b), color: C.ACCENT_INK, bg: C.ACCENT, bold: true });
83
+ if (b < s.text.length) out.push({ text: s.text.slice(b), color: s.color });
84
+ }
85
+ return out;
86
+ }
87
+
88
+ /**
89
+ * Read ±ctx lines around `line` of `absPath` and syntax-highlight them, tagging
90
+ * the matched model string on the match line. Returns { path, line, rows } where
91
+ * each row is { num, isMatch, segs }; null if the file can't be read.
92
+ */
93
+ export function readSnippet(absPath, line, matchStr, ctx = 3) {
94
+ let content;
95
+ try {
96
+ const st = fs.statSync(absPath); // size-gate BEFORE reading so a huge file is never slurped
97
+ if (!st.isFile() || st.size > 2_000_000) return null;
98
+ content = fs.readFileSync(absPath, "utf8");
99
+ } catch {
100
+ return null;
101
+ }
102
+ const lines = content.split(/\r?\n/);
103
+ let idx = Math.max(0, Math.min((line || 1) - 1, lines.length - 1));
104
+ // Re-anchor: a cross-run cached line can be stale (file edited since the scan).
105
+ // If the recorded line no longer contains the model string, search nearby for
106
+ // it so the caret + highlight mark the right code instead of confidently lying.
107
+ if (matchStr) {
108
+ const m = matchStr.toLowerCase();
109
+ if (!(lines[idx] || "").toLowerCase().includes(m)) {
110
+ let best = -1;
111
+ for (let d = 1; d <= 40 && best < 0; d++) {
112
+ if (idx - d >= 0 && (lines[idx - d] || "").toLowerCase().includes(m)) best = idx - d;
113
+ else if (idx + d < lines.length && (lines[idx + d] || "").toLowerCase().includes(m)) best = idx + d;
114
+ }
115
+ if (best >= 0) idx = best;
116
+ }
117
+ }
118
+ const start = Math.max(0, idx - ctx);
119
+ const end = Math.min(lines.length, idx + ctx + 1);
120
+ const ext = path.extname(absPath);
121
+ const rows = [];
122
+ for (let i = start; i < end; i++) {
123
+ const text = (lines[i] || "").replace(/\t/g, " ");
124
+ rows.push({ num: i + 1, isMatch: i === idx, segs: highlightLine(text, ext, i === idx ? matchStr : null) });
125
+ }
126
+ return { path: absPath, line: idx + 1, rows };
127
+ }