@effected/cli-kit 0.0.0-stage → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cancelled.js +43 -0
- package/CliAudience.js +177 -0
- package/CliColor.js +66 -0
- package/CliEnv.js +101 -0
- package/CliExit.js +117 -0
- package/CliFailure.js +287 -0
- package/CliFormat.js +124 -0
- package/CliInteractive.js +94 -0
- package/CliLinks.js +153 -0
- package/CliLog.js +367 -0
- package/CliLogger.js +91 -0
- package/CliMessage.js +79 -0
- package/CliPrompt.js +110 -0
- package/CliRuntime.js +254 -0
- package/CliTest.js +240 -0
- package/CliTheme.js +142 -0
- package/ConfigIssueRenderer.js +62 -0
- package/Doc.js +548 -0
- package/Fmt.js +295 -0
- package/GithubAnnotation.js +39 -0
- package/Glyphs.js +82 -0
- package/LICENSE +21 -0
- package/NotInteractive.js +41 -0
- package/README.md +213 -2
- package/Render.js +280 -0
- package/SchemaIssueRenderer.js +47 -0
- package/Status.js +165 -0
- package/TestTerminal.js +107 -0
- package/Token.js +70 -0
- package/index.d.ts +4035 -0
- package/index.js +27 -0
- package/internal/ExitRequested.js +21 -0
- package/internal/HelpRouting.js +63 -0
- package/internal/ansi.js +229 -0
- package/internal/autoFormat.js +33 -0
- package/internal/canPrompt.js +14 -0
- package/internal/counts.js +83 -0
- package/internal/diagnostics.js +31 -0
- package/internal/displayPath.js +46 -0
- package/internal/displayWidth.js +42 -0
- package/internal/failureTarget.js +195 -0
- package/internal/fallbackAnswer.js +17 -0
- package/internal/fileSink.js +61 -0
- package/internal/format.js +173 -0
- package/internal/inspect.js +40 -0
- package/internal/isExitCode.js +11 -0
- package/internal/layout.js +249 -0
- package/internal/linkScheme.js +29 -0
- package/internal/linkTarget.js +49 -0
- package/internal/logSafety.js +45 -0
- package/internal/renderAnsi.js +51 -0
- package/internal/renderDoc.js +322 -0
- package/internal/renderGithubLog.js +59 -0
- package/internal/renderMarkdown.js +367 -0
- package/internal/renderPlain.js +49 -0
- package/internal/scanAudience.js +105 -0
- package/internal/splitFrame.js +55 -0
- package/internal/terminalCapabilities.js +23 -0
- package/internal/wizardGate.js +17 -0
- package/package.json +87 -5
- package/testing.d.ts +283 -0
- package/testing.js +4 -0
- package/tsdoc-metadata.json +11 -0
- package/ui/CliUi.js +509 -0
- package/ui/CliUiDetached.js +36 -0
- package/ui/CliUiLive.js +436 -0
- package/ui/CliUiRouteStream.js +72 -0
- package/ui/Confirm.js +244 -0
- package/ui/DocView.js +76 -0
- package/ui/KeyHelp.js +61 -0
- package/ui/KeyTable.js +198 -0
- package/ui/MultiSelect.js +259 -0
- package/ui/Select.js +229 -0
- package/ui/Tabs.js +201 -0
- package/ui/TextInput.js +289 -0
- package/ui/Toggle.js +31 -0
- package/ui/UiKey.js +48 -0
- package/ui/UiProvider.js +62 -0
- package/ui/UiStreams.js +17 -0
- package/ui/UiTheme.js +166 -0
- package/ui/Viewport.js +203 -0
- package/ui/internal/ErrorBoundary.js +29 -0
- package/ui/internal/Holder.js +73 -0
- package/ui/internal/ScreenContext.js +51 -0
- package/ui/internal/UiProviders.js +38 -0
- package/ui/internal/ink.js +121 -0
- package/ui/internal/inkChalk.js +47 -0
- package/ui/internal/inkConsole.js +150 -0
- package/ui/internal/lazyView.js +73 -0
- package/ui/internal/lineRouter.js +70 -0
- package/ui/internal/lineText.js +18 -0
- package/ui/internal/mountPermit.js +15 -0
- package/ui/internal/processStreams.js +18 -0
- package/ui/internal/renderOptions.js +12 -0
- package/ui/testing/CliUiTest.js +792 -0
- package/ui/testing/fakeStreams.js +104 -0
- package/ui/testing/terminalModel.js +67 -0
- package/ui-testing-serializer.d.ts +12 -0
- package/ui-testing-serializer.js +32 -0
- package/ui-testing.d.ts +558 -0
- package/ui-testing.js +3 -0
- package/ui.d.ts +2013 -0
- package/ui.js +17 -0
package/ui/CliUi.js
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
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
|
+
* It has two forms. With a real `otherwise` (any value but `undefined`) the error type is `Cancelled` alone, since a
|
|
358
|
+
* non-interactive run answers and cannot fail. Without one, or with `{ otherwise: undefined }`, which counts as not
|
|
359
|
+
* given, `NotInteractive` stays in the error type even after the caller checked `CliInteractive`, since the type
|
|
360
|
+
* cannot know. Catch the tag and fail with `CliError.UserError` to exit as a usage error (`64` under
|
|
361
|
+
* `CliRuntime.main`). `A` is read from the screen, never from `otherwise`.
|
|
362
|
+
*
|
|
363
|
+
* As with `CliUi.run`, do not log while the screen is mounted: a line written to the terminal from elsewhere tears
|
|
364
|
+
* the frame.
|
|
365
|
+
*
|
|
366
|
+
* @param screen - the screen to show
|
|
367
|
+
* @param options - the non-interactive default, and whether to erase the last frame
|
|
368
|
+
*/
|
|
369
|
+
static prompt = (screen, options) => CliUi.run(screen, options?.clear === true ? { clear: true } : void 0).pipe(Effect.catchTag("NotInteractive", (error) => {
|
|
370
|
+
const otherwise = options?.otherwise;
|
|
371
|
+
return otherwise === void 0 ? Effect.fail(error) : Effect.succeed(otherwise);
|
|
372
|
+
}));
|
|
373
|
+
/**
|
|
374
|
+
* A fallback for `Flag.withFallbackPrompt` or `Argument.withFallbackPrompt` that shows a screen when the run is
|
|
375
|
+
* interactive: `CliPrompt.fallback` for screens.
|
|
376
|
+
*
|
|
377
|
+
* @remarks
|
|
378
|
+
* Interactive, the screen mounts and its answer is the parameter's value. Not interactive, `otherwise` is used when
|
|
379
|
+
* given and Ink and React are never loaded; without it the parameter fails as missing, exactly as with no fallback,
|
|
380
|
+
* so core renders its own message and `CliRuntime.main` exits `64`. Name the parameter with `flag` (the name
|
|
381
|
+
* without dashes) or `argument` so that error can be built.
|
|
382
|
+
*
|
|
383
|
+
* The options are `CliPromptFallbackOptions` (from `@effected/cli-kit`), the same as `CliPrompt.fallback`'s, and `clear`
|
|
384
|
+
* as for {@link CliUi.run}.
|
|
385
|
+
*
|
|
386
|
+
* It runs during parsing, whose environment is core's alone, so it reads `CliTheme` if one is there: with
|
|
387
|
+
* `CliRuntime.main`'s `env` (`CliEnv.layer`), or provided around the program. With no theme it treats the run as
|
|
388
|
+
* not interactive, and when `CliInteractive` is on it says so once, at debug level. Interactivity is
|
|
389
|
+
* `CliInteractive`, which an audience flag can set before parsing under `CliAudience`. Core calls a fallback only
|
|
390
|
+
* when its `TerminalCapabilities.canPrompt` is `true`; `CliInteractive.layer` and `layerTest` hold it there so that
|
|
391
|
+
* `otherwise` survives a piped run, and a `canPrompt: false` provided inside the program bypasses this fallback,
|
|
392
|
+
* as it does `CliPrompt.fallback`.
|
|
393
|
+
*
|
|
394
|
+
* As with `CliPrompt.fallback`, the screen runs here rather than being handed to core, whose fallback runner
|
|
395
|
+
* turns a quit into the missing-parameter error, which would exit `64`. A cancel (Esc, Ctrl-C) is `Cancelled`,
|
|
396
|
+
* raised as a defect because core's parse step turns every typed failure into a usage error, so only
|
|
397
|
+
* `CliRuntime.main` (or `CliRuntime.reportFailures`) renders it, as one line with exit `130`. A missing Ink in an
|
|
398
|
+
* interactive run is a defect naming the peers, never a silent `otherwise`.
|
|
399
|
+
*
|
|
400
|
+
* After the screen has unmounted, core still runs the answered `Prompt.succeed` it is handed against the
|
|
401
|
+
* terminal, exactly as it does for `CliPrompt.fallback`: `Prompt.run` opens the terminal's input in a scope (on
|
|
402
|
+
* Node a readline over stdin, in raw mode) before looking at the prompt. That is harmless. The prompt is already
|
|
403
|
+
* answered, so nothing is read and no key is waited for; the scope closes at once, restoring the mode and closing
|
|
404
|
+
* the reader; and Ink has already let go of stdin, so the two never hold it together. Not interactive, the screen
|
|
405
|
+
* never mounts, and `CliPrompt.gateTerminal`, which `CliEnv.layer` installs, keeps that subscription off the real
|
|
406
|
+
* terminal altogether.
|
|
407
|
+
*
|
|
408
|
+
* @param screen - the screen to show
|
|
409
|
+
* @param options - the parameter it stands in for, the non-interactive default, and whether to erase the last frame
|
|
410
|
+
*/
|
|
411
|
+
static fallback = (screen, options) => {
|
|
412
|
+
let explained = false;
|
|
413
|
+
return Effect.gen(function* () {
|
|
414
|
+
const theme = yield* Effect.serviceOption(CliTheme);
|
|
415
|
+
if (Option.isNone(theme)) {
|
|
416
|
+
if (!explained && (yield* CliInteractive)) {
|
|
417
|
+
explained = true;
|
|
418
|
+
const name = "flag" in options ? `--${options.flag}` : `<${options.argument}>`;
|
|
419
|
+
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)`);
|
|
420
|
+
}
|
|
421
|
+
return yield* answerWithoutPerson(options);
|
|
422
|
+
}
|
|
423
|
+
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)));
|
|
424
|
+
});
|
|
425
|
+
};
|
|
426
|
+
/**
|
|
427
|
+
* A screen whose module is loaded only when it mounts, so importing the command that uses it loads neither the
|
|
428
|
+
* screen's own code nor React.
|
|
429
|
+
*
|
|
430
|
+
* @param load - imports the module whose default export is the screen
|
|
431
|
+
*/
|
|
432
|
+
static lazy = (load) => async (control) => (await load()).default(control);
|
|
433
|
+
/**
|
|
434
|
+
* A live view's `render` whose module is loaded only when a run first draws it, so importing the command that uses
|
|
435
|
+
* the view loads neither the view's own code nor React: `CliUi.lazy` for `CliUi.live`.
|
|
436
|
+
*
|
|
437
|
+
* @remarks
|
|
438
|
+
* `load` resolves to the view, `(state, frame) => ReactElement`, exactly what `render` takes, so the frame index a
|
|
439
|
+
* spinner needs reaches it: either a module whose default export is the view (`() => import("./view.js")`), or the
|
|
440
|
+
* view itself (`() => import("./views.js").then((module) => module.syncView)`, for a named export).
|
|
441
|
+
*
|
|
442
|
+
* It is optional: `render` still takes the view directly, and needs no dynamic import. A view passed directly
|
|
443
|
+
* loads with the module that imports it, so it costs React on every run that loads that module; `lazyView` is
|
|
444
|
+
* 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
|
|
445
|
+
* prints a run's final frame as a string; a run that is not interactive and has a `final` document never loads it,
|
|
446
|
+
* nor Ink, nor React. An import that fails degrades the run, as a render that throws does: one warning, and the next
|
|
447
|
+
* run tries the import again. A load that resolves to no view (neither a function nor a module whose `default` is
|
|
448
|
+
* one) is a programming error, and deterministic, so it is kept for the view's life: every run degrades without
|
|
449
|
+
* loading again, and the view warns once, saying what it received and what is expected, rather than once per run. A
|
|
450
|
+
* view function that happens to carry a `default` property is the view.
|
|
451
|
+
*
|
|
452
|
+
* The returned function is for `CliUi.live`'s `render` alone: called before its module has loaded, it throws.
|
|
453
|
+
*
|
|
454
|
+
* Keep the `LiveOptions` (the state, the events and the `lazyView` call) in a module the view does not import. The
|
|
455
|
+
* view module usually imports the state's types or its fold from somewhere; if that somewhere is the module that
|
|
456
|
+
* holds the `import("./view.js")`, the dynamic import closes a cycle, which Biome's `noImportCycles` reports even
|
|
457
|
+
* though it is lazy. A layout that stays acyclic: the model (state, events, fold) in one module, the view importing
|
|
458
|
+
* the model, and the options (with `lazyView`) in a third that imports the model and loads the view.
|
|
459
|
+
*
|
|
460
|
+
* ```ts
|
|
461
|
+
* // commands/sync.ts: no JSX, no React
|
|
462
|
+
* const view = yield* CliUi.live({
|
|
463
|
+
* events,
|
|
464
|
+
* initial,
|
|
465
|
+
* reduce,
|
|
466
|
+
* render: CliUi.lazyView(() => import("./sync-view.js")),
|
|
467
|
+
* final: (state) => [Doc.paragraph(`${state.done} synced`)],
|
|
468
|
+
* isStart,
|
|
469
|
+
* isTerminal,
|
|
470
|
+
* })
|
|
471
|
+
* ```
|
|
472
|
+
*
|
|
473
|
+
* @param load - resolves to the view, or to a module whose default export is the view
|
|
474
|
+
*/
|
|
475
|
+
static lazyView = lazyView;
|
|
476
|
+
/**
|
|
477
|
+
* A screen whose answer is `f` of `screen`'s: it mounts `screen` and resolves with `f(value)` when `screen` resolves
|
|
478
|
+
* with `value`.
|
|
479
|
+
*
|
|
480
|
+
* @remarks
|
|
481
|
+
* Only the resolve is mapped. A cancel passes through unchanged, as the same `Cancelled`, and so does everything
|
|
482
|
+
* else about the screen: what it draws, its keys, a lazy load. `f` runs when the screen resolves; what it throws is
|
|
483
|
+
* thrown from the screen's resolve, so it is a defect of the run, as any other throw in a key handler is.
|
|
484
|
+
*
|
|
485
|
+
* The mapped screen is a `Screen` like any other, so it goes wherever a screen goes: `CliUi.run`, `CliUi.prompt`,
|
|
486
|
+
* `CliUi.fallback`, or around a `CliUi.lazy` one. The commonest use is a `Confirm` behind a boolean flag, where the
|
|
487
|
+
* fallback needs a `Screen<boolean>` and `Confirm` answers a whole `ConfirmResult`:
|
|
488
|
+
*
|
|
489
|
+
* ```ts
|
|
490
|
+
* const yes = Flag.Boolean("yes").pipe(
|
|
491
|
+
* Flag.withFallbackPrompt(
|
|
492
|
+
* CliUi.fallback(
|
|
493
|
+
* CliUi.map(Confirm.screen({ message: "Publish?" }), (result) => result.confirmed),
|
|
494
|
+
* { flag: "yes", otherwise: false },
|
|
495
|
+
* ),
|
|
496
|
+
* ),
|
|
497
|
+
* )
|
|
498
|
+
* ```
|
|
499
|
+
*
|
|
500
|
+
* @param screen - the screen to show
|
|
501
|
+
* @param f - turns its answer into the mapped screen's
|
|
502
|
+
*/
|
|
503
|
+
static map = (screen, f) => (control) => screen({
|
|
504
|
+
resolve: (value) => control.resolve(f(value)),
|
|
505
|
+
cancel: control.cancel
|
|
506
|
+
});
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
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 };
|