@velum-labs/routekit-cli-ui 0.9.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/LICENSE +201 -0
- package/README.md +20 -0
- package/dist/format.d.ts +27 -0
- package/dist/format.js +112 -0
- package/dist/fuzzy.d.ts +23 -0
- package/dist/fuzzy.js +61 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +29 -0
- package/dist/ink/components.d.ts +49 -0
- package/dist/ink/components.js +116 -0
- package/dist/ink/presenter.d.ts +31 -0
- package/dist/ink/presenter.js +250 -0
- package/dist/ink/prompts.d.ts +73 -0
- package/dist/ink/prompts.js +338 -0
- package/dist/ink/store.d.ts +13 -0
- package/dist/ink/store.js +22 -0
- package/dist/plain.d.ts +36 -0
- package/dist/plain.js +291 -0
- package/dist/presenter.d.ts +144 -0
- package/dist/presenter.js +124 -0
- package/dist/prompt.d.ts +95 -0
- package/dist/prompt.js +246 -0
- package/dist/runtime.d.ts +20 -0
- package/dist/runtime.js +47 -0
- package/dist/test/cli-ui.test.d.ts +1 -0
- package/dist/test/cli-ui.test.js +230 -0
- package/dist/test/ink.test.d.ts +1 -0
- package/dist/test/ink.test.js +96 -0
- package/dist/theme.d.ts +82 -0
- package/dist/theme.js +299 -0
- package/dist/wizard.d.ts +13 -0
- package/dist/wizard.js +54 -0
- package/package.json +50 -0
package/dist/theme.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** True when ANSI styling should be emitted to the given stream. */
|
|
2
|
+
export declare function supportsColor(stream?: NodeJS.WriteStream): boolean;
|
|
3
|
+
/** True when Unicode glyphs (box-drawing, status icons) should be used. */
|
|
4
|
+
export declare function supportsUnicode(stream?: NodeJS.WriteStream): boolean;
|
|
5
|
+
type Style = (text: string) => string;
|
|
6
|
+
export declare const bold: Style;
|
|
7
|
+
export declare const dim: Style;
|
|
8
|
+
export declare const italic: Style;
|
|
9
|
+
export declare const underline: Style;
|
|
10
|
+
export declare const red: Style;
|
|
11
|
+
export declare const green: Style;
|
|
12
|
+
export declare const yellow: Style;
|
|
13
|
+
export declare const blue: Style;
|
|
14
|
+
export declare const magenta: Style;
|
|
15
|
+
export declare const cyan: Style;
|
|
16
|
+
export declare const gray: Style;
|
|
17
|
+
/** Status glyphs, with ASCII fallbacks on dumb terminals. */
|
|
18
|
+
export declare const glyph: {
|
|
19
|
+
tick: () => "✔" | "[ok]";
|
|
20
|
+
cross: () => "✖" | "[x]";
|
|
21
|
+
bullet: () => "•" | "*";
|
|
22
|
+
arrow: () => "›" | ">";
|
|
23
|
+
pointer: () => ">" | "❯";
|
|
24
|
+
warn: () => "⚠" | "[!]";
|
|
25
|
+
pending: () => "○" | "( )";
|
|
26
|
+
checkboxOn: () => "[x]" | "◉";
|
|
27
|
+
checkboxOff: () => "◯" | "[ ]";
|
|
28
|
+
};
|
|
29
|
+
/** Frames for the in-place spinner (braille dots when Unicode is supported). */
|
|
30
|
+
export declare const SPINNER_FRAMES: readonly string[];
|
|
31
|
+
export type BrandOptions = {
|
|
32
|
+
name: string;
|
|
33
|
+
tagline: string;
|
|
34
|
+
};
|
|
35
|
+
/** Configure the product wordmark rendered by presenter banners. */
|
|
36
|
+
export declare function configureBrand(options: BrandOptions): void;
|
|
37
|
+
/** The compact one-line product header (used for sub-surfaces and fallbacks). */
|
|
38
|
+
export declare function brandHeader(subtitle?: string): string;
|
|
39
|
+
/** Remove ANSI styling so we can measure on-screen width. */
|
|
40
|
+
export declare function stripAnsi(text: string): string;
|
|
41
|
+
/** Visible (printed) width of a string, ignoring ANSI escapes. */
|
|
42
|
+
export declare function visibleWidth(text: string): number;
|
|
43
|
+
/**
|
|
44
|
+
* Greedy word-wrap that is safe for ANSI-styled text: visible width is
|
|
45
|
+
* measured without escapes, and styles that are open at a line break are
|
|
46
|
+
* closed at the end of the line and re-opened on the next, so every returned
|
|
47
|
+
* line renders standalone.
|
|
48
|
+
*/
|
|
49
|
+
export declare function wrapAnsi(text: string, width: number): string[];
|
|
50
|
+
/** Frame tones: neutral (dim) for informational boxes, error (red) for failures. */
|
|
51
|
+
export type BoxTone = "neutral" | "error";
|
|
52
|
+
/**
|
|
53
|
+
* A titled, framed block rendered as plain strings. Rounded box-drawing
|
|
54
|
+
* characters when color (≈ a unicode TTY) is on, ASCII otherwise. Lines may
|
|
55
|
+
* contain ANSI styling; widths are measured against the visible text so the
|
|
56
|
+
* frame always aligns. The box never draws wider than the terminal — content
|
|
57
|
+
* lines that would overflow are word-wrapped (ANSI-safely) instead.
|
|
58
|
+
*
|
|
59
|
+
* Deliberately NOT Ink's `<Box borderStyle>`: Ink borders only exist inside a
|
|
60
|
+
* mounted live app, while these boxes are *transcript* output — written once
|
|
61
|
+
* to the stream and left in scrollback. They must render identically from the
|
|
62
|
+
* plain presenter (non-TTY, `--quiet`, CI), from the top-level error handler
|
|
63
|
+
* after any Ink app has unmounted, and inside string-snapshot tests, none of
|
|
64
|
+
* which can mount a React tree.
|
|
65
|
+
*/
|
|
66
|
+
export declare function box(title: string, lines: string[], options?: {
|
|
67
|
+
tone?: BoxTone;
|
|
68
|
+
}): string;
|
|
69
|
+
/**
|
|
70
|
+
* Apply a left-to-right truecolor gradient across each line of `text`. Returns
|
|
71
|
+
* the text unchanged when 24-bit color is unavailable (callers pick a fallback
|
|
72
|
+
* color). Spaces are left uncolored so the escape sequences stay minimal.
|
|
73
|
+
*/
|
|
74
|
+
export declare function gradient(text: string): string;
|
|
75
|
+
/**
|
|
76
|
+
* The full-dress figlet wordmark + gradient + tagline, shown once per command
|
|
77
|
+
* as an entrance "moment". Degrades gracefully to the one-line {@link
|
|
78
|
+
* brandHeader} when color is off, the terminal is too narrow, or figlet is
|
|
79
|
+
* somehow unavailable — preserving clean output when piped or captured.
|
|
80
|
+
*/
|
|
81
|
+
export declare function brandBanner(subtitle?: string): string;
|
|
82
|
+
export {};
|
package/dist/theme.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal theming. All color/format helpers no-op when color is not supported
|
|
3
|
+
* (not a TTY, `NO_COLOR` set, or a dumb terminal), so output stays clean when
|
|
4
|
+
* piped or captured. UI is written to stderr by convention so the launched
|
|
5
|
+
* coding tool's stdout stays pristine.
|
|
6
|
+
*
|
|
7
|
+
* `figlet` is used purely for the one-shot wordmark banner; everything else is
|
|
8
|
+
* plain ANSI shared by the Ink presenter and the plain-text fallback.
|
|
9
|
+
*/
|
|
10
|
+
import figlet from "figlet";
|
|
11
|
+
import stringWidth from "string-width";
|
|
12
|
+
/** True when ANSI styling should be emitted to the given stream. */
|
|
13
|
+
export function supportsColor(stream = process.stderr) {
|
|
14
|
+
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "")
|
|
15
|
+
return false;
|
|
16
|
+
if (process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== "0")
|
|
17
|
+
return true;
|
|
18
|
+
if (process.env.TERM === "dumb")
|
|
19
|
+
return false;
|
|
20
|
+
return Boolean(stream.isTTY);
|
|
21
|
+
}
|
|
22
|
+
/** True when Unicode glyphs (box-drawing, status icons) should be used. */
|
|
23
|
+
export function supportsUnicode(stream = process.stderr) {
|
|
24
|
+
if (process.env.TERM === "dumb")
|
|
25
|
+
return false;
|
|
26
|
+
return Boolean(stream.isTTY);
|
|
27
|
+
}
|
|
28
|
+
function wrap(open, close) {
|
|
29
|
+
return (text) => (supportsColor() ? `\u001b[${open}m${text}\u001b[${close}m` : text);
|
|
30
|
+
}
|
|
31
|
+
export const bold = wrap(1, 22);
|
|
32
|
+
export const dim = wrap(2, 22);
|
|
33
|
+
export const italic = wrap(3, 23);
|
|
34
|
+
export const underline = wrap(4, 24);
|
|
35
|
+
export const red = wrap(31, 39);
|
|
36
|
+
export const green = wrap(32, 39);
|
|
37
|
+
export const yellow = wrap(33, 39);
|
|
38
|
+
export const blue = wrap(34, 39);
|
|
39
|
+
export const magenta = wrap(35, 39);
|
|
40
|
+
export const cyan = wrap(36, 39);
|
|
41
|
+
export const gray = wrap(90, 39);
|
|
42
|
+
/** Status glyphs, with ASCII fallbacks on dumb terminals. */
|
|
43
|
+
export const glyph = {
|
|
44
|
+
tick: () => (supportsUnicode() ? "✔" : "[ok]"),
|
|
45
|
+
cross: () => (supportsUnicode() ? "✖" : "[x]"),
|
|
46
|
+
bullet: () => (supportsUnicode() ? "•" : "*"),
|
|
47
|
+
arrow: () => (supportsUnicode() ? "›" : ">"),
|
|
48
|
+
pointer: () => (supportsUnicode() ? "❯" : ">"),
|
|
49
|
+
warn: () => (supportsUnicode() ? "⚠" : "[!]"),
|
|
50
|
+
pending: () => (supportsUnicode() ? "○" : "( )"),
|
|
51
|
+
checkboxOn: () => (supportsUnicode() ? "◉" : "[x]"),
|
|
52
|
+
checkboxOff: () => (supportsUnicode() ? "◯" : "[ ]")
|
|
53
|
+
};
|
|
54
|
+
/** Frames for the in-place spinner (braille dots when Unicode is supported). */
|
|
55
|
+
export const SPINNER_FRAMES = supportsUnicode()
|
|
56
|
+
? ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
57
|
+
: ["|", "/", "-", "\\"];
|
|
58
|
+
let brand = { name: "routekit", tagline: "shared routing infrastructure" };
|
|
59
|
+
/** Configure the product wordmark rendered by presenter banners. */
|
|
60
|
+
export function configureBrand(options) {
|
|
61
|
+
brand = { ...options };
|
|
62
|
+
}
|
|
63
|
+
/** The compact one-line product header (used for sub-surfaces and fallbacks). */
|
|
64
|
+
export function brandHeader(subtitle) {
|
|
65
|
+
const title = bold(cyan(brand.name));
|
|
66
|
+
const tag = dim(brand.tagline);
|
|
67
|
+
const head = `${title} ${tag}`;
|
|
68
|
+
return subtitle === undefined ? head : `${head}\n${dim(subtitle)}`;
|
|
69
|
+
}
|
|
70
|
+
const ANSI_PATTERN = /\u001b\[[0-9;]*m/g;
|
|
71
|
+
/** Remove ANSI styling so we can measure on-screen width. */
|
|
72
|
+
export function stripAnsi(text) {
|
|
73
|
+
return text.replace(ANSI_PATTERN, "");
|
|
74
|
+
}
|
|
75
|
+
/** Visible (printed) width of a string, ignoring ANSI escapes. */
|
|
76
|
+
export function visibleWidth(text) {
|
|
77
|
+
return stringWidth(stripAnsi(text));
|
|
78
|
+
}
|
|
79
|
+
/** Track open SGR codes across a sequence boundary (theme styles only). */
|
|
80
|
+
function applySgr(active, sequence) {
|
|
81
|
+
const body = sequence.slice(2, -1);
|
|
82
|
+
const drop = (codes) => {
|
|
83
|
+
for (let index = active.length - 1; index >= 0; index--) {
|
|
84
|
+
const code = active[index] ?? "";
|
|
85
|
+
if (codes.includes(code) || (codes.includes("38") && code.startsWith("38;")))
|
|
86
|
+
active.splice(index, 1);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
if (body === "" || body === "0")
|
|
90
|
+
active.length = 0;
|
|
91
|
+
else if (body === "22")
|
|
92
|
+
drop(["1", "2"]);
|
|
93
|
+
else if (body === "23")
|
|
94
|
+
drop(["3"]);
|
|
95
|
+
else if (body === "24")
|
|
96
|
+
drop(["4"]);
|
|
97
|
+
else if (body === "39")
|
|
98
|
+
drop(["31", "32", "33", "34", "35", "36", "90", "38"]);
|
|
99
|
+
else
|
|
100
|
+
active.push(body);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Greedy word-wrap that is safe for ANSI-styled text: visible width is
|
|
104
|
+
* measured without escapes, and styles that are open at a line break are
|
|
105
|
+
* closed at the end of the line and re-opened on the next, so every returned
|
|
106
|
+
* line renders standalone.
|
|
107
|
+
*/
|
|
108
|
+
export function wrapAnsi(text, width) {
|
|
109
|
+
if (width <= 0)
|
|
110
|
+
return [text];
|
|
111
|
+
const lines = [];
|
|
112
|
+
for (const paragraph of text.split("\n")) {
|
|
113
|
+
if (visibleWidth(paragraph) <= width) {
|
|
114
|
+
lines.push(paragraph);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
// Codes open at the current position; re-opened at each new line start.
|
|
118
|
+
const active = [];
|
|
119
|
+
let line = "";
|
|
120
|
+
let lineWidth = 0;
|
|
121
|
+
const endLine = () => {
|
|
122
|
+
lines.push(active.length > 0 ? `${line}\u001b[0m` : line);
|
|
123
|
+
line = active.map((code) => `\u001b[${code}m`).join("");
|
|
124
|
+
lineWidth = 0;
|
|
125
|
+
};
|
|
126
|
+
for (const word of paragraph.split(" ")) {
|
|
127
|
+
const wordWidth = visibleWidth(word);
|
|
128
|
+
if (lineWidth > 0) {
|
|
129
|
+
if (lineWidth + 1 + wordWidth <= width) {
|
|
130
|
+
line += " ";
|
|
131
|
+
lineWidth += 1;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
endLine();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (wordWidth <= width) {
|
|
138
|
+
for (const escape of word.match(ANSI_PATTERN) ?? [])
|
|
139
|
+
applySgr(active, escape);
|
|
140
|
+
line += word;
|
|
141
|
+
lineWidth += wordWidth;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
// A single word wider than the line (URL, long id): hard split.
|
|
145
|
+
let index = 0;
|
|
146
|
+
while (index < word.length) {
|
|
147
|
+
const escape = /^\u001b\[[0-9;]*m/.exec(word.slice(index));
|
|
148
|
+
if (escape !== null) {
|
|
149
|
+
applySgr(active, escape[0]);
|
|
150
|
+
line += escape[0];
|
|
151
|
+
index += escape[0].length;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (lineWidth >= width)
|
|
155
|
+
endLine();
|
|
156
|
+
line += word[index] ?? "";
|
|
157
|
+
lineWidth += 1;
|
|
158
|
+
index += 1;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (stripAnsi(line).length > 0) {
|
|
162
|
+
lines.push(active.length > 0 ? `${line}\u001b[0m` : line);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return lines;
|
|
166
|
+
}
|
|
167
|
+
/** The widest a box may draw on the current terminal (frame included). */
|
|
168
|
+
function maxBoxWidth(stream = process.stderr) {
|
|
169
|
+
const columns = stream.columns;
|
|
170
|
+
if (columns === undefined || columns <= 0)
|
|
171
|
+
return 84;
|
|
172
|
+
return Math.min(Math.max(columns, 24), 100);
|
|
173
|
+
}
|
|
174
|
+
const ROUNDED_BOX = {
|
|
175
|
+
topLeft: "╭",
|
|
176
|
+
topRight: "╮",
|
|
177
|
+
bottomLeft: "╰",
|
|
178
|
+
bottomRight: "╯",
|
|
179
|
+
horizontal: "─",
|
|
180
|
+
vertical: "│"
|
|
181
|
+
};
|
|
182
|
+
const ASCII_BOX = {
|
|
183
|
+
topLeft: "+",
|
|
184
|
+
topRight: "+",
|
|
185
|
+
bottomLeft: "+",
|
|
186
|
+
bottomRight: "+",
|
|
187
|
+
horizontal: "-",
|
|
188
|
+
vertical: "|"
|
|
189
|
+
};
|
|
190
|
+
/**
|
|
191
|
+
* A titled, framed block rendered as plain strings. Rounded box-drawing
|
|
192
|
+
* characters when color (≈ a unicode TTY) is on, ASCII otherwise. Lines may
|
|
193
|
+
* contain ANSI styling; widths are measured against the visible text so the
|
|
194
|
+
* frame always aligns. The box never draws wider than the terminal — content
|
|
195
|
+
* lines that would overflow are word-wrapped (ANSI-safely) instead.
|
|
196
|
+
*
|
|
197
|
+
* Deliberately NOT Ink's `<Box borderStyle>`: Ink borders only exist inside a
|
|
198
|
+
* mounted live app, while these boxes are *transcript* output — written once
|
|
199
|
+
* to the stream and left in scrollback. They must render identically from the
|
|
200
|
+
* plain presenter (non-TTY, `--quiet`, CI), from the top-level error handler
|
|
201
|
+
* after any Ink app has unmounted, and inside string-snapshot tests, none of
|
|
202
|
+
* which can mount a React tree.
|
|
203
|
+
*/
|
|
204
|
+
export function box(title, lines, options = {}) {
|
|
205
|
+
const chars = supportsUnicode() ? ROUNDED_BOX : ASCII_BOX;
|
|
206
|
+
const frame = options.tone === "error" ? red : dim;
|
|
207
|
+
const titleText = options.tone === "error" ? bold(red(title)) : bold(title);
|
|
208
|
+
// "│ " + content + " │" -> the frame consumes 4 columns.
|
|
209
|
+
const maxContent = maxBoxWidth() - 4;
|
|
210
|
+
const wrapped = lines.flatMap((line) => {
|
|
211
|
+
if (visibleWidth(line) <= maxContent)
|
|
212
|
+
return [line];
|
|
213
|
+
// Indent wrapped continuations so they read as part of the first line.
|
|
214
|
+
return wrapAnsi(line, maxContent - 2).map((part, index) => (index === 0 ? part : ` ${part}`));
|
|
215
|
+
});
|
|
216
|
+
const contentWidth = Math.min(Math.max(visibleWidth(titleText), ...wrapped.map(visibleWidth), 0), maxContent);
|
|
217
|
+
const inner = contentWidth + 2; // one space of padding each side
|
|
218
|
+
// Build with un-nested styles: color the frame pieces, bold only the title,
|
|
219
|
+
// so a bold reset (SGR 22) never prematurely closes a surrounding style.
|
|
220
|
+
const titleRule = chars.horizontal.repeat(Math.max(0, inner - visibleWidth(titleText) - 3));
|
|
221
|
+
const top = `${frame(`${chars.topLeft}${chars.horizontal} `)}${titleText}${frame(` ${titleRule}${chars.topRight}`)}`;
|
|
222
|
+
const body = wrapped.map((line) => {
|
|
223
|
+
const pad = " ".repeat(Math.max(0, contentWidth - visibleWidth(line)));
|
|
224
|
+
return `${frame(chars.vertical)} ${line}${pad} ${frame(chars.vertical)}`;
|
|
225
|
+
});
|
|
226
|
+
const bottom = frame(`${chars.bottomLeft}${chars.horizontal.repeat(inner)}${chars.bottomRight}`);
|
|
227
|
+
return [top, ...body, bottom].join("\n");
|
|
228
|
+
}
|
|
229
|
+
/** True when the terminal advertises 24-bit color (needed for the gradient). */
|
|
230
|
+
function supportsTrueColor() {
|
|
231
|
+
if (!supportsColor())
|
|
232
|
+
return false;
|
|
233
|
+
const colorterm = process.env.COLORTERM ?? "";
|
|
234
|
+
return colorterm.includes("truecolor") || colorterm.includes("24bit");
|
|
235
|
+
}
|
|
236
|
+
/** Gradient endpoints: cyan-400 -> fuchsia-500, for the "magical" wordmark. */
|
|
237
|
+
const GRADIENT_FROM = [34, 211, 238];
|
|
238
|
+
const GRADIENT_TO = [217, 70, 239];
|
|
239
|
+
function mix(from, to, t) {
|
|
240
|
+
return Math.round(from + (to - from) * t);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Apply a left-to-right truecolor gradient across each line of `text`. Returns
|
|
244
|
+
* the text unchanged when 24-bit color is unavailable (callers pick a fallback
|
|
245
|
+
* color). Spaces are left uncolored so the escape sequences stay minimal.
|
|
246
|
+
*/
|
|
247
|
+
export function gradient(text) {
|
|
248
|
+
if (!supportsTrueColor())
|
|
249
|
+
return text;
|
|
250
|
+
const lines = text.split("\n");
|
|
251
|
+
const width = Math.max(1, ...lines.map((line) => line.length));
|
|
252
|
+
return lines
|
|
253
|
+
.map((line) => {
|
|
254
|
+
let out = "";
|
|
255
|
+
for (let i = 0; i < line.length; i++) {
|
|
256
|
+
const ch = line[i] ?? "";
|
|
257
|
+
if (ch === " ") {
|
|
258
|
+
out += ch;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const t = width <= 1 ? 0 : i / (width - 1);
|
|
262
|
+
const r = mix(GRADIENT_FROM[0], GRADIENT_TO[0], t);
|
|
263
|
+
const g = mix(GRADIENT_FROM[1], GRADIENT_TO[1], t);
|
|
264
|
+
const b = mix(GRADIENT_FROM[2], GRADIENT_TO[2], t);
|
|
265
|
+
out += `\u001b[38;2;${r};${g};${b}m${ch}`;
|
|
266
|
+
}
|
|
267
|
+
return out + "\u001b[39m";
|
|
268
|
+
})
|
|
269
|
+
.join("\n");
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* The full-dress figlet wordmark + gradient + tagline, shown once per command
|
|
273
|
+
* as an entrance "moment". Degrades gracefully to the one-line {@link
|
|
274
|
+
* brandHeader} when color is off, the terminal is too narrow, or figlet is
|
|
275
|
+
* somehow unavailable — preserving clean output when piped or captured.
|
|
276
|
+
*/
|
|
277
|
+
export function brandBanner(subtitle) {
|
|
278
|
+
const fallback = brandHeader(subtitle);
|
|
279
|
+
if (!supportsColor())
|
|
280
|
+
return fallback;
|
|
281
|
+
let art;
|
|
282
|
+
try {
|
|
283
|
+
art = figlet.textSync(brand.name, { font: "ANSI Shadow" });
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return fallback;
|
|
287
|
+
}
|
|
288
|
+
// Drop trailing blank lines figlet pads with, keep the shape otherwise.
|
|
289
|
+
const lines = art.replace(/[\s\n]+$/u, "").split("\n");
|
|
290
|
+
const widest = Math.max(0, ...lines.map((line) => line.length));
|
|
291
|
+
const columns = process.stderr.columns;
|
|
292
|
+
if (columns !== undefined && widest > columns)
|
|
293
|
+
return fallback;
|
|
294
|
+
const trimmed = lines.join("\n");
|
|
295
|
+
const wordmark = supportsTrueColor() ? gradient(trimmed) : bold(cyan(trimmed));
|
|
296
|
+
const tag = dim(brand.tagline);
|
|
297
|
+
const sub = subtitle !== undefined ? `\n${dim(subtitle)}` : "";
|
|
298
|
+
return `${wordmark}\n${tag}${sub}`;
|
|
299
|
+
}
|
package/dist/wizard.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Back } from "./prompt.js";
|
|
2
|
+
export type WizardStep<S> = {
|
|
3
|
+
id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
/** Run the step: return the next state, or BACK to go back one step. */
|
|
6
|
+
run: (state: S) => Promise<S | Back>;
|
|
7
|
+
/** When true for the current state the step is skipped (both directions). */
|
|
8
|
+
skip?: (state: S) => boolean;
|
|
9
|
+
};
|
|
10
|
+
export declare function runWizard<S>(input: {
|
|
11
|
+
steps: ReadonlyArray<WizardStep<S>>;
|
|
12
|
+
initial: S;
|
|
13
|
+
}): Promise<S>;
|
package/dist/wizard.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tiny wizard runner: ordered steps over a shared state object, with Esc
|
|
3
|
+
* back-navigation (steps receive prompts created with `allowBack: true` and
|
|
4
|
+
* return {@link BACK} to go back one step). Each step prints a dim
|
|
5
|
+
* `step N/M · title` header so a finished wizard reads as a transcript of the
|
|
6
|
+
* journey; skipped steps disappear from the count.
|
|
7
|
+
*/
|
|
8
|
+
import { BACK } from "./prompt.js";
|
|
9
|
+
import { isInteractive, uiStream } from "./runtime.js";
|
|
10
|
+
import { dim } from "./theme.js";
|
|
11
|
+
export async function runWizard(input) {
|
|
12
|
+
const out = uiStream();
|
|
13
|
+
let state = input.initial;
|
|
14
|
+
// Snapshots of the state as it was when each step started, so going back
|
|
15
|
+
// replays from exactly where that step began.
|
|
16
|
+
const snapshots = [];
|
|
17
|
+
let index = 0;
|
|
18
|
+
while (index < input.steps.length) {
|
|
19
|
+
const step = input.steps[index];
|
|
20
|
+
if (step === undefined)
|
|
21
|
+
break;
|
|
22
|
+
if (step.skip !== undefined && step.skip(state)) {
|
|
23
|
+
snapshots[index] = state;
|
|
24
|
+
index += 1;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const active = input.steps.filter((candidate) => candidate.skip === undefined || !candidate.skip(state));
|
|
28
|
+
const position = active.indexOf(step);
|
|
29
|
+
if (position >= 0 && active.length > 1 && isInteractive()) {
|
|
30
|
+
out.write(`${dim(`step ${position + 1}/${active.length} · ${step.title} ${position > 0 ? "(esc goes back)" : ""}`.trimEnd())}\n`);
|
|
31
|
+
}
|
|
32
|
+
snapshots[index] = state;
|
|
33
|
+
const result = await step.run(state);
|
|
34
|
+
if (result === BACK) {
|
|
35
|
+
// Walk back past skipped steps; stay on the first step when already there.
|
|
36
|
+
let previous = index - 1;
|
|
37
|
+
while (previous >= 0) {
|
|
38
|
+
const candidate = input.steps[previous];
|
|
39
|
+
const snapshot = snapshots[previous];
|
|
40
|
+
if (candidate !== undefined && snapshot !== undefined && (candidate.skip === undefined || !candidate.skip(snapshot)))
|
|
41
|
+
break;
|
|
42
|
+
previous -= 1;
|
|
43
|
+
}
|
|
44
|
+
if (previous >= 0) {
|
|
45
|
+
index = previous;
|
|
46
|
+
state = snapshots[previous];
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
state = result;
|
|
51
|
+
index += 1;
|
|
52
|
+
}
|
|
53
|
+
return state;
|
|
54
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@velum-labs/routekit-cli-ui",
|
|
3
|
+
"private": false,
|
|
4
|
+
"version": "0.9.0",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/velum-labs/handoffkit.git",
|
|
8
|
+
"directory": "packages/cli-ui"
|
|
9
|
+
},
|
|
10
|
+
"description": "Brand-configurable terminal UX: an Ink presenter, prompt facade, and plain-text fallback.",
|
|
11
|
+
"license": "Apache-2.0",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"registry": "https://registry.npmjs.org",
|
|
25
|
+
"access": "public",
|
|
26
|
+
"provenance": true
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"figlet": "1.11.0",
|
|
30
|
+
"ink": "7.1.0",
|
|
31
|
+
"react": "19.2.7",
|
|
32
|
+
"string-width": "8.2.1"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/figlet": "1.7.0",
|
|
36
|
+
"@types/react": "19.2.17",
|
|
37
|
+
"ink-testing-library": "4.0.0"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"routekit",
|
|
41
|
+
"terminal-ui",
|
|
42
|
+
"ink",
|
|
43
|
+
"prompts"
|
|
44
|
+
],
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc -b",
|
|
47
|
+
"clean": "tsc -b --clean",
|
|
48
|
+
"test": "node --test \"dist/test/*.test.js\""
|
|
49
|
+
}
|
|
50
|
+
}
|