@nexusbloom/cli 0.2.12 → 0.3.0

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/ui.js ADDED
@@ -0,0 +1,371 @@
1
+ /**
2
+ * ui.js — Terminal UI helpers for the nxb CLI.
3
+ *
4
+ * Everything rendering / prompting related lives here so command modules
5
+ * stay lean and the look-and-feel is consistent across `list`, `abbr`, `run`, etc.
6
+ *
7
+ * Exports:
8
+ * - Visual primitives : renderHeader, renderBox, renderBadge, divider
9
+ * - Table printer : renderTable
10
+ * - Pagination engine : paginate (pure) + paginateInteractive (live nav)
11
+ * - Prompt wrappers : promptSelect, promptInput, promptConfirm,
12
+ * promptNumber, promptCheckbox, promptMultiSelect
13
+ */
14
+
15
+ import chalk from "chalk";
16
+ import {
17
+ select,
18
+ input,
19
+ confirm,
20
+ number,
21
+ checkbox,
22
+ Separator,
23
+ } from "@inquirer/prompts";
24
+
25
+ // ─── Constants ───────────────────────────────────────────────────────────────
26
+
27
+ export const PAGE_SIZE = 10;
28
+
29
+ // Box-drawing palette
30
+ const BOX = {
31
+ topLeft: "┌",
32
+ topRight: "┐",
33
+ bottomLeft: "└",
34
+ bottomRight: "┘",
35
+ h: "─",
36
+ v: "│",
37
+ };
38
+
39
+ // Badge style map per tool type / category
40
+ const BADGE_STYLES = {
41
+ local: { label: chalk.bgBlueBright.white(" local "), border: "blueBright" },
42
+ builtin: { label: chalk.bgGreen.white(" builtin "), border: "green" },
43
+ community: { label: chalk.bgMagenta.white("community"), border: "magenta" },
44
+ uncategorized: { label: chalk.bgGray.white(" uncat "), border: "gray" },
45
+ };
46
+
47
+ // ─── Simple helpers ──────────────────────────────────────────────────────────
48
+
49
+ /** Print a thin horizontal rule across the terminal. */
50
+ export function divider(width = 60) {
51
+ return console.log(chalk.gray("─".repeat(width)));
52
+ }
53
+
54
+ /**
55
+ * Render a centered header with a title and optional subtitle.
56
+ * Clears any previous inquirer prompt state.
57
+ */
58
+ export function renderHeader(title, { subtitle, color = "cyanBright" } = {}) {
59
+ const bar = "═".repeat(Math.max(title.length + 4, 30));
60
+ const line1 = chalk[color].bold(` ${bar}`);
61
+ const line2 = chalk[color].bold(` ║ ${title.padEnd(title.length)} ║`);
62
+ const line3 = chalk[color].bold(` ${bar}`);
63
+ console.log("\n" + line1);
64
+ console.log(line2);
65
+ console.log(line3);
66
+ if (subtitle) {
67
+ console.log(chalk.dim(` ${subtitle}\n`));
68
+ } else {
69
+ console.log();
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Render a bordered box around arbitrary content lines.
75
+ *
76
+ * renderBox("Results", ["line one", "line two"], { footer: "done." })
77
+ */
78
+ export function renderBox(title, lines = [], { footer, width, borderColor = "gray" } = {}) {
79
+ const border = chalk[borderColor];
80
+ const innerWidth = (width || Math.min(72, Math.max(40, Math.max(...(lines || []).map(l => (l || "").length), title.length)))) - 4;
81
+ const top = border(`${BOX.topLeft}${BOX.h.repeat(innerWidth + 2)}${BOX.topRight}`);
82
+
83
+ const titleLine = border(BOX.v) + " " + chalk.bold(title) + " " + border(BOX.v);
84
+
85
+ const body = (lines || []).map((line) => {
86
+ const cleaned = typeof line === "string" ? line : String(line);
87
+ return border(BOX.v) + " " + cleaned.padEnd(innerWidth) + " " + border(BOX.v);
88
+ });
89
+
90
+ const bottom = border(`${BOX.bottomLeft}${BOX.h.repeat(innerWidth + 2)}${BOX.bottomRight}`);
91
+
92
+ console.log("\n" + top);
93
+ console.log(titleLine);
94
+ if (body.length) console.log(body.join("\n"));
95
+ if (footer) console.log(" " + chalk.dim(footer));
96
+ console.log(bottom + "\n");
97
+ }
98
+
99
+ /**
100
+ * Render a small colored badge.
101
+ *
102
+ * renderBadge("local") → blue background
103
+ * renderBadge("image") → category badge
104
+ * renderBadge("custom", "info") → generic info badge
105
+ */
106
+ export function renderBadge(text, type = "info") {
107
+ if (type === "tool") {
108
+ const style = BADGE_STYLES[text] || BADGE_STYLES.uncategorized;
109
+ return style.label;
110
+ }
111
+ // Generic info badge
112
+ return chalk.gray(`[${text}]`);
113
+ }
114
+
115
+ /** Pre-built type badge for tool objects. */
116
+ export function typeBadge(tool) {
117
+ const t = tool?.type || "builtin";
118
+ return renderBadge(t, "tool");
119
+ }
120
+
121
+ /** Pre-built category badge — shows the category name in a muted block. */
122
+ export function categoryBadge(cat) {
123
+ const c = (cat || "uncategorized").toLowerCase();
124
+ // Known categories get a coloured block
125
+ const known = {
126
+ text: chalk.bgWhite.black(" text "),
127
+ image: chalk.bgCyan.black(" image "),
128
+ data: chalk.bgYellow.black(" data "),
129
+ network: chalk.bgBlue.white("network"),
130
+ security: chalk.bgRed.white("secure"),
131
+ development: chalk.bgGreen.white(" dev "),
132
+ utility: chalk.bgGray.white("utils "),
133
+ math: chalk.bgGray.white(" math "),
134
+ business: chalk.bgGray.white("biz "),
135
+ education: chalk.bgGray.white(" learn "),
136
+ };
137
+ if (known[c]) return known[c];
138
+ // Show first 6 chars for unknown categories
139
+ const label = c === "uncategorized" ? " any " : c.slice(0, 6).padEnd(6);
140
+ return chalk.bgGray.white(` ${label} `);
141
+ }
142
+
143
+ // ─── Table printer ───────────────────────────────────────────────────────────
144
+
145
+ /**
146
+ * Render an aligned text table.
147
+ *
148
+ * cols: [{ key, header, width?, align?, color? }]
149
+ * rows: array of objects keyed by `key`
150
+ */
151
+ export function renderTable(cols, rows, { indent = " ", headColor = "bold" } = {}) {
152
+ const computed = cols.map((c) => ({
153
+ ...c,
154
+ width: c.width || Math.max(c.header.length, ...rows.map((r) => stringWidth(r[c.key]))),
155
+ }));
156
+
157
+ const fmt = (val, col) => {
158
+ const str = val == null ? "" : String(val);
159
+ const trimmed = str.length > col.width ? str.slice(0, col.width - 1) + "…" : str;
160
+ let cell = trimmed.padEnd(col.width);
161
+ if (col.align === "right") cell = trimmed.padStart(col.width);
162
+ if (col.color) return col.color(cell);
163
+ return cell;
164
+ };
165
+
166
+ // Header
167
+ const headLine = indent + computed.map((c) => chalk[headColor](c.header.padEnd(c.width))).join(" ");
168
+ const sepLine = indent + computed.map((c) => chalk.gray("─".repeat(c.width))).join(" ");
169
+
170
+ console.log(headLine);
171
+ console.log(sepLine);
172
+ for (const row of rows) {
173
+ console.log(indent + computed.map((c) => fmt(row[c.key], c)).join(" "));
174
+ }
175
+ }
176
+
177
+ /** Byte-safe string width (strips ANSI, handles emoji roughly). */
178
+ function stringWidth(str) {
179
+ return (str || "").replace(/\x1b\[[0-9;]*m/g, "").length;
180
+ }
181
+
182
+ // ─── Pagination engine ───────────────────────────────────────────────────────
183
+
184
+ /**
185
+ * Pure pagination. Given a flat list, return a slice plus navigation metadata.
186
+ *
187
+ * const page = paginate(items, 10, 2);
188
+ * // { items, page, totalPages, total, pageSize, startIndex, endIndex,
189
+ * hasPrev, hasNext }
190
+ */
191
+ export function paginate(items, pageSize = PAGE_SIZE, currentPage = 1) {
192
+ const list = items || [];
193
+ const total = list.length;
194
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
195
+ const page = Math.max(1, Math.min(currentPage, totalPages));
196
+ const startIndex = (page - 1) * pageSize;
197
+ const endIndex = Math.min(startIndex + pageSize, total);
198
+ return {
199
+ items: list.slice(startIndex, endIndex),
200
+ page,
201
+ totalPages,
202
+ total,
203
+ pageSize,
204
+ startIndex,
205
+ endIndex,
206
+ hasPrev: page > 1,
207
+ hasNext: page < totalPages,
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Interactive paginator: renders the current page via `renderPage`, then offers
213
+ * prev / next / cancel navigation until the user settles.
214
+ *
215
+ * renderPage(pageResult) → returns a value or Promise; its return is used as
216
+ * the final resolved value when the user picks "Done".
217
+ *
218
+ * Returns the last rendered page object (with `selected` set when applicable)
219
+ * or `null` on cancel.
220
+ */
221
+ export async function paginateInteractive(items, pageSize, renderPage, options = {}) {
222
+ const all = items || [];
223
+ if (all.length === 0) {
224
+ renderPage(paginate([], pageSize, 1));
225
+ return null;
226
+ }
227
+
228
+ let page = 1;
229
+ let result = paginate(all, pageSize, page);
230
+
231
+ while (true) {
232
+ // Clear and re-render the page
233
+ if (process.stdout.isTTY) process.stdout.write("\x1b[?25l"); // hide cursor
234
+ renderPage(result);
235
+
236
+ if (result.totalPages === 1) {
237
+ if (process.stdout.isTTY) process.stdout.write("\x1b[?25h");
238
+ return result;
239
+ }
240
+
241
+ // Build navigator choices
242
+ const footer = [];
243
+ if (result.hasPrev) footer.push({ name: `${chalk.cyan("◀ Prev page")}`, value: "prev", description: "Show previous page" });
244
+ footer.push({ name: `${chalk.cyan("→ Done")}`, value: "done", description: "Keep current view" });
245
+ if (result.hasNext) footer.push({ name: `${chalk.cyan("▶ Next page")}`, value: "next", description: "Show next page" });
246
+ footer.push({ name: `${chalk.red("✕ Cancel")}`, value: "cancel", description: "Abort" });
247
+
248
+ const choices = [
249
+ new Separator(),
250
+ ...footer,
251
+ ];
252
+
253
+ const action = await select({
254
+ message: chalk.gray(`Page ${result.page} of ${result.totalPages} · ${result.total} items`),
255
+ choices,
256
+ pageSize: options.pageSize || 8,
257
+ });
258
+
259
+ if (process.stdout.isTTY) process.stdout.write("\x1b[?25l"); // hide again before re-render
260
+ if (options.beforeRender) await options.beforeRender();
261
+ if (action === "cancel") {
262
+ if (process.stdout.isTTY) process.stdout.write("\x1b[?25h");
263
+ return null;
264
+ }
265
+ if (action === "done") {
266
+ if (process.stdout.isTTY) process.stdout.write("\x1b[?25h");
267
+ return result;
268
+ }
269
+ if (action === "prev") page = Math.max(1, page - 1);
270
+ if (action === "next") page = Math.min(result.totalPages, page + 1);
271
+ result = paginate(all, pageSize, page);
272
+ }
273
+ }
274
+
275
+ /** Pagination footer: `Page 1 of 5 · 42 items` in a subtle style. */
276
+ export function renderPageInfo(result) {
277
+ console.log(
278
+ chalk.dim(
279
+ ` Page ${chalk.cyan(result.page)} of ${chalk.cyan(result.totalPages)} ` +
280
+ chalk.gray(`· ${result.total} items`)
281
+ )
282
+ );
283
+ }
284
+
285
+ // ─── Prompt wrappers (consistent styling + sensible defaults) ─────────────────
286
+
287
+ /**
288
+ * Present a list of choices and return the selected value.
289
+ *
290
+ * choices: [{ name, value, description? }] or plain strings
291
+ * Use a Separator anywhere in the array.
292
+ */
293
+ export async function promptSelect(message, choices, opts = {}) {
294
+ return select({
295
+ message: chalk.cyan(message),
296
+ choices,
297
+ pageSize: opts.pageSize || 10,
298
+ ...opts,
299
+ });
300
+ }
301
+
302
+ /** Single-line text input. */
303
+ export async function promptInput(message, { defaultValue, validate, ...opts } = {}) {
304
+ return input({
305
+ message: chalk.cyan(message),
306
+ default: defaultValue,
307
+ ...(validate ? { validate } : {}),
308
+ ...opts,
309
+ });
310
+ }
311
+
312
+ /** Yes/no confirmation. */
313
+ export async function promptConfirm(message, { default: def = true } = {}) {
314
+ return confirm({
315
+ message: chalk.cyan(message),
316
+ default: def,
317
+ });
318
+ }
319
+
320
+ /** Numeric input. */
321
+ export async function promptNumber(message, { min, max, ...opts } = {}) {
322
+ return number({
323
+ message: chalk.cyan(message),
324
+ ...(min != null ? { min } : {}),
325
+ ...(max != null ? { max } : {}),
326
+ ...opts,
327
+ });
328
+ }
329
+
330
+ /**
331
+ * Multi-select (checkbox) — returns array of selected values.
332
+ * Pass `isCheckedAll` to offer a "toggle all" separator-less convenience.
333
+ */
334
+ export async function promptCheckbox(message, choices, opts = {}) {
335
+ return checkbox({
336
+ message: chalk.cyan(message),
337
+ choices,
338
+ pageSize: opts.pageSize || 10,
339
+ ...opts,
340
+ });
341
+ }
342
+
343
+ /** Convenience: separator for checkbox/choice lists. */
344
+ export function sep(label = "") {
345
+ return new Separator(label ? ` — ${label} — ` : "");
346
+ }
347
+
348
+ // ─── Tool-specific renderers ─────────────────────────────────────────────────
349
+
350
+ /** Render a single tool row for `nxb list` / `nxb search`. */
351
+ export function renderToolCard(tool, { compact = false } = {}) {
352
+ const icon = tool.icon || "🔧";
353
+ const name = chalk.bold.cyan(tool.name || tool.slug);
354
+ const slug = chalk.dim(tool.slug);
355
+ const badge = typeBadge(tool);
356
+ const desc = chalk.dim(tool.short_description || tool.description || "");
357
+
358
+ if (compact) {
359
+ console.log(
360
+ ` ${icon} ${chalk.cyan(tool.slug.padEnd(28))} ${desc.slice(0, 55)}${badge ? " " + badge : ""}`
361
+ );
362
+ return;
363
+ }
364
+
365
+ console.log(` ${icon} ${name} ${badge}`);
366
+ console.log(` ${slug}`);
367
+ if (desc) console.log(` ${desc}`);
368
+ const tags = tool.tags && tool.tags.length ? chalk.gray(tool.tags.map((t) => `#${t}`).join(" ")) : "";
369
+ if (tags) console.log(` ${tags}`);
370
+ console.log();
371
+ }