@modelstatus/cli 0.1.1 → 0.1.25

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/src/tui/ui.js CHANGED
@@ -1,28 +1,112 @@
1
1
  import React from "react";
2
- import { Text } from "ink";
2
+ import { Box, Text, Spacer } from "ink";
3
3
 
4
4
  export const h = React.createElement;
5
5
 
6
+ /* ===========================================================================
7
+ * LLM Status TUI design system. Single source of truth for tokens + the shared
8
+ * components every view composes from. See the redesign spec: a marketing-grade
9
+ * windowed console. Two ink facts this file is built on (verified in ink@5.2.1):
10
+ * - A <Box> backgroundColor paints NOTHING. Background is honored ONLY on
11
+ * <Text>. So a full-width highlighted row threads the same backgroundColor
12
+ * into EVERY child <Text> (gutters included) and pads to the exact width.
13
+ * - Per-edge border flags (borderTop:false / borderBottom:false) work, giving
14
+ * clean left/right rails — the window-chrome technique.
15
+ * ======================================================================== */
16
+
17
+ // ASCII / no-background fallback for dumb terminals (or MM_ASCII=1). Read once.
18
+ const ASCII = process.env.MM_ASCII === "1" || process.env.TERM === "dumb";
19
+ const BG_ON = !ASCII && process.env.NO_COLOR == null;
20
+
21
+ // ---- Palette --------------------------------------------------------------
22
+ export const C = {
23
+ BG: "#0b0f14",
24
+ BG_ELEV: "#11161d",
25
+ BG_ROW: "#161c25",
26
+ BG_ROW_SEL: "#13202a",
27
+ BORDER: "#243042",
28
+ BORDER_ACCENT: "#22d3ee",
29
+ FG: "#e6edf3",
30
+ FG_DIM: "#8b98a5",
31
+ FG_FAINT: "#5b6673",
32
+ FG_STRONG: "#f9fafb",
33
+ ACCENT: "#22d3ee",
34
+ ACCENT_INK: "#06181d",
35
+ ACCENT_DK: "#0e7490",
36
+ BLUE: "#60a5fa",
37
+ };
38
+
39
+ export const LIGHT = { red: "#ff5f56", yellow: "#ffbd2e", green: "#27c93f" };
40
+
41
+ // ---- Glyphs (with ASCII fallback) -----------------------------------------
42
+ const G_UNICODE = {
43
+ ok: "●", deprecating: "◐", retiring: "▲", retired: "×", custom: "○",
44
+ selOn: "●", selOff: "○", rail: "▎", spark: "✦", warn: "⚠", repl: "→",
45
+ crit: "⚑", check: "✓", cross: "×", dot: "·", bullet: "•", caret: "›", pause: "⏸",
46
+ };
47
+ const G_ASCII = {
48
+ ok: "o", deprecating: "%", retiring: "^", retired: "x", custom: ".",
49
+ selOn: "*", selOff: ".", rail: "|", spark: "*", warn: "!", repl: "->",
50
+ crit: "!", check: "OK", cross: "x", dot: ".", bullet: "*", caret: ">", pause: "||",
51
+ };
52
+ export const GLYPH = ASCII ? G_ASCII : G_UNICODE;
53
+
54
+ // ---- Health spine ---------------------------------------------------------
6
55
  export const HEALTH_COLOR = {
7
- ok: "green",
8
- deprecating: "yellow",
9
- retiring: "magenta",
10
- retired: "red",
11
- custom: "gray",
56
+ ok: "#16a34a",
57
+ deprecating: "#d97706",
58
+ retiring: "#ea580c",
59
+ retired: "#dc2626",
60
+ custom: "#9ca3af",
12
61
  };
13
62
  export const HEALTH_GLYPH = {
14
- ok: "●",
15
- deprecating: "●",
16
- retiring: "▲",
17
- retired: "■",
18
- custom: "○",
63
+ ok: GLYPH.ok,
64
+ deprecating: GLYPH.deprecating,
65
+ retiring: GLYPH.retiring,
66
+ retired: GLYPH.retired,
67
+ custom: GLYPH.custom,
19
68
  };
20
-
21
- export const healthColor = (hh) => HEALTH_COLOR[hh] || "white";
69
+ export const healthColor = (hh) => HEALTH_COLOR[hh] || C.FG;
22
70
  export const healthGlyph = (hh) => HEALTH_GLYPH[hh] || "·";
23
71
 
24
- /** Truncate + pad to a fixed width for column alignment. */
72
+ // ---- Spinner / ticks ------------------------------------------------------
73
+ export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
74
+
75
+ export function useInterval(fn, ms) {
76
+ const ref = React.useRef(fn);
77
+ ref.current = fn;
78
+ React.useEffect(() => {
79
+ if (ms == null) return undefined;
80
+ const t = setInterval(() => ref.current(), ms);
81
+ return () => clearInterval(t);
82
+ }, [ms]);
83
+ }
84
+
85
+ export function useTick(ms = 80, running = true) {
86
+ const [tick, setTick] = React.useState(0);
87
+ useInterval(() => setTick((t) => t + 1), running ? ms : null);
88
+ return tick;
89
+ }
90
+
91
+ // ---- String helpers -------------------------------------------------------
92
+ export const fmtNum = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
93
+
94
+ /** Truncate+pad to a fixed width (hard slice, no ellipsis). */
25
95
  export const cell = (s, w) => String(s ?? "").slice(0, w).padEnd(w);
96
+ /** Right-ellipsis: keep the head, drop the tail (slugs, names). */
97
+ export const cellE = (s, w) => {
98
+ s = String(s ?? "");
99
+ return (s.length > w ? s.slice(0, w - 1) + "…" : s).padEnd(w);
100
+ };
101
+ /** Left-ellipsis: keep the tail (file:line — the :line stays visible). */
102
+ export const cellL = (s, w) => {
103
+ s = String(s ?? "");
104
+ return (s.length > w ? "…" + s.slice(-(w - 1)) : s).padEnd(w);
105
+ };
106
+ /** Right-align pad. */
107
+ export const rpad = (s, w) => String(s ?? "").padStart(w);
108
+
109
+ export const clampCursor = (i, len) => (len <= 0 ? 0 : Math.max(0, Math.min(len - 1, i)));
26
110
 
27
111
  /** Data-fetching hook: { loading, data, error, reload, setData }. */
28
112
  export function useAsync(fn, deps = []) {
@@ -43,5 +127,566 @@ export function useAsync(fn, deps = []) {
43
127
  return { ...state, reload, setData: (data) => setState((s) => ({ ...s, data })) };
44
128
  }
45
129
 
46
- /** Clamp a cursor index into [0, len). */
47
- export const clampCursor = (i, len) => (len <= 0 ? 0 : Math.max(0, Math.min(len - 1, i)));
130
+ // ---- Relative retirement time --------------------------------------------
131
+ /** retires_date { text, color, bold }; text is ≤7 chars by construction. */
132
+ export function relativeTime(retiresDate, today = new Date()) {
133
+ if (!retiresDate) return { text: "", color: C.FG_FAINT, bold: false };
134
+ const t = new Date(retiresDate).getTime();
135
+ if (Number.isNaN(t)) return { text: "", color: C.FG_FAINT, bold: false };
136
+ const d = Math.round((t - today.getTime()) / 86_400_000);
137
+ let text;
138
+ if (d < 0) {
139
+ const abs = Math.abs(d);
140
+ if (abs < 60) text = `${abs}d ago`;
141
+ else if (abs < 365) text = `${Math.round(abs / 30)}mo ago`;
142
+ else text = `${Math.round(abs / 365)}y ago`;
143
+ } else if (d < 14) text = `in ${d}d`;
144
+ else if (d < 60) text = `in ${Math.ceil(d / 7)}w`;
145
+ else if (d < 365) text = `in ${Math.round(d / 30)}mo`;
146
+ else text = `in ${Math.round(d / 365)}y`;
147
+ text = text.slice(0, 8); // width safety (rel column is 8)
148
+ // Urgency ramp.
149
+ let color = C.FG_FAINT;
150
+ let bold = false;
151
+ if (d < 0) color = "#dc2626";
152
+ else if (d <= 7) { color = "#ea580c"; bold = true; }
153
+ else if (d <= 14) { color = "#f59e0b"; bold = true; }
154
+ else if (d <= 90) color = "#d97706";
155
+ else color = C.FG_FAINT;
156
+ return { text, color, bold };
157
+ }
158
+
159
+ /** env → { text, color }. */
160
+ export function envTag(env) {
161
+ const e = String(env || "");
162
+ const color = e === "prod" ? C.ACCENT : e === "staging" ? "#a78bfa" : C.FG_FAINT;
163
+ return { text: e, color };
164
+ }
165
+
166
+ // ===========================================================================
167
+ // Window chrome
168
+ // ===========================================================================
169
+
170
+ /** Top border with a centered accent title: ╭── title ──╮ (width = outer). */
171
+ export function topRule(title, width) {
172
+ const inner = Math.max(0, width - 2);
173
+ const t = ` ${title} `;
174
+ const dash = Math.max(0, inner - t.length);
175
+ const left = Math.floor(dash / 2);
176
+ const right = dash - left;
177
+ return h(
178
+ Box,
179
+ { flexDirection: "row" },
180
+ h(Text, { color: C.BORDER }, "╭" + "─".repeat(left)),
181
+ h(Text, { color: C.ACCENT, bold: true }, t.slice(0, inner)),
182
+ h(Text, { color: C.BORDER }, "─".repeat(right) + "╮"),
183
+ );
184
+ }
185
+
186
+ export function bottomRule(width) {
187
+ return h(Text, { color: C.BORDER }, "╰" + "─".repeat(Math.max(0, width - 2)) + "╯");
188
+ }
189
+
190
+ /** Bordered window: titled top rule, side-railed body, bottom rule. */
191
+ export function Window({ title, width, children }) {
192
+ return h(
193
+ Box,
194
+ { flexDirection: "column", width },
195
+ topRule(title, width),
196
+ h(
197
+ Box,
198
+ {
199
+ borderStyle: "round",
200
+ borderTop: false,
201
+ borderBottom: false,
202
+ borderColor: C.BORDER,
203
+ paddingX: 1,
204
+ flexDirection: "column",
205
+ width,
206
+ },
207
+ children,
208
+ ),
209
+ bottomRule(width),
210
+ );
211
+ }
212
+
213
+ /** The three macOS traffic-light dots — first interior row. */
214
+ export function TrafficLights() {
215
+ return h(
216
+ Box,
217
+ { flexDirection: "row" },
218
+ h(Text, { color: LIGHT.red }, "●"),
219
+ h(Text, {}, " "),
220
+ h(Text, { color: LIGHT.yellow }, "●"),
221
+ h(Text, {}, " "),
222
+ h(Text, { color: LIGHT.green }, "●"),
223
+ );
224
+ }
225
+
226
+ // Short labels so seven tabs + chip fit 80 cols. Number is always the hotkey.
227
+ const TAB_ABBR = { "What's New": "New", Inventory: "Inv", Account: "Acct" };
228
+
229
+ /** Top tab strip with the account/plan chip pushed right. */
230
+ export function TabStrip({ idx, tabs, account, plan, version }) {
231
+ const segs = tabs.map((v, i) => {
232
+ const label = TAB_ABBR[v.label] || v.label;
233
+ const txt = ` ${i + 1} ${label} `;
234
+ return i === idx
235
+ ? h(Text, { key: v.key, backgroundColor: BG_ON ? C.ACCENT : undefined, color: BG_ON ? C.ACCENT_INK : C.ACCENT, bold: true }, txt)
236
+ : h(Text, { key: v.key, color: C.FG_FAINT }, txt);
237
+ });
238
+ const right = [];
239
+ if (version) right.push(h(Text, { key: "v", color: C.BLUE, bold: true }, ` v${version}`));
240
+ if (account) right.push(h(Text, { key: "a", color: C.FG_DIM }, ` ${account}`));
241
+ if (plan && plan !== "free") right.push(h(Text, { key: "p", backgroundColor: BG_ON ? "#16a34a" : undefined, color: BG_ON ? C.ACCENT_INK : "#16a34a", bold: true }, " PRO "));
242
+ else if (plan === "free") right.push(h(Text, { key: "p", color: C.FG_FAINT }, " free "));
243
+ return h(Box, { flexDirection: "row" }, ...segs, h(Spacer, null), ...right);
244
+ }
245
+
246
+ /** Smaller in-view sub-tabs (whatsnew / alerts). */
247
+ export function SubTabs({ idx, tabs }) {
248
+ return h(
249
+ Box,
250
+ { flexDirection: "row" },
251
+ ...tabs.map((t, i) =>
252
+ h(
253
+ Text,
254
+ { key: t, backgroundColor: i === idx && BG_ON ? C.ACCENT : undefined, color: i === idx ? (BG_ON ? C.ACCENT_INK : C.ACCENT) : C.FG_FAINT, bold: i === idx },
255
+ ` ${t} `,
256
+ ),
257
+ ),
258
+ h(Text, { color: C.FG_FAINT }, " ←→ switch"),
259
+ );
260
+ }
261
+
262
+ // ===========================================================================
263
+ // List rows + cursor highlight (the load-bearing component)
264
+ // ===========================================================================
265
+
266
+ /**
267
+ * One list row, full inner width. `cells` = [{ text, color, bold? }] already
268
+ * padded to their column widths. When `active`, a subtle bg wash + an accent
269
+ * rail mark the cursor; bg is threaded into EVERY child Text (incl. the filler)
270
+ * because Box bg is a no-op in ink. `selected` deepens the wash.
271
+ */
272
+ export function ListRow({ active, selected, cells, width }) {
273
+ const bg = active && BG_ON ? (selected ? C.BG_ROW_SEL : C.BG_ROW) : undefined;
274
+ const rail = active ? { text: GLYPH.rail, color: C.ACCENT } : { text: " ", color: C.FG };
275
+ const all = [rail, ...cells];
276
+ const used = all.reduce((n, c) => n + c.text.length, 0);
277
+ if (used < width) all.push({ text: " ".repeat(width - used), color: C.FG });
278
+ return h(
279
+ Text,
280
+ { wrap: "truncate" },
281
+ ...all.map((c, i) => h(Text, { key: i, color: c.color, bold: c.bold, backgroundColor: bg }, c.text)),
282
+ );
283
+ }
284
+
285
+ // ===========================================================================
286
+ // Status bar + health legend
287
+ // ===========================================================================
288
+
289
+ /** counts → [{text,color}] legend segments, all states in fixed order, zeros dimmed. */
290
+ export function legendSegments(counts = {}) {
291
+ const order = ["ok", "deprecating", "retiring", "retired"];
292
+ const segs = [];
293
+ order.forEach((k, i) => {
294
+ const n = counts[k] || 0;
295
+ const color = n > 0 ? HEALTH_COLOR[k] : C.FG_FAINT;
296
+ if (i > 0) segs.push({ text: " ", color: C.FG_FAINT });
297
+ segs.push({ text: `${HEALTH_GLYPH[k]} ${n}`, color });
298
+ });
299
+ if (counts.custom > 0) {
300
+ segs.push({ text: " ", color: C.FG_FAINT });
301
+ segs.push({ text: `${HEALTH_GLYPH.custom} ${counts.custom}`, color: HEALTH_COLOR.custom });
302
+ }
303
+ return segs;
304
+ }
305
+
306
+ /**
307
+ * Pinned status strip. `segsLeft` / `segsRight` are [{text,color,bold?}]. The
308
+ * middle gap is filled with bg-colored spaces so the strip reads as one
309
+ * continuous bar (Box bg / space-between won't fill it — verified).
310
+ */
311
+ export function StatusBar({ segsLeft = [], segsRight = [], width }) {
312
+ const bg = BG_ON ? C.BG_ELEV : undefined;
313
+ const len = (segs) => segs.reduce((n, s) => n + s.text.length, 0);
314
+ const fill = Math.max(1, width - len(segsLeft) - len(segsRight));
315
+ const all = [...segsLeft, { text: " ".repeat(fill), color: C.FG_FAINT }, ...segsRight];
316
+ return h(
317
+ Text,
318
+ { wrap: "truncate" },
319
+ ...all.map((s, i) => h(Text, { key: i, color: s.color, bold: s.bold, backgroundColor: bg }, s.text)),
320
+ );
321
+ }
322
+
323
+ // ===========================================================================
324
+ // Keybar
325
+ // ===========================================================================
326
+
327
+ export function KeyCap({ k, label }) {
328
+ return h(
329
+ Box,
330
+ { flexDirection: "row" },
331
+ h(Text, { backgroundColor: BG_ON ? C.BG_ELEV : undefined, color: C.ACCENT, bold: true }, ` ${k} `),
332
+ h(Text, { color: C.FG_DIM }, ` ${label}`),
333
+ );
334
+ }
335
+
336
+ /** Single-line keybar; always shows `q quit`, dropping earlier keys with `…`
337
+ * when the row overflows (so quit is never the casualty of truncation). */
338
+ export function KeyBar({ keys, width }) {
339
+ const SEP = 2;
340
+ const capW = (it) => it.k.length + 2 + 1 + it.label.length; // " k " + " label"
341
+ const budget = width - capW({ k: "q", label: "quit" }) - SEP; // reserve room for q quit
342
+ const out = [];
343
+ let used = 0;
344
+ let dropped = false;
345
+ for (const item of keys) {
346
+ const w = capW(item) + (out.length ? SEP : 0);
347
+ if (used + w > budget) { dropped = true; break; }
348
+ if (out.length) out.push(h(Text, { key: `s${out.length}`, color: C.FG }, " "));
349
+ out.push(h(KeyCap, { key: item.k, k: item.k, label: item.label }));
350
+ used += w;
351
+ }
352
+ if (dropped) out.push(h(Text, { key: "more", color: C.FG_FAINT }, " … "));
353
+ else if (out.length) out.push(h(Text, { key: "sq", color: C.FG }, " "));
354
+ out.push(h(KeyCap, { key: "q", k: "q", label: "quit" }));
355
+ return h(Box, { flexDirection: "row" }, ...out);
356
+ }
357
+
358
+ // ===========================================================================
359
+ // State helpers
360
+ // ===========================================================================
361
+
362
+ /** kind ∈ loading|scanning|done|error → leading glyph + colored text, one row. */
363
+ export function StateLine({ kind, text, spin }) {
364
+ if (kind === "done") return h(Text, {}, h(Text, { color: "#16a34a" }, ` ${GLYPH.check} `), h(Text, { color: C.FG_DIM }, text));
365
+ if (kind === "error") return h(Text, {}, h(Text, { color: "#dc2626" }, ` ${GLYPH.cross} `), h(Text, { color: "#dc2626" }, text));
366
+ // loading / scanning
367
+ return h(Text, {}, h(Text, { color: C.ACCENT }, ` ${spin ?? SPINNER[0]} `), h(Text, { color: C.FG_DIM }, text));
368
+ }
369
+
370
+ /** Centered empty/onboarding card framed by faint rules. */
371
+ export function EmptyCard({ icon = GLYPH.spark, title, lines = [], width }) {
372
+ const rule = "─".repeat(Math.max(8, Math.min(width - 2, 60)));
373
+ return h(
374
+ Box,
375
+ { flexDirection: "column", paddingX: 1 },
376
+ h(Text, { color: C.FG_FAINT }, rule),
377
+ h(Text, {}, ""),
378
+ h(Text, {}, h(Text, { color: C.ACCENT, bold: true }, `${icon} `), h(Text, { color: C.FG }, title)),
379
+ ...lines.map((l, i) => h(Text, { key: i, color: C.FG_DIM }, ` ${l}`)),
380
+ h(Text, {}, ""),
381
+ h(Text, { color: C.FG_FAINT }, rule),
382
+ );
383
+ }
384
+
385
+ // ===========================================================================
386
+ // SweepBar — indeterminate progress (shared by Local + Scan streaming)
387
+ // ===========================================================================
388
+
389
+ /** Bouncing cyan-ramp bar; we don't know the total file count, so no fake %. */
390
+ export function SweepBar({ tick, width = 22 }) {
391
+ const span = width * 2 - 2;
392
+ const head = span > 0 ? tick % span : 0;
393
+ const pos = head < width ? head : span - head;
394
+ const cells = [];
395
+ for (let i = 0; i < width; i++) {
396
+ const d = pos - i;
397
+ if (d === 0) cells.push(h(Text, { key: i, color: "#67e8f9" }, "█"));
398
+ else if (d === 1 || d === -1) cells.push(h(Text, { key: i, color: C.ACCENT }, "▓"));
399
+ else if (d === 2 || d === -2) cells.push(h(Text, { key: i, color: C.ACCENT_DK }, "▒"));
400
+ else cells.push(h(Text, { key: i, color: C.FG_FAINT }, "░"));
401
+ }
402
+ return h(Box, {}, ...cells);
403
+ }
404
+
405
+ // ===========================================================================
406
+ // Layout authority — single place that decides column widths for `W` inner cols
407
+ // ===========================================================================
408
+
409
+ /** Scan/list column widths derived from inner width W (reference W=78).
410
+ * Columns: rail(1) sel(1) ·g· health(1) ·g· slug ·g· loc ·g· env(5) ·g· rel(8).
411
+ * Non-var fields = rail+sel+health+env = 8; gutters = 5 (4 when loc dropped). */
412
+ export function layout(W) {
413
+ const rel = 8;
414
+ let slug = 34;
415
+ let loc = 23;
416
+ const total = () => 8 + (loc > 0 ? 5 : 4) + slug + loc + rel;
417
+ if (W < 72) loc = 0;
418
+ while (total() > W && slug > 18) slug--;
419
+ while (total() > W && loc > 12) loc--;
420
+ return { slug, loc, env: 5, rel, dropLoc: loc === 0 };
421
+ }
422
+
423
+ // ===========================================================================
424
+ // Interactive list kit — cursor/scroll + "/" search (shared by list views)
425
+ // ===========================================================================
426
+
427
+ /** Cursor + scroll window over a list of `length` items, `pageSize` visible.
428
+ * Stays in range when the list shrinks (e.g. a search filter). */
429
+ export function useCursorList(length, pageSize) {
430
+ const [cursor, setCursor] = React.useState(0);
431
+ React.useEffect(() => {
432
+ if (cursor > length - 1) setCursor(Math.max(0, length - 1));
433
+ }, [length]); // eslint-disable-line react-hooks/exhaustive-deps
434
+ const c = clampCursor(cursor, length);
435
+ const page = Math.max(1, pageSize);
436
+ const start = Math.max(0, Math.min(c - Math.floor(page / 2), Math.max(0, length - page)));
437
+ return {
438
+ cursor: c,
439
+ start,
440
+ end: Math.min(length, start + page),
441
+ up: () => setCursor((x) => clampCursor(x - 1, length)),
442
+ down: () => setCursor((x) => clampCursor(x + 1, length)),
443
+ set: setCursor,
444
+ reset: () => setCursor(0),
445
+ };
446
+ }
447
+
448
+ /** "/" search state. Convention: "/" opens; type to filter live; Esc clears +
449
+ * exits; Enter keeps the filter and exits input mode. */
450
+ export function useSearch() {
451
+ const [searching, setSearching] = React.useState(false);
452
+ const [query, setQuery] = React.useState("");
453
+ // ink's useInput fires the render-time closure; two keys in one tick both see
454
+ // the stale `searching` before React commits. A ref mutated synchronously in
455
+ // open/confirm/clear lets the branch guard read the up-to-date mode, so a key
456
+ // pressed immediately after "/" (or after Enter) routes correctly instead of
457
+ // leaking into destructive commands.
458
+ const ref = React.useRef(false);
459
+ const setMode = (v) => { ref.current = v; setSearching(v); };
460
+ return {
461
+ searching,
462
+ query,
463
+ active: searching || query.length > 0,
464
+ isSearchingNow: () => ref.current,
465
+ open: () => setMode(true),
466
+ confirm: () => setMode(false),
467
+ clear: () => { setQuery(""); setMode(false); },
468
+ type: (ch) => setQuery((q) => q + ch),
469
+ backspace: () => setQuery((q) => q.slice(0, -1)),
470
+ };
471
+ }
472
+
473
+ /** Bottom search line: ` / query▏ (N matches)` while typing, or a dim filter
474
+ * chip when a query is set but input is closed. Renders an empty line otherwise
475
+ * (so the frame height stays constant). */
476
+ export function SearchBar({ searching, query, count }) {
477
+ if (!searching && !query) return h(Text, {}, "");
478
+ return h(
479
+ Text,
480
+ {},
481
+ h(Text, { color: C.ACCENT, bold: true }, " / "),
482
+ h(Text, { color: C.FG }, query),
483
+ searching ? h(Text, { color: C.ACCENT }, "▏") : null,
484
+ h(Text, { color: C.FG_FAINT }, ` ${count} match${count === 1 ? "" : "es"}${searching ? " · esc clear · enter keep" : " · / edit"}`),
485
+ );
486
+ }
487
+
488
+ // ===========================================================================
489
+ // ModelDetailBar — full-width bottom detail panel for the cursor row (k9s-style)
490
+ // ===========================================================================
491
+
492
+ const fmtCtx = (n) => (!n ? "—" : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n));
493
+ const orDash = (s) => (s == null || s === "" ? "—" : String(s));
494
+
495
+ const refLoc = (r) => (r.source_path || r.location_label || "") + (r.source_line ? ":" + r.source_line : "");
496
+
497
+ // Display width: most chars 1 column, CJK / fullwidth / most emoji 2, combining
498
+ // & zero-width 0. Dependency-free approximation — enough to keep the snippet
499
+ // panel (the only place we clip raw user-file content) from overflowing.
500
+ function charWidth(cp) {
501
+ if (cp === 0) return 0;
502
+ if ((cp >= 0x0300 && cp <= 0x036f) || (cp >= 0x200b && cp <= 0x200f) || cp === 0xfeff) return 0;
503
+ if (
504
+ (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0x303e) ||
505
+ (cp >= 0x3041 && cp <= 0x33ff) || (cp >= 0x3400 && cp <= 0x4dbf) ||
506
+ (cp >= 0x4e00 && cp <= 0x9fff) || (cp >= 0xa000 && cp <= 0xa4cf) ||
507
+ (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff) ||
508
+ (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) ||
509
+ (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x1f300 && cp <= 0x1faff) ||
510
+ (cp >= 0x20000 && cp <= 0x3fffd)
511
+ ) return 2;
512
+ return 1;
513
+ }
514
+ export function dispWidth(str) {
515
+ let w = 0;
516
+ for (const ch of String(str)) w += charWidth(ch.codePointAt(0));
517
+ return w;
518
+ }
519
+ /** Truncate + right-pad a string to exactly `w` display columns (code-point aware). */
520
+ function padDisp(s, w) {
521
+ s = String(s ?? "");
522
+ let out = "", used = 0;
523
+ for (const ch of s) {
524
+ const cw = charWidth(ch.codePointAt(0));
525
+ if (used + cw > w) break;
526
+ out += ch; used += cw;
527
+ }
528
+ return out + " ".repeat(Math.max(0, w - used));
529
+ }
530
+
531
+ /** Clip + right-pad a segment array to exactly `budget` display columns,
532
+ * optionally skipping the first `skip` columns (left horizontal-scroll).
533
+ * Code-point + display-width aware so wide chars never split or overflow. */
534
+ function clipSegs(segs, budget, skip = 0) {
535
+ const out = [];
536
+ let used = 0, skipped = 0;
537
+ for (const s of segs) {
538
+ if (used >= budget) break;
539
+ let text = "";
540
+ for (const ch of String(s.text)) {
541
+ const w = charWidth(ch.codePointAt(0));
542
+ if (skipped < skip) { skipped += w; continue; }
543
+ if (used + w > budget) { used = budget; break; }
544
+ text += ch; used += w;
545
+ }
546
+ if (text) out.push({ ...s, text });
547
+ if (used >= budget) break;
548
+ }
549
+ if (used < budget) out.push({ text: " ".repeat(budget - used), color: C.FG });
550
+ return out;
551
+ }
552
+
553
+ /** Columns to skip so the accent-highlighted span on a match row is visible
554
+ * (~8 cols from the left) when it sits past `budget`; 0 if already on-screen. */
555
+ function matchScroll(segs, budget) {
556
+ let col = 0, hitCol = -1, hitW = 0;
557
+ for (const s of segs) {
558
+ const w = dispWidth(s.text);
559
+ if (s.bg === C.ACCENT && hitCol < 0) { hitCol = col; hitW = w; }
560
+ col += w;
561
+ }
562
+ if (hitCol < 0 || hitCol + hitW <= budget) return 0;
563
+ return Math.max(0, hitCol - 8);
564
+ }
565
+
566
+ /** Render a readSnippet() result (from snippet.js) to ≤ maxRows full-width Text
567
+ * lines: a file:line header then syntax-highlighted code with the match line
568
+ * gutter-marked and the offending string accent-highlighted. */
569
+ export function snippetLines(snippet, width, maxRows) {
570
+ if (!snippet || !snippet.rows || !snippet.rows.length) return [];
571
+ const W = Math.max(20, width);
572
+ const all = snippet.rows;
573
+ const max = Math.max(1, maxRows);
574
+ // Window AROUND the matched row (readSnippet centers it) so the offending line
575
+ // stays visible when we can't render every context line — top-slicing would
576
+ // drop the one line the preview exists to show.
577
+ let start = 0;
578
+ if (all.length > max) {
579
+ const m = all.findIndex((r) => r.isMatch);
580
+ start = Math.max(0, Math.min((m < 0 ? 0 : m) - Math.floor((max - 1) / 2), all.length - max));
581
+ }
582
+ const rows = all.slice(start, start + max);
583
+ const gw = String(rows[rows.length - 1].num).length;
584
+ const hdr = snippet.path.split(/[\\/]/).slice(-2).join("/") + ":" + snippet.line; // split both seps (Windows)
585
+ const out = [h(Text, { key: "sh", color: C.FG_FAINT }, padDisp(" " + hdr, W))];
586
+ rows.forEach((row, i) => {
587
+ const gutter = (row.isMatch ? GLYPH.caret : " ") + String(row.num).padStart(gw) + " ";
588
+ const budget = W - gutter.length;
589
+ // Horizontal-scroll the match row so a model string past the panel width
590
+ // (long minified configs, deep indentation) is still shown, not left-clipped.
591
+ const skip = row.isMatch ? matchScroll(row.segs, budget) : 0;
592
+ const lead = skip > 0 ? 1 : 0;
593
+ out.push(
594
+ h(
595
+ Text,
596
+ { key: "sl" + i },
597
+ h(Text, { color: row.isMatch ? C.ACCENT : C.FG_FAINT }, gutter),
598
+ skip > 0 ? h(Text, { key: "ell", color: C.FG_FAINT }, "…") : null,
599
+ ...clipSegs(row.segs, budget - lead, skip).map((s, j) => h(Text, { key: j, color: s.color, bold: s.bold, backgroundColor: s.bg }, s.text)),
600
+ ),
601
+ );
602
+ });
603
+ return out;
604
+ }
605
+
606
+ /**
607
+ * Full-width bottom detail panel for the highlighted reference. Every line is a
608
+ * single colored Text padded to exactly `width` (the StatusBar/ListRow
609
+ * discipline) so the panel fills uniformly — no nested bordered box, no flex
610
+ * shrink-wrap. Padded to `height` rows. `model` is the registry model (or null
611
+ * for custom); `refs` are the local references to it.
612
+ */
613
+ export function ModelDetailBar({ title, health, model, refs = [], width, height = 6, refCursor = -1, snippet = null }) {
614
+ const W = Math.max(20, width);
615
+ const focused = refCursor >= 0;
616
+ const rt = model && model.retires_date ? relativeTime(model.retires_date) : null;
617
+ const lines = [];
618
+ // separator rule
619
+ lines.push(h(Text, { key: "rule", color: C.BORDER }, "─".repeat(W)));
620
+ // header: health glyph + slug (strong) + health word, padded to W
621
+ const hg = `${healthGlyph(health)} `;
622
+ const hw = ` ${health}`;
623
+ const slugW = Math.max(8, W - hg.length - hw.length);
624
+ lines.push(
625
+ h(
626
+ Text,
627
+ { key: "head" },
628
+ h(Text, { color: healthColor(health) }, hg),
629
+ h(Text, { color: C.FG_STRONG, bold: true }, cellE(title, slugW)),
630
+ h(Text, { color: healthColor(health) }, cell(hw, W - hg.length - slugW)),
631
+ ),
632
+ );
633
+ // Distinct usage locations (preserve the candidate object for each).
634
+ const distinct = [...new Map(refs.map((r) => [refLoc(r), r])).values()];
635
+
636
+ if (focused) {
637
+ // Selectable reference list (drilled in) + a syntax-highlighted snippet of
638
+ // the highlighted ref below it.
639
+ lines.push(h(Text, { key: "hint", color: C.FG_FAINT }, cell(` used in ${distinct.length} · ↑↓ select · ↵ open · e exclude · esc/⌫ back`, W)));
640
+ // Size the snippet from the rows actually left (was a fixed 7, which on a
641
+ // standard 80x24 — panelH 9 — overflowed and bottom-truncated the match
642
+ // line + collapsed the ref list to one row). Reserve a few ref rows when
643
+ // there's room, hand the remainder (minus its own rule) to the snippet.
644
+ const chrome = lines.length; // rule + head + hint
645
+ const avail = Math.max(1, height - chrome);
646
+ const minRefRows = Math.min(distinct.length || 1, avail <= 6 ? 1 : avail <= 10 ? 2 : 3);
647
+ const snipMax = Math.max(0, avail - minRefRows - 1); // -1 for the snippet's own rule
648
+ const snip = snippet && snipMax >= 2 ? snippetLines(snippet, W, snipMax - 1) : []; // snipMax incl. snippet's file:line header
649
+ const reserve = snip.length ? snip.length + 1 : 0; // +1 for the rule above the snippet
650
+ const rowsAvail = Math.max(1, avail - reserve);
651
+ const cur = Math.max(0, Math.min(refCursor, distinct.length - 1));
652
+ const start = Math.max(0, Math.min(cur - Math.floor(rowsAvail / 2), Math.max(0, distinct.length - rowsAvail)));
653
+ const envW = 8;
654
+ const pathW = Math.max(8, W - 1 - envW);
655
+ distinct.slice(start, start + rowsAvail).forEach((r, i) => {
656
+ const idx = start + i;
657
+ const isCur = idx === cur;
658
+ lines.push(
659
+ h(
660
+ Text,
661
+ { key: "rf" + idx },
662
+ h(Text, { color: C.ACCENT }, isCur ? GLYPH.rail : " "),
663
+ h(Text, { color: isCur ? C.FG_STRONG : C.FG_DIM, bold: isCur }, cellL(refLoc(r), pathW)),
664
+ h(Text, { color: C.FG_FAINT }, cell(r.environment || "", W - 1 - pathW)),
665
+ ),
666
+ );
667
+ });
668
+ if (snip.length) {
669
+ lines.push(h(Text, { key: "srule", color: C.BORDER }, "─".repeat(W)));
670
+ lines.push(...snip);
671
+ }
672
+ } else {
673
+ if (model) {
674
+ const life = `released ${orDash(model.release_date)} deprecated ${orDash(model.deprecation_date)} retires ${orDash(model.retires_date)}${rt && rt.text ? ` (${rt.text})` : ""}${model.replacement_slug ? ` ${GLYPH.repl} ${model.replacement_slug}` : ""}`;
675
+ const about = `provider ${orDash(model.provider_slug)} family ${orDash(model.family)} modality ${orDash(model.modality)} context ${fmtCtx(model.context_window)}`;
676
+ lines.push(h(Text, { key: "life", color: C.FG_DIM }, cell(life, W)));
677
+ lines.push(h(Text, { key: "about", color: C.FG_DIM }, cell(about, W)));
678
+ } else {
679
+ lines.push(h(Text, { key: "cu", color: C.FG_FAINT }, cell("not in the registry — tracked as a custom model", W)));
680
+ }
681
+ const locs = distinct.slice(0, 8).map(refLoc).join(" ");
682
+ const more = distinct.length > 1 ? " · ↵ select" : "";
683
+ lines.push(h(Text, { key: "used", color: C.FG_FAINT }, cell(`used in ${distinct.length} place${distinct.length === 1 ? "" : "s"}: ${locs}${distinct.length > 8 ? " …" : ""}${more}`, W)));
684
+ }
685
+ while (lines.length < height) lines.push(h(Text, { key: "p" + lines.length, color: C.FG }, cell("", W)));
686
+ return h(Box, { flexDirection: "column" }, ...lines.slice(0, height));
687
+ }
688
+
689
+ /** Distinct usage locations for a model, preserving the first candidate per file:line. */
690
+ export function distinctRefs(refs = []) {
691
+ return [...new Map(refs.map((r) => [refLoc(r), r])).values()];
692
+ }