@modelstatus/cli 0.1.76 → 0.1.77

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelstatus/cli",
3
- "version": "0.1.76",
3
+ "version": "0.1.77",
4
4
  "description": "Track which AI models you use, where, and never get surprised by a retirement. Free offline model-health for any repo (mm status), browser sign-in for cloud inventory + alerts.",
5
5
  "keywords": [
6
6
  "llm",
package/src/tui/app.js CHANGED
@@ -4,6 +4,7 @@ import { render, Box, Text, useInput, useApp, useStdout } from "ink";
4
4
  import { createClient } from "../api.js";
5
5
  import { track } from "../telemetry.js";
6
6
  import { BUILD_VERSION } from "../version.js";
7
+ import { feedbackEligible, recordFeedbackShown, recordFeedbackSubmitted, submitFeedback, FeedbackStrip, FeedbackCard, ACTION_THRESHOLD } from "./feedback.js";
7
8
  import {
8
9
  h, C, GLYPH, useAsync, Window, TrafficLights, TabStrip, StatusBar, legendSegments,
9
10
  KeyBar, EmptyCard,
@@ -146,6 +147,12 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
146
147
  // changes (reset below). Keeps the keybar honest for tabbed views (What's New,
147
148
  // Alerts) where the active keys depend on which sub-tab you're on.
148
149
  const [dynKeys, setDynKeys] = React.useState(null);
150
+ // Feedback: null | {stage:"strip"} | {stage:"card", score, comment, sending}.
151
+ // The strip auto-shows once ACTION_THRESHOLD real actions happen (30-day
152
+ // cooldown, never after a submit); `!` opens the card from anywhere, always.
153
+ const [feedback, setFeedback] = React.useState(null);
154
+ const actionsRef = React.useRef(0);
155
+ const fbEligibleRef = React.useRef(feedbackEligible());
149
156
  const me = useAsync(async () => (apiKey ? client.me() : null), [apiKey]);
150
157
 
151
158
  const { outer, termRows } = useTermDims();
@@ -187,6 +194,21 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
187
194
  useInput(
188
195
  (input, key) => {
189
196
  if (key.ctrl && input === "c") return exit();
197
+ if (input === "!") {
198
+ setFeedback({ stage: "card", score: null, comment: "", sending: false });
199
+ return;
200
+ }
201
+ // Count "real" actions (letter/symbol keys that do work — not tab digits,
202
+ // not bare navigation, not q). Arrows/enter arrive with empty input, so
203
+ // pure browsing never trips the ask.
204
+ if (fbEligibleRef.current && !feedback && input && !key.ctrl && !key.meta && !/^[0-9]$/.test(input) && input !== "q") {
205
+ actionsRef.current += 1;
206
+ if (actionsRef.current >= ACTION_THRESHOLD) {
207
+ fbEligibleRef.current = false;
208
+ recordFeedbackShown();
209
+ setFeedback({ stage: "strip" });
210
+ }
211
+ }
190
212
  // Ctrl-L: hard redraw. Clears screen + scrollback, then bumps `redraw` so
191
213
  // the frame string changes (ink skips writing identical output) and ink
192
214
  // repaints from a clean top. Fixes a display knocked askew by a resize /
@@ -219,7 +241,37 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
219
241
  }
220
242
  if (input === "q") return exit();
221
243
  },
222
- { isActive: !prompt && !capturing },
244
+ { isActive: !prompt && !capturing && feedback?.stage !== "card" },
245
+ );
246
+ // Feedback card is modal: digits score it (until a comment starts), printable
247
+ // chars build the comment, ←→ nudges the score, ↵ sends, esc closes.
248
+ useInput(
249
+ (input, key) => {
250
+ const fb = feedback;
251
+ if (!fb || fb.sending) return;
252
+ if (key.escape) return setFeedback(null);
253
+ if (key.return) {
254
+ if (!fb.score) return showToast("pick a score first (1-5)", "#d97706");
255
+ setFeedback({ ...fb, sending: true });
256
+ submitFeedback({ apiBase, score: fb.score, comment: fb.comment }).then((ok) => {
257
+ if (ok) {
258
+ recordFeedbackSubmitted();
259
+ setFeedback(null);
260
+ showToast("🙏 thanks — feedback sent to the humans");
261
+ } else {
262
+ setFeedback((f) => (f ? { ...f, sending: false } : f));
263
+ showToast("couldn't send feedback — check your connection, try again", "#dc2626");
264
+ }
265
+ });
266
+ return;
267
+ }
268
+ if (key.leftArrow) return setFeedback({ ...fb, score: Math.max(1, (fb.score || 4) - 1) });
269
+ if (key.rightArrow) return setFeedback({ ...fb, score: Math.min(5, (fb.score || 2) + 1) });
270
+ if (key.backspace || key.delete) return setFeedback({ ...fb, comment: fb.comment.slice(0, -1) });
271
+ if (/^[1-5]$/.test(input) && !fb.comment) return setFeedback({ ...fb, score: Number(input) });
272
+ if (input && !key.ctrl && !key.meta) return setFeedback({ ...fb, comment: (fb.comment + input).slice(0, 500) });
273
+ },
274
+ { isActive: feedback?.stage === "card" },
223
275
  );
224
276
  useInput(
225
277
  (input, key) => {
@@ -257,9 +309,13 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
257
309
  });
258
310
  keys = GATE_KEYS;
259
311
  } else {
260
- body = h(View, { client, me: account, refreshMe: me.reload, dir, apiBase, ui, width: W, height: bodyRows, active: !prompt, fresh });
312
+ body = h(View, { client, me: account, refreshMe: me.reload, dir, apiBase, ui, width: W, height: bodyRows, active: !prompt && feedback?.stage !== "card", fresh });
261
313
  keys = dynKeys || (current2.meta && current2.meta.keys) || [];
262
314
  }
315
+ if (feedback?.stage === "card") {
316
+ body = h(FeedbackCard, { score: feedback.score, comment: feedback.comment, sending: feedback.sending, width: W });
317
+ keys = [{ k: "1-5", label: "score" }, { k: "↵", label: "send" }, { k: "esc", label: "close" }];
318
+ }
263
319
 
264
320
  // Status bar segments.
265
321
  const live = status && status.forKey === current2.key ? status : null;
@@ -282,7 +338,7 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
282
338
  h(Text, {}, ""),
283
339
  h(Box, { flexDirection: "column", width: W, minHeight: bodyRows }, body),
284
340
  h(PromptRow, { prompt }),
285
- h(Text, {}, ""),
341
+ feedback?.stage === "strip" ? h(FeedbackStrip, null) : h(Text, {}, ""),
286
342
  toast ? h(Text, { color: toast.color }, ` ${toast.msg}`) : h(Text, {}, ""),
287
343
  h(StatusBar, { segsLeft, segsRight, width: W }),
288
344
  h(KeyBar, { keys: keys.some((k) => k.k === "P") ? keys : [...keys, { k: "P", label: "play 🦍" }], width: W }),
@@ -0,0 +1,121 @@
1
+ /* In-TUI feedback: a quiet "♥ enjoying mm? press ! to rate it" strip appears
2
+ * once the user has taken a handful of real actions, and `!` (always available
3
+ * on the Account tab too) opens a modal card — score 1–5 (1 = hot garbage,
4
+ * 5 = best thing since sliced bread) + optional comment. Submission goes to
5
+ * POST /api/v1/feedback on our API (works signed-out); the SERVER forwards to
6
+ * PostHog + the ops Slack channel — the public binary never holds a secret.
7
+ *
8
+ * Nag policy (user-chosen): auto-show at most once per 30 days; submitting
9
+ * means never auto-show again. The `!` key keeps working regardless.
10
+ */
11
+ import React from "react";
12
+ import { Box, Text } from "ink";
13
+ import { h, C } from "./ui.js";
14
+ import { loadConfig, setConfigValue } from "../config.js";
15
+ import { BUILD_VERSION } from "../version.js";
16
+
17
+ const COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
18
+ export const ACTION_THRESHOLD = 5; // strip appears after this many real actions
19
+
20
+ /** Should the auto-strip show this session? (The `!` key is always allowed.) */
21
+ export function feedbackEligible(cfg = loadConfig(), now = Date.now()) {
22
+ const f = cfg.feedback || {};
23
+ if (f.submittedAt) return false; // they already told us — never auto-nag again
24
+ if (f.shownAt && now - f.shownAt < COOLDOWN_MS) return false;
25
+ return true;
26
+ }
27
+
28
+ /** Record that the strip was shown (ignoring it counts as dismissal). */
29
+ export function recordFeedbackShown(now = Date.now()) {
30
+ try {
31
+ setConfigValue("feedback", { ...(loadConfig().feedback || {}), shownAt: now });
32
+ } catch { /* best effort */ }
33
+ }
34
+
35
+ export function recordFeedbackSubmitted(now = Date.now()) {
36
+ try {
37
+ setConfigValue("feedback", { ...(loadConfig().feedback || {}), submittedAt: now });
38
+ } catch { /* best effort */ }
39
+ }
40
+
41
+ /** POST to our API. Returns true on success; never throws. */
42
+ export async function submitFeedback({ apiBase, score, comment }) {
43
+ try {
44
+ const cfg = loadConfig();
45
+ const res = await fetch(`${(apiBase || "https://llmstatus.ai").replace(/\/$/, "")}/api/v1/feedback`, {
46
+ method: "POST",
47
+ headers: { "content-type": "application/json" },
48
+ body: JSON.stringify({
49
+ score,
50
+ thumbs: score >= 4 ? "up" : score <= 2 ? "down" : undefined,
51
+ comment: comment || undefined,
52
+ version: typeof BUILD_VERSION === "string" ? BUILD_VERSION : "",
53
+ platform: `${process.platform}-${process.arch}`,
54
+ distinct_id: cfg.anonId || undefined,
55
+ }),
56
+ signal: AbortSignal.timeout(5_000),
57
+ });
58
+ return res.ok;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ const SCORE_LABELS = {
65
+ 1: "hot garbage",
66
+ 2: "rough",
67
+ 3: "it's fine",
68
+ 4: "really good",
69
+ 5: "best thing since sliced bread",
70
+ };
71
+
72
+ /** The one-line invite, rendered by app.js above the keybar. */
73
+ export function FeedbackStrip() {
74
+ return h(
75
+ Text,
76
+ {},
77
+ h(Text, { color: "#fb7185" }, " ♥ "),
78
+ h(Text, { color: C.FG }, "enjoying mm? "),
79
+ h(Text, { color: C.ACCENT, bold: true }, "press ! to rate it"),
80
+ );
81
+ }
82
+
83
+ /** The modal card. Pure render — app.js owns the state + key handling. */
84
+ export function FeedbackCard({ score, comment, sending, width = 78 }) {
85
+ const cells = [];
86
+ for (let n = 1; n <= 5; n++) {
87
+ const active = n === score;
88
+ cells.push(
89
+ h(
90
+ Text,
91
+ { key: `s${n}` },
92
+ h(Text, { color: active ? C.ACCENT : C.FG_DIM, bold: active }, active ? ` ▸${n}◂ ` : ` ${n} `),
93
+ ),
94
+ );
95
+ }
96
+ const label = score ? SCORE_LABELS[score] : "pick a number";
97
+ return h(
98
+ Box,
99
+ { flexDirection: "column", paddingX: 1 },
100
+ h(Text, {}, h(Text, { color: C.ACCENT, bold: true }, " ✦ feedback "), h(Text, { color: C.FG_DIM }, "— 30 seconds, straight to the humans")),
101
+ h(Text, {}, ""),
102
+ h(Text, { color: C.FG }, " How do you like mm? (1 = hot garbage · 5 = best thing since sliced bread)"),
103
+ h(Text, {}, ""),
104
+ h(Box, {}, h(Text, {}, " "), ...cells, h(Text, { color: C.FG_DIM }, ` ${label}`)),
105
+ h(Text, {}, ""),
106
+ h(Text, { color: C.FG }, " anything we should know? (optional)"),
107
+ h(
108
+ Text,
109
+ {},
110
+ h(Text, { color: C.FG_DIM }, " │ "),
111
+ h(Text, { color: C.FG }, comment || ""),
112
+ h(Text, { color: C.ACCENT }, "▌"),
113
+ ),
114
+ h(Text, {}, ""),
115
+ h(
116
+ Text,
117
+ { color: C.FG_DIM },
118
+ sending ? " sending…" : " ←→ or 1-5 score · type to comment · ↵ send · esc close",
119
+ ),
120
+ );
121
+ }
@@ -8,7 +8,7 @@ const FREE_LIMITS = { projects: 1, usages: 15, channels: 1 };
8
8
  // Background paint only when colors are on (mirrors ui.js BG_ON gate; ui handles the rest).
9
9
  const BG_ON = process.env.MM_ASCII !== "1" && process.env.TERM !== "dumb" && process.env.NO_COLOR == null;
10
10
 
11
- export const meta = { keys: [{ k: "u", label: "upgrade" }, { k: "g", label: "refresh" }] };
11
+ export const meta = { keys: [{ k: "u", label: "upgrade" }, { k: "g", label: "refresh" }, { k: "!", label: "feedback" }] };
12
12
 
13
13
  /** label/value row: padded accent label + value. */
14
14
  function Row({ label, value, color = C.FG, bold = false }) {