@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 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="docs/assets/screens-demo.mov" title="Ind3x CLI Screens Demo"></video>
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.0.0",
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
  },