@ind3x/cli-screens 1.0.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 ADDED
@@ -0,0 +1,230 @@
1
+ # @ind3x/cli-screens
2
+
3
+ A small, zero-dependency, typescript-first, screen stack library intended to mimick the flow of game screens in interactive CLI applications.
4
+
5
+ <video controls src="docs/assets/screens-demo.mov" title="Ind3x CLI Screens Demo"></video>
6
+
7
+ The library is architected around four core concepts:
8
+
9
+ - **Composition over inheritance:** build flows from factories, `sequence()`, and `withTask()`.
10
+ - **Flexibility:** pass typed context and results through navigation, and use `defineScreen()` for custom implementations.
11
+ - **Ease of use:** prefer small factory functions with useful defaults and no required inheritance.
12
+ - **Opinionated behavior:** screens render complete frames, keyboard hooks stay synchronous, asynchronous work belongs in `mount()` or `withTask()`, and an `AbortSignal` handles cancellation.
13
+
14
+ ### Features
15
+
16
+ - **Stack based navigation:** push, pop, replace, reset, or exit screens while passing typed results back through promises.
17
+ - **Composable flows:** combine built in screens without custom implementations:
18
+ - `select()` presents typed choices and returns the selected value.
19
+ - `menu()` presents choices with an optional Back action and selection callback.
20
+ - `textInput()` collects, validates, and optionally masks text.
21
+ - `message()` displays content until a configured key dismisses it.
22
+ - `typewriter()` animates text and optionally dismisses itself when finished.
23
+ - `sequence()` displays screens in order, including lazily created steps.
24
+ - `withTask()` displays a screen while a typed asynchronous task runs.
25
+ - **Asynchronous tasks:** display a screen while background work runs.
26
+ - **Shared application context:** make application state available to every screen.
27
+ - **Custom screens:** implement your own lifecycle, rendering, and keyboard hooks.
28
+ - **Styled terminal output:** align text and apply tones to titles, hints, labels, descriptions, prompts, placeholders, and validation messages.
29
+ - **Responsive display:** automatically scroll long choice lists, redraw on size changes.
30
+
31
+ ## Install
32
+
33
+ ```sh
34
+ bun install @ind3x/cli-screens
35
+ ```
36
+
37
+ ## Quick Start
38
+
39
+ ```ts
40
+ import { createCli, message, sequence, typewriter } from '@ind3x/cli-screens';
41
+
42
+ const app = createCli();
43
+
44
+ const start = sequence([
45
+ typewriter({
46
+ text: 'Loading...',
47
+ charactersPerSecond: 5,
48
+ autoDismiss: true
49
+ }),
50
+ message(`Hello, world!`),
51
+ ]);
52
+
53
+ await app.run(start);
54
+ ```
55
+
56
+ Other examples and use cases can be found in `docs/recipes/`:
57
+
58
+ - [Basic menu selection screen](docs/recipes/basic-menu-selection.ts)
59
+ - [Using a shared context](docs/recipes/shared-context.ts)
60
+ - [Menu screen from async data](docs/recipes/async-menu-data.ts)
61
+ - [Using `withTask()` hooks](docs/recipes/with-task.ts)
62
+ - [Passing input forward to the next screen](docs/recipes/pass-input-forward.ts)
63
+ - [Retrieving results from `push()` and `back()`](docs/recipes/push-back-results.ts)
64
+
65
+ `run()` owns terminal setup and cleanup. Ctrl-C and `navigation.exit()` clear the final frame and restore the terminal before resolving the returned promise.
66
+
67
+ ## Screen Navigation
68
+
69
+ - `await navigation.push(screen)` opens a child and resolves with the value it passes to `back()`.
70
+ - `navigation.back(value?)` closes the current screen. At the root, it exits.
71
+ - `navigation.replace(screen)` replaces the current screen. A caller awaiting the original screen receives the replacement screen's eventual result.
72
+ - `navigation.reset(screen)` atomically replaces the complete stack.
73
+ - `navigation.exit(value?)` exits the app and resolves `run()`.
74
+
75
+ Pass input forward through screen factory arguments, return results through `push()`/`back()`, and place application-wide services in the typed app context.
76
+
77
+ ## Additional text styling
78
+
79
+ Display text can be decorated:
80
+
81
+ ```ts
82
+ {
83
+ value: 'Centered notice',
84
+ align: 'center',
85
+ tone: 'accent',
86
+ }
87
+ ```
88
+
89
+ This applies to titles, hints, message text, selection labels and descriptions, text-input prompts and placeholders, validation messages, and typewriter text. Plain strings retain each component's opinionated default presentation.
90
+
91
+ Alongside the semantic `default`, `accent`, and `muted` tones, the standard ANSI foreground colors are available: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, and their `brightBlack` through `brightWhite` variants.
92
+
93
+ ### Select
94
+
95
+ ```ts
96
+ const port = await navigation.push(
97
+ select<T>({
98
+ title: "Port",
99
+ choices: [
100
+ { label: "Development", value: 3000 },
101
+ { label: "Production", value: 8080 },
102
+ ],
103
+ }),
104
+ );
105
+ ```
106
+
107
+ `select<T>()` returns the choice value as `T`. Choices may include a `description` or be `disabled`. Long lists automatically scroll to keep the selected choice visible, using the terminal height and indicators for choices above or below the viewport. Set `maxVisible` to impose a smaller item limit.
108
+
109
+ `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
+
111
+ ### Text input
112
+
113
+ ```ts
114
+ const token = await navigation.push(
115
+ textInput({
116
+ message: "API token",
117
+ mask: true,
118
+ validate: (value) => (value.length < 10 ? "Token is too short" : undefined),
119
+ }),
120
+ );
121
+ ```
122
+
123
+ Escape cancels and produces `undefined`.
124
+
125
+ ### Message
126
+
127
+ ```ts
128
+ await navigation.push(
129
+ message({
130
+ title: "Saved",
131
+ text: "Configuration written.",
132
+ dismissKeys: ["any"],
133
+ }),
134
+ );
135
+ ```
136
+
137
+ ### Typewriter
138
+
139
+ ```ts
140
+ await navigation.push(
141
+ typewriter({
142
+ title: {
143
+ value: "Introduction",
144
+ align: "center",
145
+ tone: "accent",
146
+ },
147
+ text: {
148
+ value: "Your adventure begins...",
149
+ align: "left",
150
+ },
151
+ charactersPerSecond: 30,
152
+ autoDismiss: true,
153
+ }),
154
+ );
155
+ ```
156
+
157
+ Animated text is positioned using its completed width, so it does not shift while characters appear.
158
+
159
+ By default, typing can be completed early with a key press and the screen then prompts for dismissal. Set `autoDismiss: true` to ignore input and close the screen automatically as soon as typing finishes.
160
+
161
+ ### Sequence
162
+
163
+ Use `sequence()` to display several screens in order without writing a custom controller screen:
164
+
165
+ ```ts
166
+ await app.run(
167
+ sequence([typewriter({ text: "Welcome to Ind3x" }), () => gameMenuScreen()]),
168
+ );
169
+ ```
170
+
171
+ Each screen advances when it navigates back. A function creates its screen only when that step begins. Exiting the app or removing the sequence prevents later factories from running.
172
+
173
+ ### With task
174
+
175
+ Use `withTask()` to display any screen while a typed asynchronous task runs:
176
+
177
+ ```ts
178
+ const loading = withTask<AppContext, User[]>(
179
+ typewriter({ text: "Loading users..." }),
180
+ async ({ context, signal }) => {
181
+ return context.api.loadUsers({ signal });
182
+ },
183
+ );
184
+
185
+ const users = await navigation.push(loading);
186
+ ```
187
+
188
+ The task owns the decorated screen's lifetime. The visual screen may push a temporary child, but it cannot pop, replace, reset, or exit the decorated flow. Its asynchronous `mount()` work does not delay the task. Successful completion closes the decorator with the task result. Removing the decorator aborts the signal passed to the task. Task and visual-mount errors use the app's normal `onError` behavior or reject `app.run()`.
189
+
190
+ ## Custom screens
191
+
192
+ Use `defineScreen()` to get contextual typing without inheritance:
193
+
194
+ ```ts
195
+ import { defineScreen, type Screen } from "@ind3x/cli-screens";
196
+
197
+ type AppContext = {
198
+ api: ApiClient;
199
+ };
200
+
201
+ const loadingScreen: Screen<AppContext> = defineScreen<AppContext>({
202
+ async mount({ context, navigation, signal }) {
203
+ const games = await context.api.games({ signal });
204
+ if (!signal.aborted) navigation.replace(gameList({ games }));
205
+ },
206
+
207
+ render({ ui }) {
208
+ ui.text("Loading...", { tone: "muted" });
209
+ },
210
+
211
+ key(event, { navigation }) {
212
+ if (event.key === "escape") navigation.back();
213
+ },
214
+ });
215
+ ```
216
+
217
+ Rendering should be side-effect free. Start requests and timers in `mount()`, store local state in the factory closure, and call `requestRender()` to request a new frame. Each screen receives an `AbortSignal` that is aborted on unmount.
218
+
219
+ A custom class may implement the structural `Screen<Context, Result>` interface, but no base class is required.
220
+
221
+ ## Development
222
+
223
+ ```sh
224
+ bun install
225
+ bun run test
226
+ ```
227
+
228
+ `bun run test` builds the JavaScript and bundled TypeScript declarations, then runs the unit tests. `bun pm pack --dry-run` shows the files that will be included in a release.
229
+
230
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the contribution workflow.
@@ -0,0 +1,312 @@
1
+ // Generated by dts-bundle-generator v9.5.1
2
+
3
+ /// <reference types="node" />
4
+
5
+ export type TextColor = "black" | "red" | "green" | "yellow" | "blue" | "magenta" | "cyan" | "white" | "brightBlack" | "brightRed" | "brightGreen" | "brightYellow" | "brightBlue" | "brightMagenta" | "brightCyan" | "brightWhite";
6
+ export type TextTone = TextColor | "accent" | "muted" | "default";
7
+ export interface TextOptions {
8
+ tone?: TextTone;
9
+ }
10
+ export interface DividerOptions extends TextOptions {
11
+ character?: string;
12
+ }
13
+ export declare class Ui {
14
+ readonly width: number;
15
+ readonly height: number;
16
+ private readonly lines;
17
+ constructor(width: number, height?: number);
18
+ /** Formats text with renderer styling without adding it to the frame. */
19
+ style(value: string, { tone }?: TextOptions): string;
20
+ text(value: string, { tone }?: TextOptions): void;
21
+ blank(): void;
22
+ columns(left: string, right: string, { tone }?: TextOptions): void;
23
+ divider({ character, tone }?: DividerOptions): void;
24
+ header(lines: string[]): void;
25
+ /** @internal Serializes a UI instance for terminal output. */
26
+ static renderFrame(ui: Ui): string;
27
+ }
28
+ export type Modifiers = {
29
+ ctrl: boolean;
30
+ shift: boolean;
31
+ alt: boolean;
32
+ };
33
+ export type KeyEvent = Modifiers & {
34
+ sequence: string;
35
+ } & ({
36
+ key: "text";
37
+ text: string;
38
+ } | {
39
+ key: "up" | "down" | "left" | "right" | "enter" | "backspace" | "escape" | "tab" | "unknown";
40
+ });
41
+ declare const screenResult: unique symbol;
42
+ export interface Navigation<Context, CurrentResult = void> {
43
+ push<Result>(screen: Screen<Context, Result>): Promise<Result | undefined>;
44
+ back(result?: CurrentResult): void;
45
+ replace<Result>(screen: Screen<Context, Result>): void;
46
+ reset<Result>(screen: Screen<Context, Result>): void;
47
+ exit(result?: unknown): void;
48
+ }
49
+ export interface ScreenEnvironment<Context, Result = void> {
50
+ readonly context: Readonly<Context>;
51
+ readonly navigation: Navigation<Context, Result>;
52
+ readonly signal: AbortSignal;
53
+ requestRender(): void;
54
+ }
55
+ export interface RenderEnvironment<Context, Result = void> extends ScreenEnvironment<Context, Result> {
56
+ readonly ui: Ui;
57
+ }
58
+ export interface Screen<Context = any, Result = void> {
59
+ /** @internal Carries the result type without affecting runtime values. */
60
+ readonly [screenResult]?: Result;
61
+ /**
62
+ * Called once when this screen is pushed to the stack.
63
+ *
64
+ * Use this hook to start requests, timers, subscriptions, or other side
65
+ * effects. The first render occurs immediately after this hook is invoked;
66
+ * the runtime does not wait for a returned promise before rendering. Call
67
+ * `requestRender()` when asynchronous state changes should be displayed.
68
+ */
69
+ mount?(environment: ScreenEnvironment<Context, Result>): void | Promise<void>;
70
+ /**
71
+ * Called when the screen needs to draw its complete frame.
72
+ *
73
+ * Occurs immediately after `mount()` is invoked.
74
+ * Further renders occur after navigation transitions, calls to `requestRender()`,
75
+ * and terminal resize events.
76
+ *
77
+ * Rendering should be synchronous and free of side effects. Use `mount()`
78
+ * or `key()` for work that changes state, then call `requestRender()`.
79
+ */
80
+ render(environment: RenderEnvironment<Context, Result>): void;
81
+ /**
82
+ * Called when the active screen receives a keyboard event.
83
+ *
84
+ * Only the screen at the top of the stack receives input. Ctrl-C is handled
85
+ * by the app runtime and is not forwarded. This hook is synchronous so
86
+ * input ordering and navigation remain predictable. Start asynchronous work
87
+ * in a screen pushed from this hook, or explicitly start a detached task.
88
+ */
89
+ key?(event: KeyEvent, environment: ScreenEnvironment<Context, Result>): void;
90
+ /**
91
+ * Called immediately before another screen is pushed above this screen.
92
+ *
93
+ * This hook runs for `navigation.push()` only. It does not run when the
94
+ * screen is replaced, reset, popped, or removed during app shutdown. The
95
+ * screen remains mounted but stops receiving renders and input until it is
96
+ * resumed.
97
+ */
98
+ suspend?(environment: ScreenEnvironment<Context, Result>): void;
99
+ /**
100
+ * Called when a child screen is popped and this screen becomes active again.
101
+ *
102
+ * This follows `navigation.back()` from the child, including when that
103
+ * child was replaced before eventually going back. It is not called while
104
+ * intermediate screens are removed by `replace()`, `reset()`, or app exit.
105
+ * A render occurs immediately after this hook.
106
+ */
107
+ resume?(environment: ScreenEnvironment<Context, Result>): void;
108
+ /**
109
+ * Called once just before this screen is permanently discarded.
110
+ *
111
+ * This occurs on `navigation.back()`, `replace()`, `reset()`, app exit,
112
+ * `dispose()`, or fatal runtime error. Its abort signal is aborted before
113
+ * this hook runs, so asynchronous work can observe cancellation. During a
114
+ * reset or app shutdown, screens are unmounted from the top of the stack
115
+ * downward.
116
+ *
117
+ * The runtime invokes this synchronous hook only once. Cancel asynchronous
118
+ * work through the abort signal; do not start cleanup that must be awaited.
119
+ */
120
+ unmount?(environment: ScreenEnvironment<Context, Result>): void;
121
+ }
122
+ /**
123
+ * Allows a custom screen to be implemented with its own context,
124
+ * result value, lifecycle hooks, renderer, and navigation methods.
125
+ *
126
+ * `defineScreen` does not add runtime behavior.
127
+ * Use `mount` for side effects, `render` to draw
128
+ * the current frame, and `key` to respond to input. A screen can return a value
129
+ * to its parent with `navigation.back(result)`.
130
+ *
131
+ * @example
132
+ * ```ts
133
+ * type AppContext = { version: string };
134
+ *
135
+ * const aboutScreen = defineScreen<AppContext, string>({
136
+ * render({ context, ui }) {
137
+ * ui.text(`Version ${context.version}`);
138
+ * ui.text('Press Enter to continue');
139
+ * },
140
+ *
141
+ * key(event, { navigation }) {
142
+ * if (event.key === 'enter') {
143
+ * navigation.back('dismissed');
144
+ * }
145
+ * },
146
+ * });
147
+ *
148
+ * const result = await navigation.push(aboutScreen);
149
+ * // result is string | undefined
150
+ * ```
151
+ */
152
+ export declare function defineScreen<Context = any, Result = void>(screen: Screen<Context, Result>): Screen<Context, Result>;
153
+ export interface CliOutput {
154
+ write(value: string): unknown;
155
+ }
156
+ export interface CliOptions<Context> {
157
+ context: Context;
158
+ input?: NodeJS.ReadStream;
159
+ output?: CliOutput;
160
+ onError?: (error: unknown) => void;
161
+ }
162
+ declare class ScreenStackApp<Context, ExitResult> {
163
+ private readonly options;
164
+ /** Screen stack ordered from the root to the current screen. */
165
+ private readonly frameStack;
166
+ private readonly terminal;
167
+ private runCompletion?;
168
+ private isRendering;
169
+ private hasRenderRequest;
170
+ /** Delays rendering until the current group of screen updates is finished. */
171
+ private renderBatchDepth;
172
+ constructor(userOptions: CliOptions<Context>);
173
+ get isRunning(): boolean;
174
+ run<Result = ExitResult>(root: Screen<Context, Result>): Promise<Result | undefined>;
175
+ dispose(): void;
176
+ private currentFrame;
177
+ private createFrame;
178
+ /** Adds a screen to the stack and starts its lifecycle. */
179
+ private mount;
180
+ private requestRender;
181
+ private pushFrame;
182
+ private back;
183
+ private replace;
184
+ private reset;
185
+ private exit;
186
+ private render;
187
+ /** Groups screen updates and renders once after they finish. */
188
+ private batchRender;
189
+ private handleKey;
190
+ private handleSigint;
191
+ private handleResize;
192
+ /** Just a wrapper around mount() suspend() resume() to make error calling easier */
193
+ private runLifecycleHook;
194
+ private unmount;
195
+ private reportError;
196
+ private reportCleanupError;
197
+ private stop;
198
+ private cleanupFrames;
199
+ }
200
+ export declare function createCli<Context = void, ExitResult = unknown>(options?: CliOptions<Context>): ScreenStackApp<Context, ExitResult>;
201
+ export type TextAlignment = "left" | "center" | "right";
202
+ export interface DecoratedText {
203
+ value: string;
204
+ align?: TextAlignment;
205
+ tone?: TextTone;
206
+ }
207
+ /**
208
+ * Text using default presentation, or text with explicit presentation options.
209
+ */
210
+ export type TextContent = string | DecoratedText;
211
+ export interface Choice<Value> {
212
+ label: TextContent;
213
+ value: Value;
214
+ description?: TextContent;
215
+ disabled?: boolean;
216
+ }
217
+ export interface SelectOptions<Value> {
218
+ title?: TextContent;
219
+ choices: readonly Choice<Value>[];
220
+ initialIndex?: number;
221
+ loop?: boolean;
222
+ subtitle?: TextContent;
223
+ cancelKeys?: readonly ("escape" | "backspace")[];
224
+ /** Maximum number of choices shown at once, in addition to terminal limits. */
225
+ maxVisible?: number;
226
+ }
227
+ export declare function select<Value, Context = any>(options: SelectOptions<Value>): Screen<Context, Value>;
228
+ export interface MenuOptions<Value, Context = any> extends Omit<SelectOptions<Value>, "choices"> {
229
+ choices: readonly Choice<Value>[];
230
+ /** Label for the selectable back action. Set to false to hide it. */
231
+ backLabel?: TextContent | false;
232
+ onSelect(value: Value, environment: ScreenEnvironment<Context, void>): void;
233
+ }
234
+ export declare function menu<Value, Context = any>(options: MenuOptions<Value, Context>): Screen<Context, void>;
235
+ export type DismissKey = Exclude<KeyEvent["key"], "text" | "unknown"> | "any";
236
+ export interface MessageOptions {
237
+ title?: TextContent;
238
+ text: TextContent;
239
+ hint?: TextContent;
240
+ dismissKeys?: readonly DismissKey[];
241
+ }
242
+ export declare function message(options: MessageOptions | TextContent): Screen<any, void>;
243
+ /**
244
+ * A screen to display in a sequence, or a factory that creates one when its
245
+ * turn begins.
246
+ */
247
+ export type ScreenSource<Context> = Screen<Context, any> | (() => Screen<Context, any>);
248
+ /**
249
+ * Creates a screen that displays each supplied screen in order.
250
+ *
251
+ * A screen advances when it navigates back. Lazy factories are not invoked
252
+ * until their turn begins. After the final screen navigates back, the sequence
253
+ * also navigates back. If the sequence is unmounted or the app exits, no later
254
+ * screens are created.
255
+ *
256
+ * @example
257
+ * ```ts
258
+ * await app.run(sequence([
259
+ * typewriter({ text: 'Welcome!' }),
260
+ * () => gameMenuScreen(),
261
+ * ]));
262
+ * ```
263
+ */
264
+ export declare function sequence<Context = any>(sources: readonly ScreenSource<Context>[]): Screen<Context, void>;
265
+ export interface TextInputOptions {
266
+ message?: TextContent;
267
+ initialValue?: string;
268
+ placeholder?: TextContent;
269
+ /** Hides entered text using `*` when true or the provided string, without changing the returned value. */
270
+ mask?: boolean | string;
271
+ allowEmpty?: boolean;
272
+ validate?: (value: string) => TextContent | undefined;
273
+ cancelKeys?: readonly ("escape")[];
274
+ }
275
+ export declare function textInput(options?: TextInputOptions): Screen<any, string>;
276
+ export interface TypewriterOptions {
277
+ /** Optional heading displayed above the animated text. */
278
+ title?: TextContent;
279
+ text: TextContent;
280
+ charactersPerSecond?: number;
281
+ /** Automatically closes the screen when typing completes. Defaults to `false`. */
282
+ autoDismiss?: boolean;
283
+ }
284
+ export declare function typewriter(options: TypewriterOptions): Screen<any, void>;
285
+ export interface TaskEnvironment<Context> {
286
+ readonly context: Readonly<Context>;
287
+ readonly signal: AbortSignal;
288
+ }
289
+ export type ScreenTask<Context, Result> = (environment: TaskEnvironment<Context>) => Result | Promise<Result>;
290
+ /**
291
+ * Displays a screen while an asynchronous task runs.
292
+ *
293
+ * The task controls the decorated screen's lifetime. Navigation that would
294
+ * remove the visual screen is ignored; when the task completes, the decorator
295
+ * navigates back with the task result. Removing the decorator aborts the signal
296
+ * supplied to the task and unmounts the visual screen.
297
+ *
298
+ * @example
299
+ * ```ts
300
+ * const loading = withTask<AppContext, User[]>(
301
+ * typewriter({ text: 'Loading users...' }),
302
+ * async ({ context, signal }) => {
303
+ * return context.api.loadUsers({ signal });
304
+ * },
305
+ * );
306
+ *
307
+ * const users = await navigation.push(loading);
308
+ * ```
309
+ */
310
+ export declare function withTask<Context = any, Result = void>(screen: Screen<Context, any>, task: ScreenTask<Context, Result>): Screen<Context, Result>;
311
+
312
+ export {};