@effected/cli 0.10.0 → 0.12.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.
Files changed (92) hide show
  1. package/Cancelled.js +44 -0
  2. package/CliAudience.js +178 -0
  3. package/CliColor.js +13 -19
  4. package/CliEnv.js +89 -0
  5. package/CliExit.js +1 -1
  6. package/CliFailure.js +302 -0
  7. package/CliInteractive.js +71 -0
  8. package/CliLinks.js +154 -0
  9. package/CliLog.js +346 -0
  10. package/CliLogger.js +34 -33
  11. package/CliMessage.js +80 -0
  12. package/CliPrompt.js +104 -0
  13. package/CliRuntime.js +110 -54
  14. package/CliTest.js +16 -0
  15. package/CliTheme.js +141 -0
  16. package/ConfigIssueRenderer.js +14 -33
  17. package/Doc.js +536 -0
  18. package/Fmt.js +133 -0
  19. package/GithubAnnotation.js +40 -0
  20. package/Glyphs.js +83 -0
  21. package/NotInteractive.js +42 -0
  22. package/README.md +145 -131
  23. package/Render.js +255 -0
  24. package/SchemaIssueRenderer.js +7 -10
  25. package/Status.js +166 -0
  26. package/TestTerminal.js +80 -0
  27. package/Token.js +69 -0
  28. package/index.d.ts +3089 -169
  29. package/index.js +19 -1
  30. package/internal/ansi.js +230 -0
  31. package/internal/autoFormat.js +34 -0
  32. package/internal/canPrompt.js +15 -0
  33. package/internal/counts.js +84 -0
  34. package/internal/diagnostics.js +32 -0
  35. package/internal/displayWidth.js +35 -0
  36. package/internal/failureTarget.js +195 -0
  37. package/internal/fallbackAnswer.js +18 -0
  38. package/internal/fileSink.js +62 -0
  39. package/internal/format.js +62 -7
  40. package/internal/layout.js +250 -0
  41. package/internal/linkScheme.js +30 -0
  42. package/internal/linkTarget.js +50 -0
  43. package/internal/logSafety.js +46 -0
  44. package/internal/renderAnsi.js +52 -0
  45. package/internal/renderDoc.js +320 -0
  46. package/internal/renderGithubLog.js +46 -0
  47. package/internal/renderMarkdown.js +368 -0
  48. package/internal/renderPlain.js +50 -0
  49. package/internal/scanAudience.js +106 -0
  50. package/internal/splitFrame.js +56 -0
  51. package/internal/wizardGate.js +18 -0
  52. package/package.json +40 -5
  53. package/testing.d.ts +88 -2
  54. package/testing.js +2 -1
  55. package/ui/CliUi.js +432 -0
  56. package/ui/CliUiLive.js +446 -0
  57. package/ui/Confirm.js +245 -0
  58. package/ui/DocView.js +74 -0
  59. package/ui/KeyHelp.js +62 -0
  60. package/ui/KeyTable.js +199 -0
  61. package/ui/MultiSelect.js +260 -0
  62. package/ui/Select.js +230 -0
  63. package/ui/Tabs.js +202 -0
  64. package/ui/TextInput.js +290 -0
  65. package/ui/Toggle.js +32 -0
  66. package/ui/UiKey.js +44 -0
  67. package/ui/UiProvider.js +60 -0
  68. package/ui/UiStreams.js +18 -0
  69. package/ui/UiTheme.js +119 -0
  70. package/ui/Viewport.js +204 -0
  71. package/ui/internal/ErrorBoundary.js +30 -0
  72. package/ui/internal/Holder.js +74 -0
  73. package/ui/internal/ScreenContext.js +52 -0
  74. package/ui/internal/UiProviders.js +21 -0
  75. package/ui/internal/ink.js +122 -0
  76. package/ui/internal/inkChalk.js +58 -0
  77. package/ui/internal/inkConsole.js +146 -0
  78. package/ui/internal/lazyView.js +74 -0
  79. package/ui/internal/lineText.js +19 -0
  80. package/ui/internal/mountPermit.js +16 -0
  81. package/ui/internal/perfDrain.js +33 -0
  82. package/ui/internal/processStreams.js +19 -0
  83. package/ui/internal/renderOptions.js +13 -0
  84. package/ui/testing/CliUiTest.js +760 -0
  85. package/ui/testing/fakeStreams.js +79 -0
  86. package/ui/testing/terminalModel.js +59 -0
  87. package/ui-testing-serializer.d.ts +14 -0
  88. package/ui-testing-serializer.js +33 -0
  89. package/ui-testing.d.ts +527 -0
  90. package/ui-testing.js +3 -0
  91. package/ui.d.ts +1790 -0
  92. package/ui.js +17 -0
package/testing.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Effect, FileSystem, Path, PlatformError, Scope } from "effect";
1
+ import { Effect, FileSystem, Layer, Path, PlatformError, Scope, Terminal } from "effect";
2
2
  import { ChildProcessSpawner } from "effect/process";
3
3
  //#region src/CliTest.d.ts
4
4
  /**
@@ -57,6 +57,22 @@ interface RunResult {
57
57
  /**
58
58
  * Spawn a built CLI bin hermetically and read its exit code and streams as data.
59
59
  *
60
+ * @example
61
+ * ```ts
62
+ * import * as NodeServices from "@effect/platform-node/NodeServices"
63
+ * import { assert, it } from "@effect/vitest"
64
+ * import { CliTest } from "@effected/cli/testing"
65
+ * import { Effect } from "effect"
66
+ *
67
+ * it.effect("prints its version", () =>
68
+ * Effect.gen(function* () {
69
+ * const sandbox = yield* CliTest.sandbox({ path: process.env.PATH ?? "" })
70
+ * const result = yield* CliTest.run("dist/bin.js", ["--version"], { sandbox, execPath: process.execPath })
71
+ * assert.strictEqual(result.exitCode, 0)
72
+ * }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
73
+ * )
74
+ * ```
75
+ *
60
76
  * @public
61
77
  */
62
78
  export declare class CliTest {
@@ -90,5 +106,75 @@ export declare class CliTest {
90
106
  static readonly run: (bin: string, args: ReadonlyArray<string>, options: RunOptions) => Effect.Effect<RunResult, PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner>;
91
107
  }
92
108
  //#endregion
93
- export type { RunOptions, RunResult, Sandbox };
109
+ //#region src/TestTerminal.d.ts
110
+ /**
111
+ * One key press for {@link TestTerminal}.
112
+ *
113
+ * @public
114
+ */
115
+ interface KeyInput {
116
+ /** The key name the prompts switch on: `down`, `up`, `enter`, `space`, `escape`, or a character. */
117
+ readonly name: string;
118
+ /** Whether Ctrl is held. */
119
+ readonly ctrl?: boolean | undefined;
120
+ /** Whether Meta is held. */
121
+ readonly meta?: boolean | undefined;
122
+ /** Whether Shift is held. */
123
+ readonly shift?: boolean | undefined;
124
+ }
125
+ /**
126
+ * What {@link TestTerminal.make} builds: the `Terminal` layer and the means to drive and inspect it.
127
+ *
128
+ * @public
129
+ */
130
+ interface TestTerminalHandle {
131
+ /** Provides `Terminal` backed by this double. */
132
+ readonly layer: Layer.Layer<Terminal.Terminal>;
133
+ /** Queue key presses, as if the user had typed them. */
134
+ readonly input: (keys: ReadonlyArray<KeyInput>) => Effect.Effect<void>;
135
+ /** Queue `text` one character at a time. */
136
+ readonly type: (text: string) => Effect.Effect<void>;
137
+ /** End the input, as Ctrl-C or end-of-file does: a prompt waiting for a key is quit. */
138
+ readonly end: Effect.Effect<void>;
139
+ /** Everything written to the terminal so far, prompt frames and escape codes included. */
140
+ readonly output: Effect.Effect<string>;
141
+ /** How many queued key presses nobody has taken yet. */
142
+ readonly pending: Effect.Effect<number>;
143
+ /**
144
+ * What the program did with the input: `keys` taken from it, `lines` read with `readLine`, and
145
+ * `subscriptions` to `readInput`. All `0` proves a code path never touched the terminal's input. Counting the
146
+ * subscription matters: on a real terminal merely subscribing attaches a reader to stdin, even when no key is
147
+ * ever taken.
148
+ */
149
+ readonly reads: Effect.Effect<{
150
+ readonly keys: number;
151
+ readonly lines: number;
152
+ readonly subscriptions: number;
153
+ }>;
154
+ }
155
+ /**
156
+ * A scripted `Terminal` for testing prompts and anything that reads the terminal.
157
+ *
158
+ * @remarks
159
+ * Queue keys with `input` or `type`, run the program under `layer`, then read `output`. To assert that a code path
160
+ * did NOT touch the terminal, queue some keys first and check `reads` is all zero (no subscription, no key, no
161
+ * line) and `pending` is unchanged afterwards. Only
162
+ * available from `@effected/cli/testing`.
163
+ *
164
+ * @public
165
+ */
166
+ export declare class TestTerminal {
167
+ private constructor();
168
+ /**
169
+ * Build a test terminal.
170
+ *
171
+ * @param options - the reported size; 80 by 24 by default
172
+ */
173
+ static readonly make: (options?: {
174
+ readonly columns?: number | undefined;
175
+ readonly rows?: number | undefined;
176
+ }) => Effect.Effect<TestTerminalHandle>;
177
+ }
178
+ //#endregion
179
+ export type { KeyInput, RunOptions, RunResult, Sandbox, TestTerminalHandle };
94
180
  //# sourceMappingURL=testing.d.ts.map
package/testing.js CHANGED
@@ -1,3 +1,4 @@
1
1
  import { CliTest } from "./CliTest.js";
2
+ import { TestTerminal } from "./TestTerminal.js";
2
3
 
3
- export { CliTest };
4
+ export { CliTest, TestTerminal };
package/ui/CliUi.js ADDED
@@ -0,0 +1,432 @@
1
+ import { Cancelled } from "../Cancelled.js";
2
+ import { CliInteractive } from "../CliInteractive.js";
3
+ import { underGithubActions } from "../internal/autoFormat.js";
4
+ import { CliTheme } from "../CliTheme.js";
5
+ import { NotInteractive } from "../NotInteractive.js";
6
+ import { answerWithoutPerson } from "../internal/fallbackAnswer.js";
7
+ import { inkModules, loadInk, withInkColour } from "./internal/ink.js";
8
+ import { errorBoundary } from "./internal/ErrorBoundary.js";
9
+ import { UiStreams } from "./UiStreams.js";
10
+ import { lazyView } from "./internal/lazyView.js";
11
+ import { mountPermit } from "./internal/mountPermit.js";
12
+ import { UiRenderOptions } from "./internal/renderOptions.js";
13
+ import { useScreenGuard } from "./internal/ScreenContext.js";
14
+ import { uiProviders } from "./internal/UiProviders.js";
15
+ import { live } from "./CliUiLive.js";
16
+ import { KeyTable, useKeys } from "./KeyTable.js";
17
+ import { Cause, Deferred, Effect, Exit, Option, Semaphore } from "effect";
18
+ import { Audience } from "@effected/env";
19
+ import { Prompt } from "effect/cli";
20
+
21
+ //#region src/ui/CliUi.ts
22
+ /**
23
+ * stdout's theme as the audience sees it: colourless for an agent (as `Render.context` makes it), so a screen's
24
+ * `useTheme`, `Styled` and the widgets' colour-none markers never carry an escape for one. `Audience` is read only when
25
+ * provided, so it stays out of the requirements.
26
+ */
27
+ const audienceTheme = Effect.gen(function* () {
28
+ const audience = yield* Effect.serviceOption(Audience);
29
+ return CliTheme.forAudience((yield* CliTheme).forStream("stdout"), Option.isSome(audience) ? audience.value.kind : void 0);
30
+ });
31
+ /** The root keys: Esc cancels with `"escape"`, Ctrl-C with `"interrupt"`. `q` belongs to widgets, never here. */
32
+ const RootKeys = (props) => {
33
+ useKeys(KeyTable.root, props.cancel);
34
+ const guard = useScreenGuard();
35
+ inkModules().ink.usePaste(guard(() => void 0));
36
+ return props.children;
37
+ };
38
+ const SCREEN_EXITED = "@effected/cli/ui: the screen exited without resolving or cancelling";
39
+ const mount = (screen, theme, clear, crash, neutralize) => Effect.gen(function* () {
40
+ const overrides = yield* UiRenderOptions;
41
+ yield* Effect.acquireRelease(Effect.sync(() => overrides.onMount?.("screen")), (_, exit) => Effect.sync(() => {
42
+ const died = Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isDieReason) : void 0;
43
+ const interrupted = Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause);
44
+ overrides.onUnmount?.(died !== void 0 ? { defect: died.defect } : interrupted ? void 0 : crash.current);
45
+ }));
46
+ const { ink, react } = yield* loadInk;
47
+ const streams = yield* UiStreams;
48
+ yield* withInkColour(theme.color);
49
+ const result = yield* Deferred.make();
50
+ const control = {
51
+ resolve: (value) => {
52
+ Deferred.doneUnsafe(result, Exit.succeed(value));
53
+ },
54
+ cancel: (reason) => {
55
+ Deferred.doneUnsafe(result, Exit.fail(new Cancelled({ reason })));
56
+ }
57
+ };
58
+ const element = yield* Effect.promise(async () => screen(control));
59
+ const die = (error) => {
60
+ if (crash.current === void 0) crash.current = { defect: error };
61
+ Deferred.doneUnsafe(result, Exit.die(error));
62
+ };
63
+ const tree = react.createElement(errorBoundary(), {
64
+ onError: die,
65
+ children: uiProviders({
66
+ cancel: control.cancel,
67
+ die,
68
+ theme,
69
+ glyphs: theme.glyphs,
70
+ ...neutralize ? { neutralizeWorkflowCommands: true } : {}
71
+ }, react.createElement(RootKeys, {
72
+ cancel: control.cancel,
73
+ children: element
74
+ }))
75
+ });
76
+ const instance = yield* Effect.acquireRelease(Effect.sync(() => ink.render(tree, {
77
+ stdin: streams.stdin,
78
+ stdout: streams.stdout,
79
+ stderr: streams.stderr,
80
+ interactive: true,
81
+ exitOnCtrlC: false,
82
+ patchConsole: false,
83
+ ...overrides.debug === true ? { debug: true } : {},
84
+ ...overrides.onRender === void 0 ? {} : { onRender: overrides.onRender },
85
+ ...overrides.maxFps === void 0 ? {} : { maxFps: overrides.maxFps }
86
+ })), (instance) => Effect.promise(async () => {
87
+ if (clear) instance.clear();
88
+ const exited = instance.waitUntilExit();
89
+ instance.unmount();
90
+ await exited.catch(() => void 0);
91
+ }));
92
+ const exited = Effect.tryPromise({
93
+ try: () => instance.waitUntilExit(),
94
+ catch: (cause) => cause
95
+ }).pipe(Effect.orDie, Effect.flatMap(() => Effect.flatMap(Deferred.isDone(result), (done) => done ? Deferred.await(result) : Effect.die(/* @__PURE__ */ new Error(SCREEN_EXITED)))));
96
+ return yield* Effect.raceFirst(Deferred.await(result), exited);
97
+ });
98
+ /**
99
+ * Interactive screens drawn with Ink, mounted as scoped resources.
100
+ *
101
+ * @example
102
+ * ```ts
103
+ * import { CliUi, Confirm } from "@effected/cli/ui"
104
+ * import { Effect } from "effect"
105
+ *
106
+ * // Answers `otherwise` when there is no person to ask, and fails with `Cancelled` when they back out.
107
+ * const ask = CliUi.prompt(Confirm.screen({ message: "Overwrite the config?" }), {
108
+ * otherwise: { confirmed: false, toggles: {} },
109
+ * })
110
+ * ```
111
+ *
112
+ * @public
113
+ */
114
+ var CliUi = class CliUi {
115
+ constructor() {}
116
+ /**
117
+ * Mount `screen` and wait for it to resolve or cancel.
118
+ *
119
+ * @remarks
120
+ * When `CliInteractive` is false it fails with `NotInteractive` and mounts nothing; Ink and React are not even
121
+ * loaded. Otherwise it loads them, holds Ink's colour level at stdout's, and mounts the screen on
122
+ * `UiStreams` with Ink's own Ctrl-C exit off: Ctrl-C cancels with `"interrupt"` and Esc with `"escape"`.
123
+ *
124
+ * Mounting is one scoped resource. However the screen ends (resolved, cancelled, crashed, or the fiber
125
+ * interrupted), it is unmounted, raw mode and bracketed paste are off, the cursor is shown, and the colour level is
126
+ * restored. A
127
+ * component that throws is a defect, never a hang or a typed failure, and nothing of Ink's crash screen reaches
128
+ * stdout. So is a `useKeys` handler that throws; a handler a consumer registers with Ink's own `useInput` or
129
+ * `usePaste` is outside the kit, and what it throws escapes as Ink leaves it. A crash wins over an end in the same
130
+ * tick: a handler that cancels or resolves and then throws, or a component that throws before the screen has
131
+ * unmounted, is a defect, never the `Cancelled` or the value; a defect raised while the screen unmounts stays beside
132
+ * that crash in the cause rather than replacing it. An interrupt stays an interrupt, even when the tree reports a
133
+ * crash as it unmounts.
134
+ *
135
+ * A screen draws on stdout (`UiStreams`), and mounts only when `CliInteractive` is true: a human audience, a
136
+ * terminal on both stdin and stdout, and a `TERM` that is not `dumb`. `CliInteractive` reads `false` until a layer
137
+ * sets it (`CliRuntime.main`'s `env` does), so a program that never provides one always gets `NotInteractive`.
138
+ *
139
+ * Screens run one at a time, process-wide: Ink owns raw mode on the one terminal, so a second `run` waits until the
140
+ * first is released; so does a `run` while a {@link CliUi.live} view has a run drawn. A screen that itself awaits
141
+ * another `CliUi.run` therefore deadlocks, and nothing guards against it.
142
+ *
143
+ * Do not log while a screen is mounted. Ink redraws its frame by counting the lines it last wrote, and it is
144
+ * mounted with `patchConsole` off, so a line written to the terminal from elsewhere (an `Effect.log`, `CliLog`, a
145
+ * background fiber) lands inside the frame and tears it. Log before the screen mounts or after it resolves.
146
+ *
147
+ * With `clear` the last frame is erased as the screen unmounts, so a wizard of several screens leaves only what the
148
+ * program prints; without it the last frame stays, with the highlight where the answer was.
149
+ *
150
+ * @param screen - builds the element to mount from its {@link ScreenControl}
151
+ * @param options - whether to erase the last frame
152
+ */
153
+ static run = (screen, options) => Effect.gen(function* () {
154
+ if (!(yield* CliInteractive)) return yield* Effect.fail(new NotInteractive());
155
+ const theme = yield* audienceTheme;
156
+ const neutralize = yield* underGithubActions;
157
+ const crash = { current: void 0 };
158
+ const exit = yield* Effect.exit(Semaphore.withPermit(mountPermit, Effect.scoped(mount(screen, theme, options?.clear === true, crash, neutralize))));
159
+ const crashed = crash.current;
160
+ const interrupted = Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause);
161
+ if (crashed === void 0 || interrupted) return yield* exit;
162
+ const dies = Exit.isFailure(exit) ? exit.cause.reasons.filter(Cause.isDieReason) : [];
163
+ if (dies.some((reason) => reason.defect === crashed.defect)) return yield* exit;
164
+ return yield* Effect.failCause(Cause.fromReasons([Cause.makeDieReason(crashed.defect), ...dies]));
165
+ });
166
+ /**
167
+ * The kit's context for an Ink tree the kit did not mount: stdout's theme and glyph set.
168
+ *
169
+ * @remarks
170
+ * Hand it to {@link UiProvider}. It loads Ink and React, as a screen's mount does, so the provider and the kit's
171
+ * hooks can render; a missing peer is a defect naming both. It is the only way to get a `UiContextValue`.
172
+ *
173
+ * Ink loads asynchronously, so this is an `Effect` that must run before the first render. A renderer that is itself
174
+ * synchronous (a test helper, a report-time `renderToString`) runs it once, with a top-level `await` at module
175
+ * scope, and then renders synchronously from the value as often as it likes:
176
+ *
177
+ * ```ts
178
+ * const value = await Effect.runPromise(CliUi.context.pipe(Effect.provide(themeLayer)))
179
+ *
180
+ * // later, synchronously:
181
+ * const text = renderToString(createElement(UiProvider, { value: { ...value, size: { columns, rows } } }, tree), {
182
+ * columns,
183
+ * })
184
+ * ```
185
+ */
186
+ static context = Effect.gen(function* () {
187
+ const theme = yield* audienceTheme;
188
+ const neutralize = yield* underGithubActions;
189
+ yield* loadInk;
190
+ return {
191
+ "~@effected/cli/ui/UiContextValue": true,
192
+ theme,
193
+ glyphs: theme.glyphs,
194
+ ...neutralize ? { neutralizeWorkflowCommands: true } : {}
195
+ };
196
+ });
197
+ /**
198
+ * A live view over a stream: fold `events` into state, and draw it with Ink while a run is going, for the caller's
199
+ * scope.
200
+ *
201
+ * @remarks
202
+ * `events` is a `PubSub` subscription or a stream, folded in a fiber of the caller's scope. A subscription, made
203
+ * before the first publish, is the surest: nothing published after it is missed. `live` makes a stream's first pull
204
+ * before it returns, so one that subscribes on its first pull without forking (`Stream.fromPubSub`) is subscribed by
205
+ * then; one that forks its upstream (`Stream.merge`, `buffer`, a concurrent `flatMap`) subscribes later, and loses
206
+ * what is published before.
207
+ *
208
+ * `live` returns its handle at once, before Ink has loaded: it loads Ink when a run first mounts (or, when not
209
+ * interactive, when an owned run without a `final` prints its final frame), and it waits on nothing asynchronous before returning, so
210
+ * a host outside Effect can take the handle with `Effect.runSync`. The handle works from the start: a `close`
211
+ * before any run has mounted folds what is queued and ends the view as the events ending would, waiting for a mount
212
+ * already under way, and one with no run to end loads nothing.
213
+ *
214
+ * End the view with `handle.close`: it stops taking events, folds what is still queued (a subscription's queued
215
+ * messages included), commits or prints the run as the events ending would, and waits for `done`. Then close the
216
+ * scope. A publisher may instead end a subscription with `PubSub.end(pubsub, last)`, which keeps everything: the view
217
+ * folds what is buffered and `last` once, then ends. A `PubSub.shutdown` drops what the view has not taken yet, and
218
+ * closing the scope by itself stops the fold at once: both lose a run's tail. Closing the scope unmounts whatever is drawn: the terminal is restored (the
219
+ * cursor shown, Ink's colour level put back) and nothing more is written. `done` completes when the events end.
220
+ *
221
+ * A run begins at an `isStart` event (or wherever `begins` says, given the state before and after the event) and ends
222
+ * at an `isTerminal` event; an event while no run is going that begins none is folded and not drawn, so what a program
223
+ * reports after a run ends never mounts a second copy of it. A run mounts the view; its end unmounts it, which leaves
224
+ * its last frame on the terminal, and the next run mounts afresh below it. A start while a run is drawn redraws in
225
+ * place: the frame is never cleared, so nothing above it is erased. The state is never reset by the kit: a reducer that wants a fresh run
226
+ * resets it on the start.
227
+ *
228
+ * The frame is at most the terminal's rows less one, re-read on every render and on a resize, so a tall frame never
229
+ * makes Ink wipe the scrollback; its width is Ink's own. The clamp
230
+ * lags one paint when the terminal gets shorter: Ink re-lays out and repaints the tree it already has on a resize,
231
+ * before React re-renders with the new row count, so a frame already at the old height can be drawn once taller
232
+ * than the terminal, which Ink answers by clearing the screen and its scrollback. Only a shrink in height while
233
+ * the frame is at its full height does it; a frame that keeps a few rows spare never meets it.
234
+ *
235
+ * While a run is drawn the view also redraws on a tick of `tickMillis` (80 by default), a schedule in the run's
236
+ * scope: interrupted with the run or the scope, it never outlives them. Its timer is not unref'd, so while a run is
237
+ * drawn it keeps the process alive: the run's terminal event, or the scope's close, is what lets the process exit.
238
+ * The frame index never steps back. Events that arrive at
239
+ * once, in one chunk or in several the view had not yet caught up with, are folded together and drawn once. The view
240
+ * takes events from `events` as fast as the stream yields them, so a stream that applies backpressure buffers in the
241
+ * view while it draws.
242
+ *
243
+ * A run whose drawing fails (a `render` that throws, or a mount that fails) degrades rather than ending the view:
244
+ * it is unmounted, leaving its last good frame on the terminal, then one warning is logged (`Effect.logWarning`),
245
+ * and the fold goes on. At its terminal event, a run with no frame left on the terminal (it never painted, or its
246
+ * last good frame threw too) writes its final frame once, as a string. The next run mounts afresh, and so does a
247
+ * start that comes while a degraded run is going: it ends that run as its terminal event would. A `reduce` that
248
+ * throws, or an `events` stream that dies, unmounts the run, then `done` dies with the error.
249
+ *
250
+ * When the run is not interactive, nothing is mounted and Ink is loaded only when a string is due. In the `owned`
251
+ * mode (the default) each run's final frame is written once to stdout, as a string laid out at stdout's width (80
252
+ * when it reports none) with no height to fit, at its terminal event or when the stream ends. It is escape-free at
253
+ * colour `none`, and for an agent audience (`Audience`, when provided) whatever the terminal could do. With a
254
+ * `final` document, that document is printed instead, once per run, rendered as `Doc.print` renders it, and Ink,
255
+ * React and a `CliUi.lazyView` module are never loaded: an agent, CI or piped run of a command with a live view pays
256
+ * for none of them. In the `hosted` mode nothing is written.
257
+ *
258
+ * Keep React off the runs that never draw (`--help`, a usage error) with `render: CliUi.lazyView(() => import(...))`.
259
+ *
260
+ * No input is mounted: the view reads no keys and never enters raw mode, so Ctrl-C stays the platform's SIGINT,
261
+ * which interrupts the program and so closes the scope. Each run holds the process-wide mount permit from its
262
+ * mount to its end, so a `CliUi.run` during a run waits for the run to end, and one between runs mounts at once.
263
+ *
264
+ * While a run is drawn, write logs through `logConsole`, provided around the work the view reports on: its lines
265
+ * land above the frame. A line written to the terminal any other way tears the frame.
266
+ *
267
+ * The view draws on stdout (`UiStreams`), at stdout's colour level and glyphs, and mounts only when the run is
268
+ * interactive (`CliInteractive`).
269
+ *
270
+ * @param options - the events, the fold, the drawing, and what starts and ends a run
271
+ */
272
+ static live = live;
273
+ /**
274
+ * Run `screen` from a handler when the run is interactive; otherwise answer with `otherwise`, or fail with
275
+ * `NotInteractive` when there is none.
276
+ *
277
+ * @remarks
278
+ * `CliUi.run` with a default: not interactive, it returns `otherwise` and Ink and React are never loaded.
279
+ * Interactive, it mounts the screen, and a cancel is the typed `Cancelled` a handler can catch, which
280
+ * `CliRuntime.main` otherwise renders as one line with exit `130`. A missing Ink in an interactive run is a defect
281
+ * naming the peers, never a silent `otherwise`. Screens in sequence make a wizard: discover the defaults first,
282
+ * pass each as an `otherwise`, and a non-interactive run returns exactly them.
283
+ *
284
+ * Without `otherwise`, `NotInteractive` stays in the error type even after the caller checked `CliInteractive`, since
285
+ * the type cannot know. Catch the tag and fail with `CliError.UserError` to exit as a usage error (`64` under
286
+ * `CliRuntime.main`).
287
+ *
288
+ * As with `CliUi.run`, do not log while the screen is mounted: a line written to the terminal from elsewhere tears
289
+ * the frame.
290
+ *
291
+ * @param screen - the screen to show
292
+ * @param options - the non-interactive default, and whether to erase the last frame
293
+ */
294
+ static prompt = (screen, options) => CliUi.run(screen, options?.clear === true ? { clear: true } : void 0).pipe(Effect.catchTag("NotInteractive", (error) => {
295
+ const otherwise = options?.otherwise;
296
+ return otherwise === void 0 ? Effect.fail(error) : Effect.succeed(otherwise);
297
+ }));
298
+ /**
299
+ * A fallback for `Flag.withFallbackPrompt` or `Argument.withFallbackPrompt` that shows a screen when the run is
300
+ * interactive: `CliPrompt.fallback` for screens.
301
+ *
302
+ * @remarks
303
+ * Interactive, the screen mounts and its answer is the parameter's value. Not interactive, `otherwise` is used when
304
+ * given and Ink and React are never loaded; without it the parameter fails as missing, exactly as with no fallback,
305
+ * so core renders its own message and `CliRuntime.main` exits `64`. Name the parameter with `flag` (the name
306
+ * without dashes) or `argument` so that error can be built.
307
+ *
308
+ * The options are {@link @effected/cli!CliPromptFallbackOptions}, the same as `CliPrompt.fallback`'s, and `clear`
309
+ * as for {@link CliUi.run}.
310
+ *
311
+ * It runs during parsing, whose environment is core's alone, so it reads `CliTheme` if one is there: with
312
+ * `CliRuntime.main`'s `env` (`CliEnv.layer`), or provided around the program. With no theme it treats the run as
313
+ * not interactive, and when `CliInteractive` is on it says so once, at debug level. Interactivity is
314
+ * `CliInteractive`, which an audience flag can set before parsing under `CliAudience`.
315
+ *
316
+ * As with `CliPrompt.fallback`, the screen runs here rather than being handed to core, whose fallback runner
317
+ * turns a quit into the missing-parameter error, which would exit `64`. A cancel (Esc, Ctrl-C) is `Cancelled`,
318
+ * raised as a defect because core's parse step turns every typed failure into a usage error, so only
319
+ * `CliRuntime.main` (or `CliRuntime.reportFailures`) renders it, as one line with exit `130`. A missing Ink in an
320
+ * interactive run is a defect naming the peers, never a silent `otherwise`.
321
+ *
322
+ * After the screen has unmounted, core still runs the answered `Prompt.succeed` it is handed against the
323
+ * terminal, exactly as it does for `CliPrompt.fallback`: `Prompt.run` opens the terminal's input in a scope (on
324
+ * Node a readline over stdin, in raw mode) before looking at the prompt. That is harmless. The prompt is already
325
+ * answered, so nothing is read and no key is waited for; the scope closes at once, restoring the mode and closing
326
+ * the reader; and Ink has already let go of stdin, so the two never hold it together. Not interactive, the screen
327
+ * never mounts, and `CliPrompt.gateTerminal`, which `CliEnv.layer` installs, keeps that subscription off the real
328
+ * terminal altogether.
329
+ *
330
+ * @param screen - the screen to show
331
+ * @param options - the parameter it stands in for, the non-interactive default, and whether to erase the last frame
332
+ */
333
+ static fallback = (screen, options) => {
334
+ let explained = false;
335
+ return Effect.gen(function* () {
336
+ const theme = yield* Effect.serviceOption(CliTheme);
337
+ if (Option.isNone(theme)) {
338
+ if (!explained && (yield* CliInteractive)) {
339
+ explained = true;
340
+ const name = "flag" in options ? `--${options.flag}` : `<${options.argument}>`;
341
+ yield* Effect.logDebug(`@effected/cli/ui: CliUi.fallback for ${name} answered without its screen: CliInteractive is on, but no CliTheme is provided around parsing (CliRuntime.main's env provides one)`);
342
+ }
343
+ return yield* answerWithoutPerson(options);
344
+ }
345
+ return yield* CliUi.run(screen, options.clear === true ? { clear: true } : void 0).pipe(Effect.provideService(CliTheme, theme.value), Effect.map((answer) => Prompt.succeed(answer)), Effect.catchTag("Cancelled", (cancelled) => Effect.die(cancelled)), Effect.catchTag("NotInteractive", () => answerWithoutPerson(options)));
346
+ });
347
+ };
348
+ /**
349
+ * A screen whose module is loaded only when it mounts, so importing the command that uses it loads neither the
350
+ * screen's own code nor React.
351
+ *
352
+ * @param load - imports the module whose default export is the screen
353
+ */
354
+ static lazy = (load) => async (control) => (await load()).default(control);
355
+ /**
356
+ * A live view's `render` whose module is loaded only when a run first draws it, so importing the command that uses
357
+ * the view loads neither the view's own code nor React: `CliUi.lazy` for `CliUi.live`.
358
+ *
359
+ * @remarks
360
+ * `load` resolves to the view, `(state, frame) => ReactElement`, exactly what `render` takes, so the frame index a
361
+ * spinner needs reaches it: either a module whose default export is the view (`() => import("./view.js")`), or the
362
+ * view itself (`() => import("./views.js").then((module) => module.syncView)`, for a named export).
363
+ *
364
+ * It is optional: `render` still takes the view directly, and needs no dynamic import. A view passed directly
365
+ * loads with the module that imports it, so it costs React on every run that loads that module; `lazyView` is
366
+ * 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
367
+ * prints a run's final frame as a string; a run that is not interactive and has a `final` document never loads it,
368
+ * nor Ink, nor React. An import that fails degrades the run, as a render that throws does: one warning, and the next
369
+ * run tries the import again. A load that resolves to no view (neither a function nor a module whose `default` is
370
+ * one) is a programming error, and deterministic, so it is kept for the view's life: every run degrades without
371
+ * loading again, and the view warns once, saying what it received and what is expected, rather than once per run. A
372
+ * view function that happens to carry a `default` property is the view.
373
+ *
374
+ * The returned function is for `CliUi.live`'s `render` alone: called before its module has loaded, it throws.
375
+ *
376
+ * Keep the `LiveOptions` (the state, the events and the `lazyView` call) in a module the view does not import. The
377
+ * view module usually imports the state's types or its fold from somewhere; if that somewhere is the module that
378
+ * holds the `import("./view.js")`, the dynamic import closes a cycle, which Biome's `noImportCycles` reports even
379
+ * though it is lazy. A layout that stays acyclic: the model (state, events, fold) in one module, the view importing
380
+ * the model, and the options (with `lazyView`) in a third that imports the model and loads the view.
381
+ *
382
+ * ```ts
383
+ * // commands/sync.ts: no JSX, no React
384
+ * const view = yield* CliUi.live({
385
+ * events,
386
+ * initial,
387
+ * reduce,
388
+ * render: CliUi.lazyView(() => import("./sync-view.js")),
389
+ * final: (state) => [Doc.paragraph(`${state.done} synced`)],
390
+ * isStart,
391
+ * isTerminal,
392
+ * })
393
+ * ```
394
+ *
395
+ * @param load - resolves to the view, or to a module whose default export is the view
396
+ */
397
+ static lazyView = lazyView;
398
+ /**
399
+ * A screen whose answer is `f` of `screen`'s: it mounts `screen` and resolves with `f(value)` when `screen` resolves
400
+ * with `value`.
401
+ *
402
+ * @remarks
403
+ * Only the resolve is mapped. A cancel passes through unchanged, as the same `Cancelled`, and so does everything
404
+ * else about the screen: what it draws, its keys, a lazy load. `f` runs when the screen resolves; what it throws is
405
+ * thrown from the screen's resolve, so it is a defect of the run, as any other throw in a key handler is.
406
+ *
407
+ * The mapped screen is a `Screen` like any other, so it goes wherever a screen goes: `CliUi.run`, `CliUi.prompt`,
408
+ * `CliUi.fallback`, or around a `CliUi.lazy` one. The commonest use is a `Confirm` behind a boolean flag, where the
409
+ * fallback needs a `Screen<boolean>` and `Confirm` answers a whole `ConfirmResult`:
410
+ *
411
+ * ```ts
412
+ * const yes = Flag.Boolean("yes").pipe(
413
+ * Flag.withFallbackPrompt(
414
+ * CliUi.fallback(
415
+ * CliUi.map(Confirm.screen({ message: "Publish?" }), (result) => result.confirmed),
416
+ * { flag: "yes", otherwise: false },
417
+ * ),
418
+ * ),
419
+ * )
420
+ * ```
421
+ *
422
+ * @param screen - the screen to show
423
+ * @param f - turns its answer into the mapped screen's
424
+ */
425
+ static map = (screen, f) => (control) => screen({
426
+ resolve: (value) => control.resolve(f(value)),
427
+ cancel: control.cancel
428
+ });
429
+ };
430
+
431
+ //#endregion
432
+ export { CliUi };