@effected/cli 0.11.0 → 0.13.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.
@@ -212,12 +212,14 @@ const LEADING_MOVES = /^(?:\u001b\[[0-9;?]*[A-Za-ln-z])+/;
212
212
  * with the settle-and-send machinery that drives a screen.
213
213
  *
214
214
  * @remarks
215
- * In `"debug"` mode (screens) Ink writes each frame whole and the capture keeps it as written. In `"production"` mode
216
- * (the live view) Ink runs as it does for real: each render writes its erase moves and the new frame in one write, so
217
- * the capture keeps that write without its moves; a write of moves alone is Ink clearing the frame for a log line, not
218
- * a frame. stderr is the same stream as stdout there, as on a terminal, so the transcript holds both.
215
+ * `screens` is the render path a screen mounts on; a live view always mounts on the production path. On the debug path
216
+ * Ink writes each frame whole and the capture keeps it as written. On the production path Ink runs as it does for real:
217
+ * each render writes its erase moves and the new frame in one write, so the capture keeps that write without its
218
+ * moves; a write of moves alone is Ink clearing the frame for a log line, not a frame. stdout and stderr are two
219
+ * streams, each read alone, and `fake.written` is both in the order written: one terminal, for `written` and the
220
+ * transcript.
219
221
  */
220
- const makeTerminal = (options, mode = "debug") => {
222
+ const makeTerminal = (options, settings = { screens: "debug" }) => {
221
223
  const columns = options.columns ?? 80;
222
224
  const rows = options.rows ?? 24;
223
225
  const color = options.color ?? "truecolor";
@@ -231,7 +233,7 @@ const makeTerminal = (options, mode = "debug") => {
231
233
  lastWrite = Date.now();
232
234
  const current = captures.at(-1);
233
235
  if (!frameDue || current === void 0 || current.ended) return;
234
- if (mode === "debug") {
236
+ if (current.mode === "debug") {
235
237
  frameDue = false;
236
238
  current.raws.push(chunk);
237
239
  return;
@@ -243,10 +245,7 @@ const makeTerminal = (options, mode = "debug") => {
243
245
  current.raws.push(text.replace(LEADING_MOVES, "").replace(/\n+$/, ""));
244
246
  }
245
247
  });
246
- const streams = mode === "production" ? {
247
- ...fake.streams,
248
- stderr: fake.streams.stdout
249
- } : fake.streams;
248
+ const streams = fake.streams;
250
249
  const stream = {
251
250
  isTerminal: true,
252
251
  color,
@@ -262,13 +261,16 @@ const makeTerminal = (options, mode = "debug") => {
262
261
  tokens: MARKER_STYLES,
263
262
  glyphs: options.glyphs ?? "unicode"
264
263
  }).pipe(Layer.provide(terminal)), CliInteractive.layerTest(options.interactive ?? true), Layer.succeed(UiStreams, streams), Layer.succeed(UiRenderOptions, {
265
- ...mode === "debug" ? { debug: true } : {},
264
+ ...settings.screens === "debug" ? { debug: true } : {},
265
+ maxFps: 1e3,
266
266
  onRender: () => {
267
267
  frameDue = true;
268
268
  },
269
- onMount: () => {
269
+ onMount: (kind) => {
270
+ const mode = kind === "live" ? "production" : settings.screens;
270
271
  captures.push({
271
272
  raws: [],
273
+ mode,
272
274
  ended: false,
273
275
  crash: void 0
274
276
  });
@@ -482,7 +484,7 @@ var CliUiTest = class {
482
484
  * frames on a real terminal (a screen clear, for instance), nor about the final frame left in the scrollback once
483
485
  * the screen ends (answered screens stay); a test of that needs the production render path.
484
486
  * For the same reason `CliUi.run`'s `clear` has no visible effect on a harness frame, since Ink's `clear` does
485
- * nothing in debug mode: test `clear` on the production render path, as the kit's own tests do.
487
+ * nothing in debug mode: test `clear` with `session({ renderPath: "production" })` and its `transcript`.
486
488
  * Unmounting is the scope's close; to draw a different screen, render it in a new scope.
487
489
  *
488
490
  * A crash is never swallowed. A screen thunk that throws (a classic-JSX `React is not defined` included) or a
@@ -541,8 +543,11 @@ var CliUiTest = class {
541
543
  * mounted and drawn, `press` and `type` settle as they do on a rendered screen, and joining the program's fiber
542
544
  * gives its exit. Screens still run one at a time, process-wide, so `next` sees them in the order they mount.
543
545
  *
544
- * As with `render`, a screen run with `clear` leaves its frames unchanged here, and the final scrollback is not
545
- * captured: Ink renders in debug mode, where its `clear` does nothing, so test `clear` on the production render path.
546
+ * By default, as with `render`, a screen run with `clear` leaves its frames unchanged here: Ink renders screens in
547
+ * debug mode, where its `clear` does nothing. Pass `renderPath: "production"` to render them as a terminal does, and
548
+ * read `transcript` to see what stays on it: a cleared screen leaves nothing there. A live view a handler mounts
549
+ * (`CliUi.live`) always renders on the production path, and the lines it writes above its frame through
550
+ * `handle.logConsole` are in `transcript` and `written`.
546
551
  * The waits are real time: a session test that itself sleeps or times out needs `it.live`. To assert that no screen
547
552
  * mounted (a non-interactive run, a flag that skips a prompt), check that `mounts` is `0` once the program has
548
553
  * finished.
@@ -574,10 +579,11 @@ var CliUiTest = class {
574
579
  * To exercise `CliRuntime.main` as well (its failure report and exit code), run the program through it with the
575
580
  * session's layer provided around it instead; `main` provides its own `CliExit`.
576
581
  *
577
- * @param options - the terminal's size, colour and glyphs, and whether the run is interactive
582
+ * @param options - the terminal's size, colour and glyphs, whether the run is interactive, and the screens' render path
578
583
  */
579
584
  static session = (options = {}) => Effect.map(Console.Console, (ambient) => {
580
- const terminal = makeTerminal(options);
585
+ const { renderPath, ...terminalOptions } = options;
586
+ const terminal = makeTerminal(terminalOptions, { screens: renderPath ?? "debug" });
581
587
  const output = capturingConsole(ambient);
582
588
  let taken = 0;
583
589
  const next = (nextOptions = {}) => Effect.gen(function* () {
@@ -601,7 +607,13 @@ var CliUiTest = class {
601
607
  next,
602
608
  mounts: Effect.sync(() => terminal.captures.length),
603
609
  stdout: Effect.sync(() => output.out.join("")),
604
- stderr: Effect.sync(() => output.err.join(""))
610
+ stderr: Effect.sync(() => output.err.join("")),
611
+ transcript: Effect.sync(() => screenAfter(terminal.fake.written(), terminal.fake.streams.stdout.rows).join("\n")),
612
+ written: Effect.sync(() => terminal.fake.written()),
613
+ stdoutWritten: Effect.sync(() => terminal.fake.stdout()),
614
+ stderrWritten: Effect.sync(() => terminal.fake.stderr()),
615
+ stdoutTranscript: Effect.sync(() => screenAfter(terminal.fake.stdout(), terminal.fake.streams.stdout.rows).join("\n")),
616
+ stderrTranscript: Effect.sync(() => screenAfter(terminal.fake.stderr(), terminal.fake.streams.stdout.rows).join("\n"))
605
617
  };
606
618
  });
607
619
  /**
@@ -659,7 +671,7 @@ var CliUiTest = class {
659
671
  ...color === void 0 ? {} : { color },
660
672
  ...glyphs === void 0 ? {} : { glyphs },
661
673
  ...interactive === void 0 ? {} : { interactive }
662
- }, "production");
674
+ }, { screens: "production" });
663
675
  const queue = yield* Queue.unbounded();
664
676
  const handle = yield* CliUi.live({
665
677
  ...view,
@@ -681,8 +693,8 @@ var CliUiTest = class {
681
693
  rawFrame: Effect.sync(last),
682
694
  plainFrame: Effect.sync(() => trimLines(last().replace(ESCAPES, ""))),
683
695
  frames: Effect.sync(() => raws().map((raw) => trimLines(styled(raw)))),
684
- transcript: Effect.sync(() => screenAfter(terminal.fake.stdout(), terminal.fake.streams.stdout.rows).join("\n")),
685
- written: Effect.sync(() => terminal.fake.stdout()),
696
+ transcript: Effect.sync(() => screenAfter(terminal.fake.written(), terminal.fake.streams.stdout.rows).join("\n")),
697
+ written: Effect.sync(() => terminal.fake.written()),
686
698
  handle
687
699
  };
688
700
  });
@@ -723,7 +735,20 @@ var CliUiTest = class {
723
735
  * tag (a raw or styled frame, or a `Render.ansi` string), but not a log line's lone `[info]` prefix, nor one whose
724
736
  * only brackets are style tags like `[b]`, which unrelated data uses too. It prints the string as token markup with
725
737
  * each line's trailing spaces trimmed, so a snapshot reads without escapes and does not churn with the palette.
726
- * Register it with `expect.addSnapshotSerializer`.
738
+ *
739
+ * @remarks
740
+ * Register it in either of two ways. Through the Vitest config, by the module whose default export it is, which
741
+ * needs no code in a test file:
742
+ *
743
+ * ```ts
744
+ * // vitest.config.ts
745
+ * export default defineConfig({ test: { snapshotSerializers: ["@effected/cli/ui/testing/serializer"] } })
746
+ * ```
747
+ *
748
+ * Or in a test file, with `expect.addSnapshotSerializer(CliUiTest.serializer)`.
749
+ *
750
+ * Snapshots are the one place a test needs `expect`: `assert` has no snapshot form, so a suite that asserts with
751
+ * `assert.*` everywhere else still writes `expect(frame).toMatchInlineSnapshot(...)` for a snapshot.
727
752
  */
728
753
  static serializer = {
729
754
  test: (value) => typeof value === "string" && (value.includes("\x1B") || MARKUP.test(value)),
@@ -1,11 +1,12 @@
1
1
  import { PassThrough, Writable } from "node:stream";
2
2
 
3
3
  //#region src/ui/testing/fakeStreams.ts
4
- const capture = (columns, rows, onWrite) => {
4
+ const capture = (columns, rows, both, onWrite) => {
5
5
  const chunks = [];
6
6
  const stream = new Writable({ write(chunk, _encoding, callback) {
7
7
  const text = chunk.toString();
8
8
  chunks.push(text);
9
+ both.push(text);
9
10
  onWrite?.(text);
10
11
  callback();
11
12
  } });
@@ -44,8 +45,9 @@ const makeFakeStreams = (options = {}) => {
44
45
  ref: () => stdin,
45
46
  unref: () => stdin
46
47
  });
47
- const stdout = capture(columns, rows, options.onStdoutWrite);
48
- const stderr = capture(columns, rows);
48
+ const both = [];
49
+ const stdout = capture(columns, rows, both, options.onStdoutWrite);
50
+ const stderr = capture(columns, rows, both);
49
51
  return {
50
52
  streams: {
51
53
  stdin,
@@ -55,6 +57,7 @@ const makeFakeStreams = (options = {}) => {
55
57
  rawModes,
56
58
  stdout: stdout.text,
57
59
  stderr: stderr.text,
60
+ written: () => both.join(""),
58
61
  input: (data) => {
59
62
  stdin.write(data);
60
63
  },
@@ -0,0 +1,14 @@
1
+ //#region src/ui-testing-serializer.d.ts
2
+ /**
3
+ * `CliUiTest.serializer`: it claims a string carrying escapes or token markup and prints it as token markup with each
4
+ * line's trailing spaces trimmed. See `CliUiTest.serializer` for what it claims.
5
+ *
6
+ * @public
7
+ */
8
+ declare const serializer: {
9
+ readonly test: (value: unknown) => boolean;
10
+ readonly serialize: (value: unknown) => string;
11
+ };
12
+ //#endregion
13
+ export { serializer as default };
14
+ //# sourceMappingURL=ui-testing-serializer.d.ts.map
@@ -0,0 +1,33 @@
1
+ import { CliUiTest } from "./ui/testing/CliUiTest.js";
2
+
3
+ //#region src/ui-testing-serializer.ts
4
+ /**
5
+ * `CliUiTest.serializer` as a module's default export, for Vitest's `snapshotSerializers` config.
6
+ *
7
+ * @remarks
8
+ * Vitest's `test.snapshotSerializers` takes module paths whose default export is a serializer, so registering this
9
+ * one needs no `expect.addSnapshotSerializer` call and no shim file of your own:
10
+ *
11
+ * ```ts
12
+ * // vitest.config.ts
13
+ * import { defineConfig } from "vitest/config"
14
+ *
15
+ * export default defineConfig({
16
+ * test: { snapshotSerializers: ["@effected/cli/ui/testing/serializer"] },
17
+ * })
18
+ * ```
19
+ *
20
+ * A separate entrypoint so a program's runtime import graph never loads test code.
21
+ *
22
+ * @packageDocumentation
23
+ */
24
+ /**
25
+ * `CliUiTest.serializer`: it claims a string carrying escapes or token markup and prints it as token markup with each
26
+ * line's trailing spaces trimmed. See `CliUiTest.serializer` for what it claims.
27
+ *
28
+ * @public
29
+ */
30
+ const serializer = CliUiTest.serializer;
31
+
32
+ //#endregion
33
+ export { serializer as default };
package/ui-testing.d.ts CHANGED
@@ -32,6 +32,21 @@ interface CliUiTestOptions {
32
32
  */
33
33
  readonly interactive?: boolean;
34
34
  }
35
+ /**
36
+ * Options for {@link CliUiTest.session}: the terminal's, and the render path its screens mount on.
37
+ *
38
+ * @public
39
+ */
40
+ interface CliUiTestSessionOptions extends CliUiTestOptions {
41
+ /**
42
+ * The path the session's screens render on. `"debug"`, the default, is `render`'s: Ink writes every frame whole and
43
+ * unthrottled, which is what `next`, `frame` and `frames` read best, and where `clear` does nothing. `"production"`
44
+ * renders screens as a real terminal does, erase moves and all, so `CliUi.run`'s `clear: true` is observable in
45
+ * {@link CliUiTestSession.transcript}: the cleared frame is gone from it. A live view mounted in a session always
46
+ * renders on the production path, whichever this is.
47
+ */
48
+ readonly renderPath?: "debug" | "production";
49
+ }
35
50
  /**
36
51
  * A screen under test: drive it with keys and read its frames.
37
52
  *
@@ -134,13 +149,26 @@ interface CliUiTestNextOptions {
134
149
  */
135
150
  interface CliUiTestSession {
136
151
  /**
137
- * Provide it around the program: in-memory terminal streams, the marker-palette `CliTheme`, `CliInteractive`
138
- * from the session's options, frame capture, and a `Console` whose writes the session keeps.
152
+ * Provide it around the program: in-memory terminal streams (`UiStreams`), the marker-palette `CliTheme`,
153
+ * `CliInteractive` from the session's options, frame capture, and a `Console` whose writes the session keeps. Its
154
+ * type names `CliTheme` alone because that is the only one of them a program can require: `UiStreams`,
155
+ * `CliInteractive`, the capture and `Console` are `Context.Reference`s with defaults, which never appear in a
156
+ * requirement, so a type cannot say that the layer supplies them. Their defaults are the real process streams, a
157
+ * run that is not interactive, and the real console, so a session provided where it is shadowed fails quietly
158
+ * rather than by a type error. Hence the ordering below.
139
159
  *
140
160
  * @remarks
141
- * Anything the program provides closer to the screens wins: under `CliRuntime.main` with `env`, `CliEnv.layer`
142
- * supplies the theme and decides interactivity, as it does for real, and the session keeps only the streams, the
143
- * capture and the console.
161
+ * **Where you provide it decides what it supplies.** Anything the program provides closer to the screens wins, so:
162
+ *
163
+ * - Provided **outside** a layer that supplies the same services, it is shadowed there. Under `CliRuntime.main`
164
+ * with `env`, `CliEnv.layer` (provided inside `main`) supplies the theme and decides interactivity, as it does for
165
+ * real, and the session keeps only the streams, the capture and the console. That is the intended way to test the
166
+ * production wiring.
167
+ * - To have the session's own theme and `interactive` option win, provide it **inside** any presentation layer of
168
+ * your own (closest to the program), or drop that layer from the test.
169
+ *
170
+ * The streams, the capture and the console are the session's wherever it is provided, since nothing else in the kit
171
+ * provides them.
144
172
  */
145
173
  readonly layer: Layer.Layer<Cli.CliTheme>;
146
174
  /**
@@ -162,6 +190,43 @@ interface CliUiTestSession {
162
190
  readonly stdout: Effect.Effect<string>;
163
191
  /** What the program wrote to stderr through `Console` (`error`, `warn`, `trace`), one line per call. */
164
192
  readonly stderr: Effect.Effect<string>;
193
+ /**
194
+ * What the terminal shows now, scrollback included, as plain text: what the session's screens and live views wrote to
195
+ * the terminal streams (`UiStreams`), with Ink's erases and clears applied, each line's trailing spaces trimmed and
196
+ * blank lines left out, as {@link CliUiTestLive.transcript} reads it.
197
+ *
198
+ * @remarks
199
+ * It holds the lines a live view writes above its frame through `handle.logConsole` (stdout's and stderr's, merged in the order
200
+ * written, as one terminal shows them; `stdoutWritten` and `stderrWritten` keep them apart), each run's committed frame, a frame printed as a string, and, with
201
+ * `renderPath: "production"`, each screen's frames as they stay on the terminal, so a screen run with `clear: true`
202
+ * leaves nothing. On the default debug path each screen frame is written whole, one after another, so the
203
+ * transcript shows every frame a screen drew rather than what a terminal would keep. What the program writes through
204
+ * `Console` is not here: that is `stdout` and `stderr`.
205
+ */
206
+ readonly transcript: Effect.Effect<string>;
207
+ /** Every byte written to the terminal streams, both of them in the order written, escapes included. */
208
+ readonly written: Effect.Effect<string>;
209
+ /**
210
+ * Every byte written to the terminal's stdout (`UiStreams.stdout`) alone, escapes included: with
211
+ * {@link CliUiTestSession.stderrWritten}, what tells a line on the wrong stream from one on the right, which the
212
+ * merged `transcript` and `written` cannot.
213
+ */
214
+ readonly stdoutWritten: Effect.Effect<string>;
215
+ /** Every byte written to the terminal's stderr (`UiStreams.stderr`) alone, escapes included. */
216
+ readonly stderrWritten: Effect.Effect<string>;
217
+ /**
218
+ * What the terminal's stdout alone shows now, as plain text: {@link CliUiTestSession.stdoutWritten} run through the
219
+ * same pipeline as {@link CliUiTestSession.transcript} (Ink's erases and clears applied, escapes stripped, trailing
220
+ * spaces trimmed, blank lines left out).
221
+ *
222
+ * @remarks
223
+ * Assert text per stream here rather than on the raw bytes, which split a painted line wherever the paint breaks
224
+ * (a counter's `Dry run` from its `0/2 repos`). A frame cleared on the production render path is absent from it
225
+ * and present in `stdoutWritten`.
226
+ */
227
+ readonly stdoutTranscript: Effect.Effect<string>;
228
+ /** What the terminal's stderr alone shows now, as plain text: {@link CliUiTestSession.stderrWritten} read as `stdoutTranscript` reads stdout. */
229
+ readonly stderrTranscript: Effect.Effect<string>;
165
230
  }
166
231
  /**
167
232
  * A live view mounted by {@link CliUiTest.live}: its event stream to publish to, and its output on the production
@@ -271,7 +336,7 @@ export declare class CliUiTest {
271
336
  * frames on a real terminal (a screen clear, for instance), nor about the final frame left in the scrollback once
272
337
  * the screen ends (answered screens stay); a test of that needs the production render path.
273
338
  * For the same reason `CliUi.run`'s `clear` has no visible effect on a harness frame, since Ink's `clear` does
274
- * nothing in debug mode: test `clear` on the production render path, as the kit's own tests do.
339
+ * nothing in debug mode: test `clear` with `session({ renderPath: "production" })` and its `transcript`.
275
340
  * Unmounting is the scope's close; to draw a different screen, render it in a new scope.
276
341
  *
277
342
  * A crash is never swallowed. A screen thunk that throws (a classic-JSX `React is not defined` included) or a
@@ -320,8 +385,11 @@ export declare class CliUiTest {
320
385
  * mounted and drawn, `press` and `type` settle as they do on a rendered screen, and joining the program's fiber
321
386
  * gives its exit. Screens still run one at a time, process-wide, so `next` sees them in the order they mount.
322
387
  *
323
- * As with `render`, a screen run with `clear` leaves its frames unchanged here, and the final scrollback is not
324
- * captured: Ink renders in debug mode, where its `clear` does nothing, so test `clear` on the production render path.
388
+ * By default, as with `render`, a screen run with `clear` leaves its frames unchanged here: Ink renders screens in
389
+ * debug mode, where its `clear` does nothing. Pass `renderPath: "production"` to render them as a terminal does, and
390
+ * read `transcript` to see what stays on it: a cleared screen leaves nothing there. A live view a handler mounts
391
+ * (`CliUi.live`) always renders on the production path, and the lines it writes above its frame through
392
+ * `handle.logConsole` are in `transcript` and `written`.
325
393
  * The waits are real time: a session test that itself sleeps or times out needs `it.live`. To assert that no screen
326
394
  * mounted (a non-interactive run, a flag that skips a prompt), check that `mounts` is `0` once the program has
327
395
  * finished.
@@ -353,9 +421,9 @@ export declare class CliUiTest {
353
421
  * To exercise `CliRuntime.main` as well (its failure report and exit code), run the program through it with the
354
422
  * session's layer provided around it instead; `main` provides its own `CliExit`.
355
423
  *
356
- * @param options - the terminal's size, colour and glyphs, and whether the run is interactive
424
+ * @param options - the terminal's size, colour and glyphs, whether the run is interactive, and the screens' render path
357
425
  */
358
- static readonly session: (options?: CliUiTestOptions) => Effect.Effect<CliUiTestSession, never, Scope.Scope>;
426
+ static readonly session: (options?: CliUiTestSessionOptions) => Effect.Effect<CliUiTestSession, never, Scope.Scope>;
359
427
  /**
360
428
  * Mount a live view (`CliUi.live`) on a fresh in-memory terminal for the enclosing scope, with an event stream the
361
429
  * test publishes to, and read what it draws on the production render path.
@@ -434,7 +502,20 @@ export declare class CliUiTest {
434
502
  * tag (a raw or styled frame, or a `Render.ansi` string), but not a log line's lone `[info]` prefix, nor one whose
435
503
  * only brackets are style tags like `[b]`, which unrelated data uses too. It prints the string as token markup with
436
504
  * each line's trailing spaces trimmed, so a snapshot reads without escapes and does not churn with the palette.
437
- * Register it with `expect.addSnapshotSerializer`.
505
+ *
506
+ * @remarks
507
+ * Register it in either of two ways. Through the Vitest config, by the module whose default export it is, which
508
+ * needs no code in a test file:
509
+ *
510
+ * ```ts
511
+ * // vitest.config.ts
512
+ * export default defineConfig({ test: { snapshotSerializers: ["@effected/cli/ui/testing/serializer"] } })
513
+ * ```
514
+ *
515
+ * Or in a test file, with `expect.addSnapshotSerializer(CliUiTest.serializer)`.
516
+ *
517
+ * Snapshots are the one place a test needs `expect`: `assert` has no snapshot form, so a suite that asserts with
518
+ * `assert.*` everywhere else still writes `expect(frame).toMatchInlineSnapshot(...)` for a snapshot.
438
519
  */
439
520
  static readonly serializer: {
440
521
  readonly test: (value: unknown) => boolean;
@@ -442,5 +523,5 @@ export declare class CliUiTest {
442
523
  };
443
524
  }
444
525
  //#endregion
445
- export type { CliUiTestHandle, CliUiTestLive, CliUiTestNextOptions, CliUiTestOptions, CliUiTestScreen, CliUiTestSession, CliUiTestView };
526
+ export type { CliUiTestHandle, CliUiTestLive, CliUiTestNextOptions, CliUiTestOptions, CliUiTestScreen, CliUiTestSession, CliUiTestSessionOptions, CliUiTestView };
446
527
  //# sourceMappingURL=ui-testing.d.ts.map
package/ui.d.ts CHANGED
@@ -39,6 +39,10 @@ interface LiveOptions<E, S> {
39
39
  * `useTerminalSize` work in it. When the final frame is printed as a string (not interactive, or a run that degraded
40
40
  * before it painted), `useTerminalSize().rows` is `Infinity`, since that frame has no height to fit: a render must
41
41
  * not allocate per row.
42
+ *
43
+ * To keep the view's module (its JSX, and so React) off every run that never draws it, pass
44
+ * `CliUi.lazyView(() => import("./view.js"))`: the module is loaded only when a run first mounts or prints its frame
45
+ * with Ink, so `--help`, a usage error, and, with `final`, a run that is not interactive, never load it.
42
46
  */
43
47
  readonly render: (state: S, frame: number) => ReactElement;
44
48
  /** Whether an event starts a run: by default, the only event that begins one (see `begins`). */
@@ -61,6 +65,28 @@ interface LiveOptions<E, S> {
61
65
  * string; hosted writes nothing, its host having its own output.
62
66
  */
63
67
  readonly mode?: "owned" | "hosted";
68
+ /**
69
+ * The final frame of a run that is not interactive (an agent, CI, a pipe, `TERM=dumb`), as a document: given, an
70
+ * `owned` view prints each run's `final(state)` at the run's end instead of rendering `render` to a string with Ink,
71
+ * so such a run loads neither Ink nor React, nor a `CliUi.lazyView` module.
72
+ *
73
+ * @remarks
74
+ * It is called once per run, at the run's terminal event (or when the events end mid-run), with the state then, and
75
+ * replaces the string that run would have printed: never both. It is rendered as `Doc.print` renders a document:
76
+ * `Render.context("stdout")` when the environment `CliRuntime.main` builds is there (`TerminalEnv`, `Audience` and
77
+ * `CliLinks`; otherwise the stdout theme, the `Audience` if any, and no width limit), the renderer the audience gets
78
+ * (`ansi` for a person, `plain` for an agent, `githubLog` under GitHub Actions), and an agent gets no escape. It is
79
+ * written where the view writes, to `UiStreams` stdout, so a host's capture of the view's output holds it. A document
80
+ * that renders to nothing prints nothing. A `final` that throws is the run degrading: one warning, nothing printed.
81
+ *
82
+ * An interactive run never calls it, and a `hosted` view never prints either way. It need not match the Ink frame:
83
+ * it is what a reader with no terminal gets.
84
+ *
85
+ * With `final` set, `render` is never called on a run that is not interactive, not even to build a string that would
86
+ * go unused: the run's output is `final(state)` alone. (Pinned by `CliUi.live.final.test.ts`, whose watch-mode test
87
+ * counts zero `render` calls over three runs.)
88
+ */
89
+ readonly final?: ((state: S) => Cli.Document) | undefined;
64
90
  /**
65
91
  * The frame tick, in milliseconds; 80 by default. While a run is drawn the view redraws on every tick, so a spinner
66
92
  * turns without events. Anything but a positive, finite number is a defect.
@@ -343,7 +369,7 @@ export declare class CliUi {
343
369
  * what is published before.
344
370
  *
345
371
  * `live` returns its handle at once, before Ink has loaded: it loads Ink when a run first mounts (or, when not
346
- * interactive, when an owned run prints its final frame), and it waits on nothing asynchronous before returning, so
372
+ * interactive, when an owned run without a `final` prints its final frame), and it waits on nothing asynchronous before returning, so
347
373
  * a host outside Effect can take the handle with `Effect.runSync`. The handle works from the start: a `close`
348
374
  * before any run has mounted folds what is queued and ends the view as the events ending would, waiting for a mount
349
375
  * already under way, and one with no run to end loads nothing.
@@ -387,8 +413,12 @@ export declare class CliUi {
387
413
  * When the run is not interactive, nothing is mounted and Ink is loaded only when a string is due. In the `owned`
388
414
  * mode (the default) each run's final frame is written once to stdout, as a string laid out at stdout's width (80
389
415
  * when it reports none) with no height to fit, at its terminal event or when the stream ends. It is escape-free at
390
- * colour `none`, and for an agent audience (`Audience`, when provided) whatever the terminal could do. In the
391
- * `hosted` mode nothing is written.
416
+ * colour `none`, and for an agent audience (`Audience`, when provided) whatever the terminal could do. With a
417
+ * `final` document, that document is printed instead, once per run, rendered as `Doc.print` renders it, and Ink,
418
+ * React and a `CliUi.lazyView` module are never loaded: an agent, CI or piped run of a command with a live view pays
419
+ * for none of them. In the `hosted` mode nothing is written.
420
+ *
421
+ * Keep React off the runs that never draw (`--help`, a usage error) with `render: CliUi.lazyView(() => import(...))`.
392
422
  *
393
423
  * No input is mounted: the view reads no keys and never enters raw mode, so Ctrl-C stays the platform's SIGINT,
394
424
  * which interrupts the program and so closes the scope. Each run holds the process-wide mount permit from its
@@ -414,6 +444,10 @@ export declare class CliUi {
414
444
  * naming the peers, never a silent `otherwise`. Screens in sequence make a wizard: discover the defaults first,
415
445
  * pass each as an `otherwise`, and a non-interactive run returns exactly them.
416
446
  *
447
+ * Without `otherwise`, `NotInteractive` stays in the error type even after the caller checked `CliInteractive`, since
448
+ * the type cannot know. Catch the tag and fail with `CliError.UserError` to exit as a usage error (`64` under
449
+ * `CliRuntime.main`).
450
+ *
417
451
  * As with `CliUi.run`, do not log while the screen is mounted: a line written to the terminal from elsewhere tears
418
452
  * the frame.
419
453
  *
@@ -466,6 +500,79 @@ export declare class CliUi {
466
500
  static readonly lazy: <A>(load: () => Promise<{
467
501
  readonly default: Screen<A>;
468
502
  }>) => Screen<A>;
503
+ /**
504
+ * A live view's `render` whose module is loaded only when a run first draws it, so importing the command that uses
505
+ * the view loads neither the view's own code nor React: `CliUi.lazy` for `CliUi.live`.
506
+ *
507
+ * @remarks
508
+ * `load` resolves to the view, `(state, frame) => ReactElement`, exactly what `render` takes, so the frame index a
509
+ * spinner needs reaches it: either a module whose default export is the view (`() => import("./view.js")`), or the
510
+ * view itself (`() => import("./views.js").then((module) => module.syncView)`, for a named export).
511
+ *
512
+ * It is optional: `render` still takes the view directly, and needs no dynamic import. A view passed directly
513
+ * loads with the module that imports it, so it costs React on every run that loads that module; `lazyView` is
514
+ * how a command keeps React off the runs that never draw. `CliUi.live` loads the module before a run mounts with Ink, or before it
515
+ * prints a run's final frame as a string; a run that is not interactive and has a `final` document never loads it,
516
+ * nor Ink, nor React. An import that fails degrades the run, as a render that throws does: one warning, and the next
517
+ * run tries the import again. A load that resolves to no view (neither a function nor a module whose `default` is
518
+ * one) is a programming error, and deterministic, so it is kept for the view's life: every run degrades without
519
+ * loading again, and the view warns once, saying what it received and what is expected, rather than once per run. A
520
+ * view function that happens to carry a `default` property is the view.
521
+ *
522
+ * The returned function is for `CliUi.live`'s `render` alone: called before its module has loaded, it throws.
523
+ *
524
+ * Keep the `LiveOptions` (the state, the events and the `lazyView` call) in a module the view does not import. The
525
+ * view module usually imports the state's types or its fold from somewhere; if that somewhere is the module that
526
+ * holds the `import("./view.js")`, the dynamic import closes a cycle, which Biome's `noImportCycles` reports even
527
+ * though it is lazy. A layout that stays acyclic: the model (state, events, fold) in one module, the view importing
528
+ * the model, and the options (with `lazyView`) in a third that imports the model and loads the view.
529
+ *
530
+ * ```ts
531
+ * // commands/sync.ts: no JSX, no React
532
+ * const view = yield* CliUi.live({
533
+ * events,
534
+ * initial,
535
+ * reduce,
536
+ * render: CliUi.lazyView(() => import("./sync-view.js")),
537
+ * final: (state) => [Doc.paragraph(`${state.done} synced`)],
538
+ * isStart,
539
+ * isTerminal,
540
+ * })
541
+ * ```
542
+ *
543
+ * @param load - resolves to the view, or to a module whose default export is the view
544
+ */
545
+ static readonly lazyView: <S>(load: () => Promise<((state: S, frame: number) => ReactElement) | {
546
+ readonly default: (state: S, frame: number) => ReactElement;
547
+ }>) => (state: S, frame: number) => ReactElement;
548
+ /**
549
+ * A screen whose answer is `f` of `screen`'s: it mounts `screen` and resolves with `f(value)` when `screen` resolves
550
+ * with `value`.
551
+ *
552
+ * @remarks
553
+ * Only the resolve is mapped. A cancel passes through unchanged, as the same `Cancelled`, and so does everything
554
+ * else about the screen: what it draws, its keys, a lazy load. `f` runs when the screen resolves; what it throws is
555
+ * thrown from the screen's resolve, so it is a defect of the run, as any other throw in a key handler is.
556
+ *
557
+ * The mapped screen is a `Screen` like any other, so it goes wherever a screen goes: `CliUi.run`, `CliUi.prompt`,
558
+ * `CliUi.fallback`, or around a `CliUi.lazy` one. The commonest use is a `Confirm` behind a boolean flag, where the
559
+ * fallback needs a `Screen<boolean>` and `Confirm` answers a whole `ConfirmResult`:
560
+ *
561
+ * ```ts
562
+ * const yes = Flag.Boolean("yes").pipe(
563
+ * Flag.withFallbackPrompt(
564
+ * CliUi.fallback(
565
+ * CliUi.map(Confirm.screen({ message: "Publish?" }), (result) => result.confirmed),
566
+ * { flag: "yes", otherwise: false },
567
+ * ),
568
+ * ),
569
+ * )
570
+ * ```
571
+ *
572
+ * @param screen - the screen to show
573
+ * @param f - turns its answer into the mapped screen's
574
+ */
575
+ static readonly map: <A, B>(screen: Screen<A>, f: (value: A) => B) => Screen<B>;
469
576
  }
470
577
  //#endregion
471
578
  //#region src/ui/UiKey.d.ts
@@ -1243,7 +1350,11 @@ export declare class Select {
1243
1350
  * Draw the select: the message, the list (the highlighted row in the accent token with the arrow glyph, disabled
1244
1351
  * rows muted, and at colour `none` ending in ` (disabled)` instead, every row cut to the width with the glyph set's
1245
1352
  * ellipsis), the highlighted choice's detail, and the
1246
- * key help. Enter calls `onSubmit` with the value; `q` cancels the screen with `"escape"`.
1353
+ * key help.
1354
+ *
1355
+ * @remarks
1356
+ * A choice's `detail` is drawn only while that choice is highlighted, as one muted line under the list, so the others'
1357
+ * details are not on screen until the cursor reaches them. Enter calls `onSubmit` with the value; `q` cancels the screen with `"escape"`.
1247
1358
  *
1248
1359
  * Single-shot: the choices and the starting choice are read once, when the view mounts, and later changes to them
1249
1360
  * are ignored; after a submit it stays as it is. Render a new view (a new screen) to ask again.
@@ -1358,9 +1469,9 @@ interface TextInputState {
1358
1469
  /** The text. */
1359
1470
  readonly value: string;
1360
1471
  /**
1361
- * The insertion point, from 0 to the value's length, in UTF-16 code units, always on a code-point boundary: an
1362
- * astral character (an emoji) is never split. Editing is by code point, not by grapheme, so a character built
1363
- * from several code points (a flag, a family emoji) is still crossed one code point at a time.
1472
+ * The insertion point, from 0 to the value's length, in UTF-16 code units. Left, right, backspace and delete move
1473
+ * and delete by grapheme, so a character built from several code points (an emoji, a flag, a letter with a
1474
+ * combining accent) is crossed and deleted whole, and the cursor never stops inside one it moved over.
1364
1475
  */
1365
1476
  readonly cursor: number;
1366
1477
  /** Whether enter was pressed; the view submits only when the value also validates. */
@@ -1387,8 +1498,30 @@ interface TextInputScreenOptions {
1387
1498
  readonly initial?: string;
1388
1499
  /** Shown, muted, while the value is empty. */
1389
1500
  readonly placeholder?: string;
1390
- /** Returns a message when the value cannot be submitted, or `undefined` when it can. */
1501
+ /** Returns a message when the value cannot be submitted, or `undefined` when it can; given the real text. */
1391
1502
  readonly validate?: (value: string) => string | undefined;
1503
+ /**
1504
+ * Draw the value masked, so a secret typed or pasted into it is never drawn: one mask per grapheme, so an emoji, a
1505
+ * flag or a letter with a combining accent is one mask, not one per code unit. `true` masks with `•` (`*` under ASCII
1506
+ * glyphs); a string masks with that string, its controls removed. A predicate, `(value) => boolean`, is asked with
1507
+ * the real value and masks with `•` from the first render it answers `true`, and then LATCHES: the value stays masked
1508
+ * through every edit after (deleting a pasted token's first character never redraws the rest in clear) until the
1509
+ * value is cleared to empty. So a field that holds an address (an `op://` reference) stays readable while typed and
1510
+ * hides a value the moment it looks like a token. Match a giveaway ANYWHERE in the value, never as a prefix:
1511
+ * `(value) => /gh[pousr]_|github_pat_/.test(value)` masks `op://v/` followed by a pasted token, which a prefix
1512
+ * match never would. Frames drawn before it first answered `true` showed the text typed so far; a paste arrives
1513
+ * whole, so a pasted token is masked from its first frame. Unmasked by default.
1514
+ *
1515
+ * @remarks
1516
+ * Only the drawing changes: `validate` and the resolved value get the real text, the cursor moves through it as
1517
+ * before, and the placeholder still shows while the value is empty. A masked frame never holds the text, so neither
1518
+ * does the scrollback; erase the frame as well with `clear: true` on the run when even the mask's length should not
1519
+ * stay behind.
1520
+ *
1521
+ * The message `validate` returns is drawn as it is, unmasked: a message that echoes the value (`"ghp_abc is a
1522
+ * token"`) draws the secret in the frame. Say what is wrong without quoting the value.
1523
+ */
1524
+ readonly mask?: string | true | ((value: string) => boolean);
1392
1525
  }
1393
1526
  /**
1394
1527
  * Props of {@link TextInput.View}.
@@ -1435,8 +1568,8 @@ export declare class TextInput {
1435
1568
  static readonly init: (options?: TextInputInitOptions) => TextInputState;
1436
1569
  /**
1437
1570
  * Apply a key: a typed character (any, `q` included) or space is inserted at the cursor; backspace and delete
1438
- * remove around it; left, right, home and end move it, clamped to the text; enter marks it submitted. Every other
1439
- * key changes nothing.
1571
+ * remove the grapheme before or after it; left and right move it a grapheme, home and end to either end, clamped
1572
+ * to the text; enter marks it submitted. Every other key changes nothing.
1440
1573
  *
1441
1574
  * @param state - where the input is
1442
1575
  * @param key - the key pressed
@@ -1444,7 +1577,8 @@ export declare class TextInput {
1444
1577
  static readonly step: (state: TextInputState, key: UiKey) => TextInputState;
1445
1578
  /**
1446
1579
  * Draw the input: the message, the value with the cursor shown as `▏` (`|` under ASCII glyphs, so it stays visible
1447
- * without colour), the placeholder while empty, a validation message in the error token, and the key help. Enter
1580
+ * without colour), or one mask per grapheme in its place with `mask`, the placeholder while empty, a validation
1581
+ * message in the error token, and the key help. Enter
1448
1582
  * submits when `validate` passes; otherwise its message is shown until the next key other than enter, or a paste.
1449
1583
  *
1450
1584
  * @remarks
@@ -1459,7 +1593,15 @@ export declare class TextInput {
1459
1593
  /**
1460
1594
  * A ready-made screen for `CliUi.run`: the input, resolving with the submitted text.
1461
1595
  *
1462
- * @param options - the message, the starting text, the placeholder and the validator
1596
+ * @remarks
1597
+ * With `mask`, a secret is drawn as one mask per grapheme and never as itself, while the screen still resolves with
1598
+ * the real text:
1599
+ *
1600
+ * ```ts
1601
+ * const token = CliUi.run(TextInput.screen({ message: "Token reference?", mask: true }), { clear: true })
1602
+ * ```
1603
+ *
1604
+ * @param options - the message, the starting text, the placeholder, the validator and the mask
1463
1605
  */
1464
1606
  static readonly screen: (options: TextInputScreenOptions) => Screen<string>;
1465
1607
  }