@effected/cli-kit 0.0.0-stage → 0.26.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 (103) hide show
  1. package/Cancelled.js +43 -0
  2. package/CliAudience.js +177 -0
  3. package/CliColor.js +59 -0
  4. package/CliEnv.js +101 -0
  5. package/CliExit.js +64 -0
  6. package/CliFailure.js +287 -0
  7. package/CliFormat.js +124 -0
  8. package/CliInteractive.js +94 -0
  9. package/CliLinks.js +153 -0
  10. package/CliLog.js +367 -0
  11. package/CliLogger.js +91 -0
  12. package/CliMessage.js +79 -0
  13. package/CliPrompt.js +110 -0
  14. package/CliRuntime.js +254 -0
  15. package/CliTest.js +206 -0
  16. package/CliTheme.js +142 -0
  17. package/ConfigIssueRenderer.js +63 -0
  18. package/Doc.js +548 -0
  19. package/Fmt.js +335 -0
  20. package/GithubAnnotation.js +39 -0
  21. package/Glyphs.js +82 -0
  22. package/LICENSE +21 -0
  23. package/NotInteractive.js +41 -0
  24. package/README.md +215 -2
  25. package/Render.js +280 -0
  26. package/SchemaIssueRenderer.js +47 -0
  27. package/Status.js +165 -0
  28. package/TestTerminal.js +101 -0
  29. package/Token.js +68 -0
  30. package/index.d.ts +3951 -0
  31. package/index.js +27 -0
  32. package/internal/ExitRequested.js +21 -0
  33. package/internal/HelpRouting.js +35 -0
  34. package/internal/ansi.js +229 -0
  35. package/internal/autoFormat.js +33 -0
  36. package/internal/canPrompt.js +14 -0
  37. package/internal/counts.js +83 -0
  38. package/internal/diagnostics.js +31 -0
  39. package/internal/displayPath.js +46 -0
  40. package/internal/displayWidth.js +42 -0
  41. package/internal/failureTarget.js +195 -0
  42. package/internal/fallbackAnswer.js +17 -0
  43. package/internal/fileSink.js +61 -0
  44. package/internal/format.js +175 -0
  45. package/internal/inspect.js +40 -0
  46. package/internal/isExitCode.js +11 -0
  47. package/internal/layout.js +249 -0
  48. package/internal/linkScheme.js +29 -0
  49. package/internal/linkTarget.js +49 -0
  50. package/internal/logSafety.js +45 -0
  51. package/internal/renderAnsi.js +51 -0
  52. package/internal/renderDoc.js +322 -0
  53. package/internal/renderGithubLog.js +59 -0
  54. package/internal/renderMarkdown.js +367 -0
  55. package/internal/renderPlain.js +49 -0
  56. package/internal/scanAudience.js +105 -0
  57. package/internal/splitFrame.js +55 -0
  58. package/internal/terminalCapabilities.js +23 -0
  59. package/internal/wizardGate.js +17 -0
  60. package/package.json +90 -5
  61. package/testing.d.ts +246 -0
  62. package/testing.js +4 -0
  63. package/tsdoc-metadata.json +11 -0
  64. package/ui/CliUi.js +507 -0
  65. package/ui/CliUiDetached.js +36 -0
  66. package/ui/CliUiLive.js +436 -0
  67. package/ui/CliUiRouteStream.js +72 -0
  68. package/ui/Confirm.js +244 -0
  69. package/ui/DocView.js +76 -0
  70. package/ui/KeyHelp.js +61 -0
  71. package/ui/KeyTable.js +198 -0
  72. package/ui/MultiSelect.js +259 -0
  73. package/ui/Select.js +229 -0
  74. package/ui/Tabs.js +201 -0
  75. package/ui/TextInput.js +289 -0
  76. package/ui/Toggle.js +31 -0
  77. package/ui/UiKey.js +48 -0
  78. package/ui/UiProvider.js +62 -0
  79. package/ui/UiStreams.js +17 -0
  80. package/ui/UiTheme.js +147 -0
  81. package/ui/Viewport.js +203 -0
  82. package/ui/internal/ErrorBoundary.js +29 -0
  83. package/ui/internal/Holder.js +73 -0
  84. package/ui/internal/ScreenContext.js +51 -0
  85. package/ui/internal/UiProviders.js +38 -0
  86. package/ui/internal/ink.js +121 -0
  87. package/ui/internal/inkChalk.js +47 -0
  88. package/ui/internal/inkConsole.js +150 -0
  89. package/ui/internal/lazyView.js +73 -0
  90. package/ui/internal/lineRouter.js +70 -0
  91. package/ui/internal/lineText.js +18 -0
  92. package/ui/internal/mountPermit.js +15 -0
  93. package/ui/internal/processStreams.js +18 -0
  94. package/ui/internal/renderOptions.js +12 -0
  95. package/ui/testing/CliUiTest.js +792 -0
  96. package/ui/testing/fakeStreams.js +104 -0
  97. package/ui/testing/terminalModel.js +67 -0
  98. package/ui-testing-serializer.d.ts +12 -0
  99. package/ui-testing-serializer.js +32 -0
  100. package/ui-testing.d.ts +558 -0
  101. package/ui-testing.js +3 -0
  102. package/ui.d.ts +1990 -0
  103. package/ui.js +17 -0
package/ui/CliUi.js ADDED
@@ -0,0 +1,507 @@
1
+ import { Cancelled } from "../Cancelled.js";
2
+ import { CliInteractive } from "../CliInteractive.js";
3
+ import { CliTheme } from "../CliTheme.js";
4
+ import { answerWithoutPerson } from "../internal/fallbackAnswer.js";
5
+ import { NotInteractive } from "../NotInteractive.js";
6
+ import { liveDetached } from "./CliUiDetached.js";
7
+ import { live } from "./CliUiLive.js";
8
+ import { routeStream } from "./CliUiRouteStream.js";
9
+ import { errorBoundary } from "./internal/ErrorBoundary.js";
10
+ import { inkModules, loadInk, withInkColour } from "./internal/ink.js";
11
+ import { lazyView } from "./internal/lazyView.js";
12
+ import { mountPermit } from "./internal/mountPermit.js";
13
+ import { UiRenderOptions } from "./internal/renderOptions.js";
14
+ import { useScreenGuard } from "./internal/ScreenContext.js";
15
+ import { treeBase, uiProviders } from "./internal/UiProviders.js";
16
+ import { KeyTable, useKeys } from "./KeyTable.js";
17
+ import { UiStreams } from "./UiStreams.js";
18
+ import { Cause, Deferred, Effect, Exit, Option, Semaphore } from "effect";
19
+ import { Audience } from "@effected/env";
20
+ import { Prompt } from "effect/cli";
21
+
22
+ // src/ui/CliUi.ts
23
+ /**
24
+ * stdout's theme as the audience sees it: colourless for an agent (as `Render.context` makes it), so a screen's
25
+ * `useTheme`, `Styled` and the widgets' colour-none markers never carry an escape for one. `Audience` is read only when
26
+ * provided, so it stays out of the requirements.
27
+ */
28
+ const audienceTheme = Effect.gen(function* () {
29
+ const audience = yield* Effect.serviceOption(Audience);
30
+ return CliTheme.forAudience((yield* CliTheme).forStream("stdout"), Option.isSome(audience) ? audience.value.kind : void 0);
31
+ });
32
+ /** The root keys: Esc cancels with `"escape"`, Ctrl-C with `"interrupt"`. `q` belongs to widgets, never here. */
33
+ const RootKeys = (props) => {
34
+ useKeys(KeyTable.root, props.cancel);
35
+ const guard = useScreenGuard();
36
+ inkModules().ink.usePaste(guard(() => void 0));
37
+ return props.children;
38
+ };
39
+ const SCREEN_EXITED = "@effected/cli-kit/ui: the screen exited without resolving or cancelling";
40
+ const mount = (screen, theme, clear, crash) => Effect.gen(function* () {
41
+ const overrides = yield* UiRenderOptions;
42
+ const base = yield* treeBase(theme);
43
+ yield* Effect.acquireRelease(Effect.sync(() => overrides.onMount?.("screen")), (_, exit) => Effect.sync(() => {
44
+ const died = Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isDieReason) : void 0;
45
+ const interrupted = Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause);
46
+ overrides.onUnmount?.(died !== void 0 ? { defect: died.defect } : interrupted ? void 0 : crash.current);
47
+ }));
48
+ const { ink, react } = yield* loadInk;
49
+ const streams = yield* UiStreams;
50
+ yield* withInkColour(theme.color);
51
+ const result = yield* Deferred.make();
52
+ const control = {
53
+ resolve: (value) => {
54
+ Deferred.doneUnsafe(result, Exit.succeed(value));
55
+ },
56
+ cancel: (reason) => {
57
+ Deferred.doneUnsafe(result, Exit.fail(new Cancelled({ reason })));
58
+ }
59
+ };
60
+ const element = yield* Effect.promise(async () => screen(control));
61
+ const die = (error) => {
62
+ if (crash.current === void 0) crash.current = { defect: error };
63
+ Deferred.doneUnsafe(result, Exit.die(error));
64
+ };
65
+ const tree = react.createElement(errorBoundary(), {
66
+ onError: die,
67
+ children: uiProviders({
68
+ ...base,
69
+ cancel: control.cancel,
70
+ die
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-kit/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 crash = { current: void 0 };
157
+ const exit = yield* Effect.exit(Semaphore.withPermit(mountPermit, Effect.scoped(mount(screen, theme, options?.clear === true, crash))));
158
+ const crashed = crash.current;
159
+ const interrupted = Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause);
160
+ if (crashed === void 0 || interrupted) return yield* exit;
161
+ const dies = Exit.isFailure(exit) ? exit.cause.reasons.filter((reason) => Cause.isDieReason(reason) && !(reason.defect instanceof Cancelled)) : [];
162
+ if (dies.some((reason) => reason.defect === crashed.defect)) return yield* exit;
163
+ return yield* Effect.failCause(Cause.fromReasons([Cause.makeDieReason(crashed.defect), ...dies]));
164
+ });
165
+ /**
166
+ * The kit's context for an Ink tree the kit did not mount: stdout's theme and glyph set.
167
+ *
168
+ * @remarks
169
+ * Hand it to {@link UiProvider}. It loads Ink and React, as a screen's mount does, so the provider and the kit's
170
+ * hooks can render; a missing peer is a defect naming both. It is the only way to get a `UiContextValue`.
171
+ *
172
+ * Ink loads asynchronously, so this is an `Effect` that must run before the first render. A renderer that is itself
173
+ * synchronous (a test helper, a report-time `renderToString`) runs it once, with a top-level `await` at module
174
+ * scope, and then renders synchronously from the value as often as it likes:
175
+ *
176
+ * ```ts
177
+ * const value = await Effect.runPromise(CliUi.context.pipe(Effect.provide(themeLayer)))
178
+ *
179
+ * // later, synchronously:
180
+ * const text = renderToString(createElement(UiProvider, { value: { ...value, size: { columns, rows } } }, tree), {
181
+ * columns,
182
+ * })
183
+ * ```
184
+ */
185
+ static context = Effect.gen(function* () {
186
+ const base = yield* treeBase(yield* audienceTheme);
187
+ yield* loadInk;
188
+ return {
189
+ "~@effected/cli-kit/ui/UiContextValue": true,
190
+ ...base
191
+ };
192
+ });
193
+ /**
194
+ * A live view over a stream: fold `events` into state, and draw it with Ink while a run is going, for the caller's
195
+ * scope.
196
+ *
197
+ * @remarks
198
+ * `events` is a `PubSub` subscription or a stream, folded in a fiber of the caller's scope. A subscription, made
199
+ * before the first publish, is the surest: nothing published after it is missed. `live` makes a stream's first pull
200
+ * before it returns, so one that subscribes on its first pull without forking (`Stream.fromPubSub`) is subscribed by
201
+ * then; one that forks its upstream (`Stream.merge`, `buffer`, a concurrent `flatMap`) subscribes later, and loses
202
+ * what is published before.
203
+ *
204
+ * `live` returns its handle at once, before Ink has loaded: it loads Ink when a run first mounts (or, when not
205
+ * interactive, when an owned run without a `final` prints its final frame), and it waits on nothing asynchronous before returning, so
206
+ * a host outside Effect can take the handle with `Effect.runSync`. The handle works from the start: a `close`
207
+ * before any run has mounted folds what is queued and ends the view as the events ending would, waiting for a mount
208
+ * already under way, and one with no run to end loads nothing.
209
+ *
210
+ * End the view with `handle.close`: it stops taking events, folds what is still queued (a subscription's queued
211
+ * messages included), commits or prints the run as the events ending would, and waits for `done`. Then close the
212
+ * scope. A publisher may instead end a subscription with `PubSub.end(pubsub, last)`, which keeps everything: the view
213
+ * folds what is buffered and `last` once, then ends. A `PubSub.shutdown` drops what the view has not taken yet, and
214
+ * 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
215
+ * cursor shown, Ink's colour level put back) and nothing more is written. `done` completes when the events end.
216
+ *
217
+ * A run begins at an `isStart` event (or wherever `begins` says, given the state before and after the event) and ends
218
+ * at an `isTerminal` event; an event while no run is going that begins none is folded and not drawn, so what a program
219
+ * reports after a run ends never mounts a second copy of it. A run mounts the view; its end unmounts it, which leaves
220
+ * its last frame on the terminal, and the next run mounts afresh below it. A start while a run is drawn redraws in
221
+ * 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
222
+ * resets it on the start.
223
+ *
224
+ * 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
225
+ * makes Ink wipe the scrollback; its width is Ink's own. The clamp
226
+ * lags one paint when the terminal gets shorter: Ink re-lays out and repaints the tree it already has on a resize,
227
+ * before React re-renders with the new row count, so a frame already at the old height can be drawn once taller
228
+ * than the terminal, which Ink answers by clearing the screen and its scrollback. Only a shrink in height while
229
+ * the frame is at its full height does it; a frame that keeps a few rows spare never meets it.
230
+ *
231
+ * While a run is drawn the view also redraws on a tick of `tickMillis` (80 by default), a schedule in the run's
232
+ * scope: interrupted with the run or the scope, it never outlives them. Its timer is not unref'd, so while a run is
233
+ * drawn it keeps the process alive: the run's terminal event, or the scope's close, is what lets the process exit.
234
+ * The frame index never steps back. Events that arrive at
235
+ * once, in one chunk or in several the view had not yet caught up with, are folded together and drawn once. The view
236
+ * takes events from `events` as fast as the stream yields them, so a stream that applies backpressure buffers in the
237
+ * view while it draws.
238
+ *
239
+ * A run whose drawing fails (a `render` that throws, or a mount that fails) degrades rather than ending the view:
240
+ * it is unmounted, leaving its last good frame on the terminal, then one warning is logged (`Effect.logWarning`),
241
+ * and the fold goes on. At its terminal event, a run with no frame left on the terminal (it never painted, or its
242
+ * last good frame threw too) writes its final frame once, as a string. The next run mounts afresh, and so does a
243
+ * start that comes while a degraded run is going: it ends that run as its terminal event would. A `reduce` that
244
+ * throws, or an `events` stream that dies, unmounts the run, then `done` dies with the error.
245
+ *
246
+ * When the run is not interactive, nothing is mounted and Ink is loaded only when a string is due. In the `owned`
247
+ * mode (the default) each run's final frame is written once to stdout, as a string laid out at stdout's width (80
248
+ * when it reports none) with no height to fit, at its terminal event or when the stream ends. It is escape-free at
249
+ * colour `none`, and for an agent audience (`Audience`, when provided) whatever the terminal could do. With a
250
+ * `final` document, that document is printed instead, once per run, rendered as `Doc.print` renders it, and Ink,
251
+ * React and a `CliUi.lazyView` module are never loaded: an agent, CI or piped run of a command with a live view pays
252
+ * for none of them. In the `hosted` mode nothing is written.
253
+ *
254
+ * Keep React off the runs that never draw (`--help`, a usage error) with `render: CliUi.lazyView(() => import(...))`.
255
+ *
256
+ * No input is mounted: the view reads no keys and never enters raw mode, so Ctrl-C stays the platform's SIGINT,
257
+ * which interrupts the program and so closes the scope. Each run holds the process-wide mount permit from its
258
+ * mount to its end, so a `CliUi.run` during a run waits for the run to end, and one between runs mounts at once.
259
+ *
260
+ * While a run is drawn, write logs through `logConsole`, provided around the work the view reports on: its lines
261
+ * land above the frame. A line written to the terminal any other way tears the frame. A host forwarding output it
262
+ * did not write itself prints it with `printAbove`, which says whether a frame was mounted to print it above.
263
+ *
264
+ * The view draws on stdout (`UiStreams`), at stdout's colour level and glyphs, and mounts only when the run is
265
+ * interactive (`CliInteractive`).
266
+ *
267
+ * @param options - the events, the fold, the drawing, and what starts and ends a run
268
+ */
269
+ static live = live;
270
+ /**
271
+ * A live view hosted by code that is not built on Effect: a test runner's reporter, a bundler plugin, a build hook.
272
+ *
273
+ * @remarks
274
+ * It runs `CliUi.live` in a fiber of its own, under `env` and in a scope of its own, and returns at once. A `PubSub`
275
+ * given as `events` is subscribed before it returns, so the first event published after the call is not lost.
276
+ * `env` is the environment the view reads, built by the host, which owns the platform: under Node,
277
+ * `CliEnv.layer().pipe(Layer.provide(NodeServices.layer))`; a test gives `CliEnv.layerTest` with a session's layer.
278
+ *
279
+ * End it with `close`, and only then shut the `PubSub` down: `close` folds the subscription's queued tail, commits
280
+ * the last run and releases the scope, and a shutdown before it drops that tail. `close` never rejects; `exit`
281
+ * says how the view ended. Forward output the host did not write through `printAbove`, or hand
282
+ * `CliUi.routeStream` the detached view's `printAbove` as its printer.
283
+ *
284
+ * A render that needs an environment service is given as an effect that makes the render function: it runs once
285
+ * under `env`, before the view is set up, so `CliLinks` or `TerminalEnv` come from the same build the view reads and
286
+ * the layer is not built a second time to resolve them. `env` must provide what that effect reads.
287
+ *
288
+ * Inside an Effect program, use `CliUi.live` in a scope instead.
289
+ *
290
+ * @example
291
+ * ```ts
292
+ * const view = CliUi.liveDetached(
293
+ * {
294
+ * ...options,
295
+ * render: Effect.gen(function* () {
296
+ * const links = yield* CliLinks
297
+ * return (state: State, frame: number) => drawWith(links, state, frame)
298
+ * }),
299
+ * },
300
+ * CliEnv.layer().pipe(Layer.provide(NodeServices.layer)),
301
+ * )
302
+ * ```
303
+ *
304
+ * @param options - the live view's options, with the events as a `PubSub` or a stream and the render given directly
305
+ * or as an effect that makes it
306
+ * @param env - the environment the view runs under, providing what a render effect reads
307
+ */
308
+ static liveDetached = liveDetached;
309
+ /**
310
+ * A stream to hand to a writer in place of a terminal stream, which routes what it writes line by line through
311
+ * `printer`, usually a live view's `printAbove`, and writes whatever the printer declines to `original`.
312
+ *
313
+ * @remarks
314
+ * For output the program does not write itself: a child process's stderr, or the streams of a test runner or a
315
+ * bundler running in-process. Each complete line is offered to `printer`; a `false` (no frame mounted) writes it,
316
+ * with its line break, to `original`. Text without a line break yet is held until one comes, until `flush`, or
317
+ * until it passes `maxHeld` characters, when it is offered as a line of its own. Bytes are decoded as UTF-8 across
318
+ * writes, so a character split between two writes arrives whole.
319
+ *
320
+ * Everything written to `original` is the decoded text, so a character split across two writes, by whichever path,
321
+ * arrives once and whole. A write that is nothing but escape sequences (a cursor hide, a clear-screen) goes
322
+ * straight to `original` when no partial line is held, since there is nothing to print above a frame. Ending
323
+ * `writable` flushes the held text, so a writer's last line with no line break is printed, not lost. A line's own cursor
324
+ * movements still tear the frame; `carriageReturn: "last"` keeps only what a progress bar redrawn with `\r` would
325
+ * have shown.
326
+ *
327
+ * The returned `writable` reports the original's `isTTY`, `columns`, `rows`, `getColorDepth`, `hasColors` and
328
+ * `getWindowSize` where the original has them. A printer that throws fails the write. Node only, as `./ui` is.
329
+ *
330
+ * @example
331
+ * ```ts
332
+ * const view = CliUi.liveDetached(options, env)
333
+ * const routed = CliUi.routeStream(process.stderr, view.printAbove, { stream: "stderr" })
334
+ * runner.errorStream = routed.writable
335
+ * // ...at the end
336
+ * routed.flush()
337
+ * await view.close()
338
+ * ```
339
+ *
340
+ * @param original - the stream the writer would have written to
341
+ * @param printer - where each line is offered first: `true` when it printed it, `false` with nothing written
342
+ * @param options - the printer's stream, the hold limit, carriage returns and a text tap
343
+ * @throws RangeError when `maxHeld` is not a non-negative integer
344
+ */
345
+ static routeStream = routeStream;
346
+ /**
347
+ * Run `screen` from a handler when the run is interactive; otherwise answer with `otherwise`, or fail with
348
+ * `NotInteractive` when there is none.
349
+ *
350
+ * @remarks
351
+ * `CliUi.run` with a default: not interactive, it returns `otherwise` and Ink and React are never loaded.
352
+ * Interactive, it mounts the screen, and a cancel is the typed `Cancelled` a handler can catch, which
353
+ * `CliRuntime.main` otherwise renders as one line with exit `130`. A missing Ink in an interactive run is a defect
354
+ * naming the peers, never a silent `otherwise`. Screens in sequence make a wizard: discover the defaults first,
355
+ * pass each as an `otherwise`, and a non-interactive run returns exactly them.
356
+ *
357
+ * Without `otherwise`, `NotInteractive` stays in the error type even after the caller checked `CliInteractive`, since
358
+ * the type cannot know. Catch the tag and fail with `CliError.UserError` to exit as a usage error (`64` under
359
+ * `CliRuntime.main`).
360
+ *
361
+ * As with `CliUi.run`, do not log while the screen is mounted: a line written to the terminal from elsewhere tears
362
+ * the frame.
363
+ *
364
+ * @param screen - the screen to show
365
+ * @param options - the non-interactive default, and whether to erase the last frame
366
+ */
367
+ static prompt = (screen, options) => CliUi.run(screen, options?.clear === true ? { clear: true } : void 0).pipe(Effect.catchTag("NotInteractive", (error) => {
368
+ const otherwise = options?.otherwise;
369
+ return otherwise === void 0 ? Effect.fail(error) : Effect.succeed(otherwise);
370
+ }));
371
+ /**
372
+ * A fallback for `Flag.withFallbackPrompt` or `Argument.withFallbackPrompt` that shows a screen when the run is
373
+ * interactive: `CliPrompt.fallback` for screens.
374
+ *
375
+ * @remarks
376
+ * Interactive, the screen mounts and its answer is the parameter's value. Not interactive, `otherwise` is used when
377
+ * given and Ink and React are never loaded; without it the parameter fails as missing, exactly as with no fallback,
378
+ * so core renders its own message and `CliRuntime.main` exits `64`. Name the parameter with `flag` (the name
379
+ * without dashes) or `argument` so that error can be built.
380
+ *
381
+ * The options are `CliPromptFallbackOptions` (from `@effected/cli-kit`), the same as `CliPrompt.fallback`'s, and `clear`
382
+ * as for {@link CliUi.run}.
383
+ *
384
+ * It runs during parsing, whose environment is core's alone, so it reads `CliTheme` if one is there: with
385
+ * `CliRuntime.main`'s `env` (`CliEnv.layer`), or provided around the program. With no theme it treats the run as
386
+ * not interactive, and when `CliInteractive` is on it says so once, at debug level. Interactivity is
387
+ * `CliInteractive`, which an audience flag can set before parsing under `CliAudience`. Core calls a fallback only
388
+ * when its `TerminalCapabilities.canPrompt` is `true`; `CliInteractive.layer` and `layerTest` hold it there so that
389
+ * `otherwise` survives a piped run, and a `canPrompt: false` provided inside the program bypasses this fallback,
390
+ * as it does `CliPrompt.fallback`.
391
+ *
392
+ * As with `CliPrompt.fallback`, the screen runs here rather than being handed to core, whose fallback runner
393
+ * turns a quit into the missing-parameter error, which would exit `64`. A cancel (Esc, Ctrl-C) is `Cancelled`,
394
+ * raised as a defect because core's parse step turns every typed failure into a usage error, so only
395
+ * `CliRuntime.main` (or `CliRuntime.reportFailures`) renders it, as one line with exit `130`. A missing Ink in an
396
+ * interactive run is a defect naming the peers, never a silent `otherwise`.
397
+ *
398
+ * After the screen has unmounted, core still runs the answered `Prompt.succeed` it is handed against the
399
+ * terminal, exactly as it does for `CliPrompt.fallback`: `Prompt.run` opens the terminal's input in a scope (on
400
+ * Node a readline over stdin, in raw mode) before looking at the prompt. That is harmless. The prompt is already
401
+ * answered, so nothing is read and no key is waited for; the scope closes at once, restoring the mode and closing
402
+ * the reader; and Ink has already let go of stdin, so the two never hold it together. Not interactive, the screen
403
+ * never mounts, and `CliPrompt.gateTerminal`, which `CliEnv.layer` installs, keeps that subscription off the real
404
+ * terminal altogether.
405
+ *
406
+ * @param screen - the screen to show
407
+ * @param options - the parameter it stands in for, the non-interactive default, and whether to erase the last frame
408
+ */
409
+ static fallback = (screen, options) => {
410
+ let explained = false;
411
+ return Effect.gen(function* () {
412
+ const theme = yield* Effect.serviceOption(CliTheme);
413
+ if (Option.isNone(theme)) {
414
+ if (!explained && (yield* CliInteractive)) {
415
+ explained = true;
416
+ const name = "flag" in options ? `--${options.flag}` : `<${options.argument}>`;
417
+ yield* Effect.logDebug(`@effected/cli-kit/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)`);
418
+ }
419
+ return yield* answerWithoutPerson(options);
420
+ }
421
+ 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)));
422
+ });
423
+ };
424
+ /**
425
+ * A screen whose module is loaded only when it mounts, so importing the command that uses it loads neither the
426
+ * screen's own code nor React.
427
+ *
428
+ * @param load - imports the module whose default export is the screen
429
+ */
430
+ static lazy = (load) => async (control) => (await load()).default(control);
431
+ /**
432
+ * A live view's `render` whose module is loaded only when a run first draws it, so importing the command that uses
433
+ * the view loads neither the view's own code nor React: `CliUi.lazy` for `CliUi.live`.
434
+ *
435
+ * @remarks
436
+ * `load` resolves to the view, `(state, frame) => ReactElement`, exactly what `render` takes, so the frame index a
437
+ * spinner needs reaches it: either a module whose default export is the view (`() => import("./view.js")`), or the
438
+ * view itself (`() => import("./views.js").then((module) => module.syncView)`, for a named export).
439
+ *
440
+ * It is optional: `render` still takes the view directly, and needs no dynamic import. A view passed directly
441
+ * loads with the module that imports it, so it costs React on every run that loads that module; `lazyView` is
442
+ * 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
443
+ * prints a run's final frame as a string; a run that is not interactive and has a `final` document never loads it,
444
+ * nor Ink, nor React. An import that fails degrades the run, as a render that throws does: one warning, and the next
445
+ * run tries the import again. A load that resolves to no view (neither a function nor a module whose `default` is
446
+ * one) is a programming error, and deterministic, so it is kept for the view's life: every run degrades without
447
+ * loading again, and the view warns once, saying what it received and what is expected, rather than once per run. A
448
+ * view function that happens to carry a `default` property is the view.
449
+ *
450
+ * The returned function is for `CliUi.live`'s `render` alone: called before its module has loaded, it throws.
451
+ *
452
+ * Keep the `LiveOptions` (the state, the events and the `lazyView` call) in a module the view does not import. The
453
+ * view module usually imports the state's types or its fold from somewhere; if that somewhere is the module that
454
+ * holds the `import("./view.js")`, the dynamic import closes a cycle, which Biome's `noImportCycles` reports even
455
+ * though it is lazy. A layout that stays acyclic: the model (state, events, fold) in one module, the view importing
456
+ * the model, and the options (with `lazyView`) in a third that imports the model and loads the view.
457
+ *
458
+ * ```ts
459
+ * // commands/sync.ts: no JSX, no React
460
+ * const view = yield* CliUi.live({
461
+ * events,
462
+ * initial,
463
+ * reduce,
464
+ * render: CliUi.lazyView(() => import("./sync-view.js")),
465
+ * final: (state) => [Doc.paragraph(`${state.done} synced`)],
466
+ * isStart,
467
+ * isTerminal,
468
+ * })
469
+ * ```
470
+ *
471
+ * @param load - resolves to the view, or to a module whose default export is the view
472
+ */
473
+ static lazyView = lazyView;
474
+ /**
475
+ * A screen whose answer is `f` of `screen`'s: it mounts `screen` and resolves with `f(value)` when `screen` resolves
476
+ * with `value`.
477
+ *
478
+ * @remarks
479
+ * Only the resolve is mapped. A cancel passes through unchanged, as the same `Cancelled`, and so does everything
480
+ * else about the screen: what it draws, its keys, a lazy load. `f` runs when the screen resolves; what it throws is
481
+ * thrown from the screen's resolve, so it is a defect of the run, as any other throw in a key handler is.
482
+ *
483
+ * The mapped screen is a `Screen` like any other, so it goes wherever a screen goes: `CliUi.run`, `CliUi.prompt`,
484
+ * `CliUi.fallback`, or around a `CliUi.lazy` one. The commonest use is a `Confirm` behind a boolean flag, where the
485
+ * fallback needs a `Screen<boolean>` and `Confirm` answers a whole `ConfirmResult`:
486
+ *
487
+ * ```ts
488
+ * const yes = Flag.Boolean("yes").pipe(
489
+ * Flag.withFallbackPrompt(
490
+ * CliUi.fallback(
491
+ * CliUi.map(Confirm.screen({ message: "Publish?" }), (result) => result.confirmed),
492
+ * { flag: "yes", otherwise: false },
493
+ * ),
494
+ * ),
495
+ * )
496
+ * ```
497
+ *
498
+ * @param screen - the screen to show
499
+ * @param f - turns its answer into the mapped screen's
500
+ */
501
+ static map = (screen, f) => (control) => screen({
502
+ resolve: (value) => control.resolve(f(value)),
503
+ cancel: control.cancel
504
+ });
505
+ };
506
+
507
+ export { CliUi };
@@ -0,0 +1,36 @@
1
+ import { live } from "./CliUiLive.js";
2
+ import { Deferred, Effect, Exit, Fiber, PubSub, Scope } from "effect";
3
+
4
+ // src/ui/CliUiDetached.ts
5
+ /** @internal */
6
+ const liveDetached = (options, env) => {
7
+ const scope = Effect.runSync(Scope.make());
8
+ const { events: source, render: drawing, ...rest } = options;
9
+ const render = Effect.isEffect(drawing) ? drawing : Effect.succeed(drawing);
10
+ const events = PubSub.isPubSub(source) ? Effect.runSync(PubSub.subscribe(source).pipe(Scope.provide(scope))) : source;
11
+ const mounted = Effect.runSync(Deferred.make());
12
+ const program = Effect.flatMap(render, (draw) => live({
13
+ ...rest,
14
+ render: draw,
15
+ events
16
+ })).pipe(Effect.tap((handle) => Deferred.succeed(mounted, handle)), Effect.flatMap((handle) => handle.done), Scope.provide(scope), Effect.provide(env), Effect.onExit((exit) => Deferred.done(mounted, Exit.isFailure(exit) ? Exit.failCause(exit.cause) : Exit.die(/* @__PURE__ */ new Error("CliUi.liveDetached: the view ended")))));
17
+ const fiber = Effect.runFork(program);
18
+ const exit = Effect.runPromise(Fiber.await(fiber));
19
+ const handle = Effect.runPromise(Deferred.await(mounted));
20
+ let closing;
21
+ let print;
22
+ handle.then((mountedHandle) => {
23
+ print = mountedHandle.printAbove;
24
+ }, () => void 0);
25
+ return {
26
+ handle,
27
+ exit,
28
+ printAbove: (stream, line) => closing === void 0 && print !== void 0 ? print(stream, line) : false,
29
+ close: () => {
30
+ closing ??= Effect.runPromise(Deferred.await(mounted).pipe(Effect.flatMap((mountedHandle) => mountedHandle.close), Effect.exit, Effect.ensuring(Scope.close(scope, Exit.void)), Effect.asVoid));
31
+ return closing;
32
+ }
33
+ };
34
+ };
35
+
36
+ export { liveDetached };