@ind3x/cli-screens 1.0.0 → 1.1.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 +45 -1
- package/dist/index.d.ts +41 -0
- package/dist/index.js +111 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A small, zero-dependency, typescript-first, screen stack library intended to mimick the flow of game screens in interactive CLI applications.
|
|
4
4
|
|
|
5
|
-
<video controls src="
|
|
5
|
+
<video controls src="https://github.com/user-attachments/assets/7dbeb7e3-1fba-4b10-94ef-65da7c8e8496" title="Ind3x CLI Screens Demo"></video>
|
|
6
6
|
|
|
7
7
|
The library is architected around four core concepts:
|
|
8
8
|
|
|
@@ -15,10 +15,13 @@ The library is architected around four core concepts:
|
|
|
15
15
|
|
|
16
16
|
- **Stack based navigation:** push, pop, replace, reset, or exit screens while passing typed results back through promises.
|
|
17
17
|
- **Composable flows:** combine built in screens without custom implementations:
|
|
18
|
+
- `confirm()` asks a yes-or-no question and returns a boolean.
|
|
18
19
|
- `select()` presents typed choices and returns the selected value.
|
|
19
20
|
- `menu()` presents choices with an optional Back action and selection callback.
|
|
20
21
|
- `textInput()` collects, validates, and optionally masks text.
|
|
21
22
|
- `message()` displays content until a configured key dismisses it.
|
|
23
|
+
- `paginate()` displays long text in terminal-sized pages.
|
|
24
|
+
- `spinner()` displays an animated status indicator.
|
|
22
25
|
- `typewriter()` animates text and optionally dismisses itself when finished.
|
|
23
26
|
- `sequence()` displays screens in order, including lazily created steps.
|
|
24
27
|
- `withTask()` displays a screen while a typed asynchronous task runs.
|
|
@@ -59,6 +62,8 @@ Other examples and use cases can be found in `docs/recipes/`:
|
|
|
59
62
|
- [Using a shared context](docs/recipes/shared-context.ts)
|
|
60
63
|
- [Menu screen from async data](docs/recipes/async-menu-data.ts)
|
|
61
64
|
- [Using `withTask()` hooks](docs/recipes/with-task.ts)
|
|
65
|
+
- [Paginating long text](docs/recipes/paginate.ts)
|
|
66
|
+
- [Displaying a spinner during a task](docs/recipes/spinner.ts)
|
|
62
67
|
- [Passing input forward to the next screen](docs/recipes/pass-input-forward.ts)
|
|
63
68
|
- [Retrieving results from `push()` and `back()`](docs/recipes/push-back-results.ts)
|
|
64
69
|
|
|
@@ -108,6 +113,21 @@ const port = await navigation.push(
|
|
|
108
113
|
|
|
109
114
|
`menu()` adds a selectable `Back` action after its choices. Set `backLabel` to rename it or `backLabel: false` to hide it, such as on a root menu that already has an explicit exit action. Escape and Backspace also continue to navigate back.
|
|
110
115
|
|
|
116
|
+
### Confirm
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
const approved = await navigation.push(
|
|
120
|
+
confirm({
|
|
121
|
+
message: "Save your changes?",
|
|
122
|
+
confirmLabel: "Save",
|
|
123
|
+
cancelLabel: "Discard",
|
|
124
|
+
initialValue: true,
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`confirm()` returns `true` or `false`. Escape and Backspace return `false`.
|
|
130
|
+
|
|
111
131
|
### Text input
|
|
112
132
|
|
|
113
133
|
```ts
|
|
@@ -134,6 +154,30 @@ await navigation.push(
|
|
|
134
154
|
);
|
|
135
155
|
```
|
|
136
156
|
|
|
157
|
+
### Paginate
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
await navigation.push(
|
|
161
|
+
paginate({
|
|
162
|
+
title: "Release notes",
|
|
163
|
+
text: releaseNotes,
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`paginate()` wraps text to the terminal width and divides it according to the available terminal height. Use the arrow keys to change pages and Escape or Backspace to return.
|
|
169
|
+
|
|
170
|
+
### Spinner
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
const loading = withTask(
|
|
174
|
+
spinner({ text: "Loading users..." }),
|
|
175
|
+
({ signal }) => api.loadUsers({ signal }),
|
|
176
|
+
);
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`spinner()` is a visual primitive and does not manage asynchronous work itself. Compose it with `withTask()` so the task owns the screen's lifetime.
|
|
180
|
+
|
|
137
181
|
### Typewriter
|
|
138
182
|
|
|
139
183
|
```ts
|
package/dist/index.d.ts
CHANGED
|
@@ -208,6 +208,19 @@ export interface DecoratedText {
|
|
|
208
208
|
* Text using default presentation, or text with explicit presentation options.
|
|
209
209
|
*/
|
|
210
210
|
export type TextContent = string | DecoratedText;
|
|
211
|
+
export interface ConfirmOptions {
|
|
212
|
+
title?: TextContent;
|
|
213
|
+
message: TextContent;
|
|
214
|
+
confirmLabel?: TextContent;
|
|
215
|
+
cancelLabel?: TextContent;
|
|
216
|
+
initialValue?: boolean;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Asks the user to confirm or cancel an action.
|
|
220
|
+
*
|
|
221
|
+
* Escape and Backspace always return `false`.
|
|
222
|
+
*/
|
|
223
|
+
declare function confirm$1<Context = any>(options: ConfirmOptions | TextContent): Screen<Context, boolean>;
|
|
211
224
|
export interface Choice<Value> {
|
|
212
225
|
label: TextContent;
|
|
213
226
|
value: Value;
|
|
@@ -240,6 +253,17 @@ export interface MessageOptions {
|
|
|
240
253
|
dismissKeys?: readonly DismissKey[];
|
|
241
254
|
}
|
|
242
255
|
export declare function message(options: MessageOptions | TextContent): Screen<any, void>;
|
|
256
|
+
export interface PaginateOptions {
|
|
257
|
+
title?: TextContent;
|
|
258
|
+
text: TextContent;
|
|
259
|
+
initialPage?: number;
|
|
260
|
+
hint?: string;
|
|
261
|
+
cancelKeys?: readonly ("escape" | "backspace")[];
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Displays long text one terminal-sized page at a time.
|
|
265
|
+
*/
|
|
266
|
+
export declare function paginate<Context = any>(options: PaginateOptions | TextContent): Screen<Context, void>;
|
|
243
267
|
/**
|
|
244
268
|
* A screen to display in a sequence, or a factory that creates one when its
|
|
245
269
|
* turn begins.
|
|
@@ -262,6 +286,19 @@ export type ScreenSource<Context> = Screen<Context, any> | (() => Screen<Context
|
|
|
262
286
|
* ```
|
|
263
287
|
*/
|
|
264
288
|
export declare function sequence<Context = any>(sources: readonly ScreenSource<Context>[]): Screen<Context, void>;
|
|
289
|
+
export interface SpinnerOptions {
|
|
290
|
+
title?: TextContent;
|
|
291
|
+
text?: TextContent;
|
|
292
|
+
frames?: readonly string[];
|
|
293
|
+
intervalMs?: number;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Displays an animated status indicator.
|
|
297
|
+
*
|
|
298
|
+
* Use with `withTask()` when the spinner should remain visible for the
|
|
299
|
+
* lifetime of asynchronous work.
|
|
300
|
+
*/
|
|
301
|
+
export declare function spinner(options?: SpinnerOptions | TextContent): Screen<any, void>;
|
|
265
302
|
export interface TextInputOptions {
|
|
266
303
|
message?: TextContent;
|
|
267
304
|
initialValue?: string;
|
|
@@ -309,4 +346,8 @@ export type ScreenTask<Context, Result> = (environment: TaskEnvironment<Context>
|
|
|
309
346
|
*/
|
|
310
347
|
export declare function withTask<Context = any, Result = void>(screen: Screen<Context, any>, task: ScreenTask<Context, Result>): Screen<Context, Result>;
|
|
311
348
|
|
|
349
|
+
export {
|
|
350
|
+
confirm$1 as confirm,
|
|
351
|
+
};
|
|
352
|
+
|
|
312
353
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -2275,6 +2275,7 @@ function createCli(options) {
|
|
|
2275
2275
|
function defineScreen(screen) {
|
|
2276
2276
|
return screen;
|
|
2277
2277
|
}
|
|
2278
|
+
|
|
2278
2279
|
// src/renderer/text-content.ts
|
|
2279
2280
|
function normalizeText(content, defaults = {}) {
|
|
2280
2281
|
return typeof content === "string" ? { align: "left", tone: "default", ...defaults, value: content } : { align: "left", tone: "default", ...defaults, ...content };
|
|
@@ -2427,6 +2428,36 @@ function move(choices, current, direction, loop) {
|
|
|
2427
2428
|
return current;
|
|
2428
2429
|
}
|
|
2429
2430
|
|
|
2431
|
+
// src/screen/confirm.ts
|
|
2432
|
+
function confirm(options) {
|
|
2433
|
+
const settings = typeof options === "string" || "value" in options ? { message: options } : options;
|
|
2434
|
+
const selection = select({
|
|
2435
|
+
choices: [
|
|
2436
|
+
{ label: settings.confirmLabel ?? "Yes", value: true },
|
|
2437
|
+
{ label: settings.cancelLabel ?? "No", value: false }
|
|
2438
|
+
],
|
|
2439
|
+
initialIndex: settings.initialValue ?? true ? 0 : 1,
|
|
2440
|
+
cancelKeys: []
|
|
2441
|
+
});
|
|
2442
|
+
return defineScreen({
|
|
2443
|
+
render(environment) {
|
|
2444
|
+
if (settings.title) {
|
|
2445
|
+
renderText(environment.ui, settings.title, { tone: "accent" });
|
|
2446
|
+
environment.ui.blank();
|
|
2447
|
+
}
|
|
2448
|
+
renderText(environment.ui, settings.message);
|
|
2449
|
+
environment.ui.blank();
|
|
2450
|
+
selection.render(environment);
|
|
2451
|
+
},
|
|
2452
|
+
key(event, environment) {
|
|
2453
|
+
if (event.key === "escape" || event.key === "backspace") {
|
|
2454
|
+
environment.navigation.back(false);
|
|
2455
|
+
return;
|
|
2456
|
+
}
|
|
2457
|
+
selection.key?.(event, environment);
|
|
2458
|
+
}
|
|
2459
|
+
});
|
|
2460
|
+
}
|
|
2430
2461
|
// src/screen/menu.ts
|
|
2431
2462
|
var backAction = Symbol("back");
|
|
2432
2463
|
function menu(options) {
|
|
@@ -2487,6 +2518,50 @@ function message(options) {
|
|
|
2487
2518
|
}
|
|
2488
2519
|
});
|
|
2489
2520
|
}
|
|
2521
|
+
// src/screen/paginate.ts
|
|
2522
|
+
function paginate(options) {
|
|
2523
|
+
const settings = typeof options === "string" || "value" in options ? { text: options } : options;
|
|
2524
|
+
const body = normalizeText(settings.text);
|
|
2525
|
+
let currentPage = Math.max(0, Math.floor(settings.initialPage ?? 0));
|
|
2526
|
+
let pageCount = 1;
|
|
2527
|
+
return defineScreen({
|
|
2528
|
+
render({ ui }) {
|
|
2529
|
+
const titleRows = settings.title ? wrapLines(normalizeText(settings.title).value, ui.width).length + 1 : 0;
|
|
2530
|
+
const rowsPerPage = Math.max(1, ui.height - titleRows - 2);
|
|
2531
|
+
const lines = wrapLines(body.value, ui.width);
|
|
2532
|
+
pageCount = Math.max(1, Math.ceil(lines.length / rowsPerPage));
|
|
2533
|
+
currentPage = Math.min(currentPage, pageCount - 1);
|
|
2534
|
+
if (settings.title) {
|
|
2535
|
+
renderText(ui, settings.title, { tone: "accent" });
|
|
2536
|
+
ui.blank();
|
|
2537
|
+
}
|
|
2538
|
+
const start = currentPage * rowsPerPage;
|
|
2539
|
+
const visibleLines = lines.slice(start, start + rowsPerPage);
|
|
2540
|
+
for (const line of visibleLines) {
|
|
2541
|
+
ui.text(alignText(line, body.align, ui.width), { tone: body.tone });
|
|
2542
|
+
}
|
|
2543
|
+
ui.blank();
|
|
2544
|
+
ui.columns(`Page ${currentPage + 1} of ${pageCount}`, settings.hint ?? "←/→ page · Esc return", { tone: "muted" });
|
|
2545
|
+
},
|
|
2546
|
+
key(event, { navigation, requestRender }) {
|
|
2547
|
+
const cancelKeys = settings.cancelKeys ?? ["escape", "backspace"];
|
|
2548
|
+
if (cancelKeys.includes(event.key)) {
|
|
2549
|
+
navigation.back();
|
|
2550
|
+
return;
|
|
2551
|
+
}
|
|
2552
|
+
const direction = event.key === "right" || event.key === "down" ? 1 : event.key === "left" || event.key === "up" ? -1 : 0;
|
|
2553
|
+
const nextPage = Math.max(0, Math.min(pageCount - 1, currentPage + direction));
|
|
2554
|
+
if (nextPage !== currentPage) {
|
|
2555
|
+
currentPage = nextPage;
|
|
2556
|
+
requestRender();
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
});
|
|
2560
|
+
}
|
|
2561
|
+
function wrapLines(value, width) {
|
|
2562
|
+
return value.split(`
|
|
2563
|
+
`).flatMap((line) => wrap(line, width));
|
|
2564
|
+
}
|
|
2490
2565
|
// src/screen/sequence.ts
|
|
2491
2566
|
function sequence(sources) {
|
|
2492
2567
|
return defineScreen({
|
|
@@ -2505,6 +2580,39 @@ function sequence(sources) {
|
|
|
2505
2580
|
render() {}
|
|
2506
2581
|
});
|
|
2507
2582
|
}
|
|
2583
|
+
// src/screen/spinner.ts
|
|
2584
|
+
var defaultFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
2585
|
+
function spinner(options = {}) {
|
|
2586
|
+
const settings = typeof options === "string" || "value" in options ? { text: options } : options;
|
|
2587
|
+
const body = normalizeText(settings.text ?? "Working...");
|
|
2588
|
+
const frames = settings.frames && settings.frames.length > 0 ? settings.frames : defaultFrames;
|
|
2589
|
+
const intervalMs = Math.max(1, settings.intervalMs ?? 80);
|
|
2590
|
+
let frameIndex = 0;
|
|
2591
|
+
let timer;
|
|
2592
|
+
return defineScreen({
|
|
2593
|
+
mount({ requestRender, signal }) {
|
|
2594
|
+
timer = setInterval(() => {
|
|
2595
|
+
frameIndex = (frameIndex + 1) % frames.length;
|
|
2596
|
+
requestRender();
|
|
2597
|
+
}, intervalMs);
|
|
2598
|
+
signal.addEventListener("abort", () => {
|
|
2599
|
+
if (timer) {
|
|
2600
|
+
clearInterval(timer);
|
|
2601
|
+
}
|
|
2602
|
+
timer = undefined;
|
|
2603
|
+
}, { once: true });
|
|
2604
|
+
},
|
|
2605
|
+
render({ ui }) {
|
|
2606
|
+
if (settings.title) {
|
|
2607
|
+
renderText(ui, settings.title, { tone: "accent" });
|
|
2608
|
+
ui.blank();
|
|
2609
|
+
}
|
|
2610
|
+
const frame = frames[frameIndex] ?? "";
|
|
2611
|
+
const text = `${frame} ${body.value}`;
|
|
2612
|
+
ui.text(alignText(text, body.align, ui.width), { tone: body.tone });
|
|
2613
|
+
}
|
|
2614
|
+
});
|
|
2615
|
+
}
|
|
2508
2616
|
// src/screen/text-input.ts
|
|
2509
2617
|
function textInput(options = {}) {
|
|
2510
2618
|
let value = options.initialValue ?? "";
|
|
@@ -2724,11 +2832,14 @@ export {
|
|
|
2724
2832
|
withTask,
|
|
2725
2833
|
typewriter,
|
|
2726
2834
|
textInput,
|
|
2835
|
+
spinner,
|
|
2727
2836
|
sequence,
|
|
2728
2837
|
select,
|
|
2838
|
+
paginate,
|
|
2729
2839
|
message,
|
|
2730
2840
|
menu,
|
|
2731
2841
|
defineScreen,
|
|
2732
2842
|
createCli,
|
|
2843
|
+
confirm,
|
|
2733
2844
|
Ui
|
|
2734
2845
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ind3x/cli-screens",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"homepage": "https://ind3x.games",
|
|
4
5
|
"packageManager": "bun@1.3.14",
|
|
5
6
|
"author": "contact@ind3x.games",
|
|
6
7
|
"description": "A small screen stack library for interactive CLI applications",
|
|
@@ -31,7 +32,7 @@
|
|
|
31
32
|
},
|
|
32
33
|
"scripts": {
|
|
33
34
|
"build": "bun run build.ts",
|
|
34
|
-
"test": "bun run build && bun test",
|
|
35
|
+
"test": "bun run build && bun test --pass-with-no-tests",
|
|
35
36
|
"prepublishOnly": "bun run build",
|
|
36
37
|
"check": "bun pm pack --dry-run"
|
|
37
38
|
},
|