@nexusbloom/cli 0.2.11 → 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/README.md +96 -19
- package/package.json +2 -1
- package/src/abbr.js +111 -0
- package/src/index.js +279 -914
- package/src/list.js +107 -0
- package/src/paths.js +42 -0
- package/src/run.js +380 -0
- package/src/tools.js +561 -0
- package/src/ui.js +371 -0
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
|
+
}
|