@effected/cli 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CliFailure.js +57 -8
- package/CliLog.js +53 -1
- package/CliMessage.js +3 -6
- package/CliRuntime.js +20 -10
- package/CliTheme.js +28 -15
- package/Doc.js +31 -7
- package/README.md +22 -6
- package/Render.js +6 -5
- package/Status.js +5 -2
- package/index.d.ts +220 -28
- package/internal/counts.js +17 -2
- package/internal/failureTarget.js +53 -14
- package/internal/renderDoc.js +4 -3
- package/internal/renderMarkdown.js +6 -5
- package/package.json +7 -2
- package/ui/CliUi.js +91 -7
- package/ui/CliUiLive.js +63 -16
- package/ui/Select.js +5 -1
- package/ui/TextInput.js +51 -11
- package/ui/internal/lazyView.js +74 -0
- package/ui/testing/CliUiTest.js +47 -22
- package/ui/testing/fakeStreams.js +6 -3
- package/ui-testing-serializer.d.ts +14 -0
- package/ui-testing-serializer.js +33 -0
- package/ui-testing.d.ts +93 -12
- package/ui.d.ts +154 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The presentation boundary of an effect/cli program: audience-aware output, a document IR and renderers, editor links, failure reports and logging, plus opt-in Ink screens, widgets and a live view",
|
|
6
6
|
"keywords": [
|
|
@@ -49,13 +49,18 @@
|
|
|
49
49
|
"import": "./ui-testing.js",
|
|
50
50
|
"default": "./ui-testing.js"
|
|
51
51
|
},
|
|
52
|
+
"./ui/testing/serializer": {
|
|
53
|
+
"types": "./ui-testing-serializer.d.ts",
|
|
54
|
+
"import": "./ui-testing-serializer.js",
|
|
55
|
+
"default": "./ui-testing-serializer.js"
|
|
56
|
+
},
|
|
52
57
|
"./package.json": "./package.json"
|
|
53
58
|
},
|
|
54
59
|
"dependencies": {
|
|
55
60
|
"@effected/github-commands": "^0.1.0"
|
|
56
61
|
},
|
|
57
62
|
"peerDependencies": {
|
|
58
|
-
"@effected/config-file": "^0.14.
|
|
63
|
+
"@effected/config-file": "^0.14.2",
|
|
59
64
|
"@effected/env": "^0.1.0",
|
|
60
65
|
"@effected/glob": "^0.10.0",
|
|
61
66
|
"@effected/walker": "^0.15.0",
|
package/ui/CliUi.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { Cancelled } from "../Cancelled.js";
|
|
2
2
|
import { CliInteractive } from "../CliInteractive.js";
|
|
3
3
|
import { underGithubActions } from "../internal/autoFormat.js";
|
|
4
|
-
import { CliTheme
|
|
4
|
+
import { CliTheme } from "../CliTheme.js";
|
|
5
5
|
import { NotInteractive } from "../NotInteractive.js";
|
|
6
6
|
import { answerWithoutPerson } from "../internal/fallbackAnswer.js";
|
|
7
7
|
import { inkModules, loadInk, withInkColour } from "./internal/ink.js";
|
|
8
8
|
import { errorBoundary } from "./internal/ErrorBoundary.js";
|
|
9
9
|
import { UiStreams } from "./UiStreams.js";
|
|
10
|
+
import { lazyView } from "./internal/lazyView.js";
|
|
10
11
|
import { mountPermit } from "./internal/mountPermit.js";
|
|
11
12
|
import { UiRenderOptions } from "./internal/renderOptions.js";
|
|
12
13
|
import { useScreenGuard } from "./internal/ScreenContext.js";
|
|
@@ -25,7 +26,7 @@ import { Prompt } from "effect/cli";
|
|
|
25
26
|
*/
|
|
26
27
|
const audienceTheme = Effect.gen(function* () {
|
|
27
28
|
const audience = yield* Effect.serviceOption(Audience);
|
|
28
|
-
return
|
|
29
|
+
return CliTheme.forAudience((yield* CliTheme).forStream("stdout"), Option.isSome(audience) ? audience.value.kind : void 0);
|
|
29
30
|
});
|
|
30
31
|
/** The root keys: Esc cancels with `"escape"`, Ctrl-C with `"interrupt"`. `q` belongs to widgets, never here. */
|
|
31
32
|
const RootKeys = (props) => {
|
|
@@ -37,7 +38,7 @@ const RootKeys = (props) => {
|
|
|
37
38
|
const SCREEN_EXITED = "@effected/cli/ui: the screen exited without resolving or cancelling";
|
|
38
39
|
const mount = (screen, theme, clear, crash, neutralize) => Effect.gen(function* () {
|
|
39
40
|
const overrides = yield* UiRenderOptions;
|
|
40
|
-
yield* Effect.acquireRelease(Effect.sync(() => overrides.onMount?.()), (_, exit) => Effect.sync(() => {
|
|
41
|
+
yield* Effect.acquireRelease(Effect.sync(() => overrides.onMount?.("screen")), (_, exit) => Effect.sync(() => {
|
|
41
42
|
const died = Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isDieReason) : void 0;
|
|
42
43
|
const interrupted = Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause);
|
|
43
44
|
overrides.onUnmount?.(died !== void 0 ? { defect: died.defect } : interrupted ? void 0 : crash.current);
|
|
@@ -80,7 +81,8 @@ const mount = (screen, theme, clear, crash, neutralize) => Effect.gen(function*
|
|
|
80
81
|
exitOnCtrlC: false,
|
|
81
82
|
patchConsole: false,
|
|
82
83
|
...overrides.debug === true ? { debug: true } : {},
|
|
83
|
-
...overrides.onRender === void 0 ? {} : { onRender: overrides.onRender }
|
|
84
|
+
...overrides.onRender === void 0 ? {} : { onRender: overrides.onRender },
|
|
85
|
+
...overrides.maxFps === void 0 ? {} : { maxFps: overrides.maxFps }
|
|
84
86
|
})), (instance) => Effect.promise(async () => {
|
|
85
87
|
if (clear) instance.clear();
|
|
86
88
|
const exited = instance.waitUntilExit();
|
|
@@ -204,7 +206,7 @@ var CliUi = class CliUi {
|
|
|
204
206
|
* what is published before.
|
|
205
207
|
*
|
|
206
208
|
* `live` returns its handle at once, before Ink has loaded: it loads Ink when a run first mounts (or, when not
|
|
207
|
-
* interactive, when an owned run prints its final frame), and it waits on nothing asynchronous before returning, so
|
|
209
|
+
* interactive, when an owned run without a `final` prints its final frame), and it waits on nothing asynchronous before returning, so
|
|
208
210
|
* a host outside Effect can take the handle with `Effect.runSync`. The handle works from the start: a `close`
|
|
209
211
|
* before any run has mounted folds what is queued and ends the view as the events ending would, waiting for a mount
|
|
210
212
|
* already under way, and one with no run to end loads nothing.
|
|
@@ -248,8 +250,12 @@ var CliUi = class CliUi {
|
|
|
248
250
|
* When the run is not interactive, nothing is mounted and Ink is loaded only when a string is due. In the `owned`
|
|
249
251
|
* mode (the default) each run's final frame is written once to stdout, as a string laid out at stdout's width (80
|
|
250
252
|
* when it reports none) with no height to fit, at its terminal event or when the stream ends. It is escape-free at
|
|
251
|
-
* colour `none`, and for an agent audience (`Audience`, when provided) whatever the terminal could do.
|
|
252
|
-
* `
|
|
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(...))`.
|
|
253
259
|
*
|
|
254
260
|
* No input is mounted: the view reads no keys and never enters raw mode, so Ctrl-C stays the platform's SIGINT,
|
|
255
261
|
* which interrupts the program and so closes the scope. Each run holds the process-wide mount permit from its
|
|
@@ -275,6 +281,10 @@ var CliUi = class CliUi {
|
|
|
275
281
|
* naming the peers, never a silent `otherwise`. Screens in sequence make a wizard: discover the defaults first,
|
|
276
282
|
* pass each as an `otherwise`, and a non-interactive run returns exactly them.
|
|
277
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
|
+
*
|
|
278
288
|
* As with `CliUi.run`, do not log while the screen is mounted: a line written to the terminal from elsewhere tears
|
|
279
289
|
* the frame.
|
|
280
290
|
*
|
|
@@ -342,6 +352,80 @@ var CliUi = class CliUi {
|
|
|
342
352
|
* @param load - imports the module whose default export is the screen
|
|
343
353
|
*/
|
|
344
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
|
+
});
|
|
345
429
|
};
|
|
346
430
|
|
|
347
431
|
//#endregion
|
package/ui/CliUiLive.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import { CliInteractive } from "../CliInteractive.js";
|
|
2
|
-
import { underGithubActions } from "../internal/autoFormat.js";
|
|
3
|
-
import {
|
|
2
|
+
import { autoFormat, underGithubActions } from "../internal/autoFormat.js";
|
|
3
|
+
import { CliLinks } from "../CliLinks.js";
|
|
4
|
+
import { CliTheme } from "../CliTheme.js";
|
|
5
|
+
import { Render } from "../Render.js";
|
|
4
6
|
import { fromReact, inkModules, loadInk, withInkColour } from "./internal/ink.js";
|
|
5
7
|
import { errorBoundary } from "./internal/ErrorBoundary.js";
|
|
6
8
|
import { holder, holderSlot } from "./internal/Holder.js";
|
|
7
9
|
import { UiStreams } from "./UiStreams.js";
|
|
8
10
|
import { makeInkConsole } from "./internal/inkConsole.js";
|
|
11
|
+
import { LazyViewShapeError, loadView } from "./internal/lazyView.js";
|
|
9
12
|
import { mountPermit } from "./internal/mountPermit.js";
|
|
10
13
|
import { drainPerformance, resolveDrain } from "./internal/perfDrain.js";
|
|
11
14
|
import { UiRenderOptions } from "./internal/renderOptions.js";
|
|
12
15
|
import { uiProviders } from "./internal/UiProviders.js";
|
|
13
16
|
import { useTerminalSize } from "./UiTheme.js";
|
|
14
17
|
import { Cause, Clock, Duration, Effect, Exit, Fiber, Option, PubSub, Pull, Queue, Schedule, Scheduler, Scope, Stream } from "effect";
|
|
15
|
-
import { Audience } from "@effected/env";
|
|
18
|
+
import { Audience, TerminalEnv } from "@effected/env";
|
|
16
19
|
import { CommandNeutralizer } from "@effected/github-commands";
|
|
17
20
|
|
|
18
21
|
//#region src/ui/CliUiLive.ts
|
|
@@ -48,7 +51,8 @@ const live = (options) => Effect.gen(function* () {
|
|
|
48
51
|
const begins = options.begins ?? ((event) => options.isStart(event));
|
|
49
52
|
if (!(Number.isFinite(tickMillis) && tickMillis > 0)) return yield* Effect.die(new Error(TICK_INVALID(tickMillis)));
|
|
50
53
|
const audience = yield* Effect.serviceOption(Audience);
|
|
51
|
-
const
|
|
54
|
+
const cliTheme = yield* CliTheme;
|
|
55
|
+
const theme = CliTheme.forAudience(cliTheme.forStream("stdout"), Option.isSome(audience) ? audience.value.kind : void 0);
|
|
52
56
|
const colour = theme.color;
|
|
53
57
|
const neutralize = yield* underGithubActions;
|
|
54
58
|
const provided = {
|
|
@@ -90,11 +94,25 @@ const live = (options) => Effect.gen(function* () {
|
|
|
90
94
|
run = void 0;
|
|
91
95
|
return current === void 0 ? Effect.succeed(void 0) : Effect.as(unmount(current), current);
|
|
92
96
|
}));
|
|
97
|
+
/**
|
|
98
|
+
* A lazy view's shape errors already warned about. A shape error is deterministic, and the lazy view keeps one
|
|
99
|
+
* error object for it, so a long watch session warns once for it, not once per run; any other failure is a fresh
|
|
100
|
+
* object, and warns each time.
|
|
101
|
+
*/
|
|
102
|
+
const shapesWarned = /* @__PURE__ */ new Set();
|
|
103
|
+
/** The degraded-run warning, unless it is a shape error this view has already warned about. */
|
|
104
|
+
const warning = (error) => {
|
|
105
|
+
if (error instanceof LazyViewShapeError) {
|
|
106
|
+
if (shapesWarned.has(error)) return Effect.void;
|
|
107
|
+
shapesWarned.add(error);
|
|
108
|
+
}
|
|
109
|
+
return Effect.logWarning(DEGRADED(error));
|
|
110
|
+
};
|
|
93
111
|
/** Stop drawing a run: unmount first, so the one warning never lands inside a frame, then warn. */
|
|
94
112
|
const degrade = (current, error) => Effect.suspend(() => {
|
|
95
113
|
if (current.degraded) return unmount(current);
|
|
96
114
|
current.degraded = true;
|
|
97
|
-
return Effect.andThen(unmount(current),
|
|
115
|
+
return Effect.andThen(unmount(current), warning(error));
|
|
98
116
|
});
|
|
99
117
|
/** Act on a failure the boundary reported for the run mounted now, if any. */
|
|
100
118
|
const checkFailure = Effect.suspend(() => {
|
|
@@ -102,8 +120,16 @@ const live = (options) => Effect.gen(function* () {
|
|
|
102
120
|
const failed = current?.failed;
|
|
103
121
|
return current === void 0 || failed === void 0 ? Effect.void : degrade(current, failed.error);
|
|
104
122
|
});
|
|
123
|
+
/** Say once that a run stopped drawing: already said for a degraded run, and said here for one that never mounted. */
|
|
124
|
+
const warnOnce = (current, error) => Effect.suspend(() => {
|
|
125
|
+
if (current.degraded) return Effect.void;
|
|
126
|
+
current.degraded = true;
|
|
127
|
+
return warning(error);
|
|
128
|
+
});
|
|
105
129
|
/** The final frame as a string, at the stdout width (80 when it reports none) and with no height to fit. */
|
|
106
130
|
const printFrame = (current) => Effect.gen(function* () {
|
|
131
|
+
const viewLoaded = yield* Effect.exit(loadView(options.render));
|
|
132
|
+
if (Exit.isFailure(viewLoaded)) return yield* warnOnce(current, Cause.squash(viewLoaded.cause));
|
|
107
133
|
const { ink, react } = yield* loadInk;
|
|
108
134
|
const frame = yield* frameOf;
|
|
109
135
|
const reported = streams.stdout.columns;
|
|
@@ -123,15 +149,32 @@ const live = (options) => Effect.gen(function* () {
|
|
|
123
149
|
});
|
|
124
150
|
const text = yield* Effect.scoped(Effect.andThen(withInkColour(colour), Effect.sync(() => ink.renderToString(tree, { columns }))));
|
|
125
151
|
drainPerformance(drain);
|
|
126
|
-
if (failure !== void 0)
|
|
127
|
-
if (!current.degraded) {
|
|
128
|
-
current.degraded = true;
|
|
129
|
-
yield* Effect.logWarning(DEGRADED(failure.error));
|
|
130
|
-
}
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
152
|
+
if (failure !== void 0) return yield* warnOnce(current, failure.error);
|
|
133
153
|
bridge.print(neutralize ? CommandNeutralizer.text(text) : text);
|
|
134
154
|
});
|
|
155
|
+
/** The context `final`'s document is rendered with: `Doc.print`'s when the environment is there. */
|
|
156
|
+
const finalContext = Effect.gen(function* () {
|
|
157
|
+
const terminal = yield* Effect.serviceOption(TerminalEnv);
|
|
158
|
+
const links = yield* Effect.serviceOption(CliLinks);
|
|
159
|
+
if (Option.isSome(terminal) && Option.isSome(links) && Option.isSome(audience)) return yield* Render.context("stdout").pipe(Effect.provideService(CliTheme, cliTheme), Effect.provideService(TerminalEnv, terminal.value), Effect.provideService(CliLinks, links.value), Effect.provideService(Audience, audience.value));
|
|
160
|
+
return Render.contextOf({
|
|
161
|
+
audience: Option.isSome(audience) ? audience.value.kind : "human",
|
|
162
|
+
color: theme.color,
|
|
163
|
+
glyphs: theme.glyphs,
|
|
164
|
+
...neutralize ? { neutralizeWorkflowCommands: true } : {}
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
/** A run's `final` document, printed as `Doc.print` would, to the view's stdout; never Ink. */
|
|
168
|
+
const printFinal = (current, final) => Effect.gen(function* () {
|
|
169
|
+
const built = yield* Effect.exit(Effect.try({
|
|
170
|
+
try: () => final(state),
|
|
171
|
+
catch: (error) => error
|
|
172
|
+
}));
|
|
173
|
+
if (Exit.isFailure(built)) return yield* warnOnce(current, Cause.squash(built.cause));
|
|
174
|
+
const ctx = yield* finalContext;
|
|
175
|
+
const text = Render[yield* autoFormat(ctx.audience)](built.value, ctx);
|
|
176
|
+
if (text !== "") bridge.print(text);
|
|
177
|
+
});
|
|
135
178
|
/** Mount a run's view with the current state, its tick beside it; a failure degrades the run. */
|
|
136
179
|
const mount = (current) => Effect.gen(function* () {
|
|
137
180
|
const scope = yield* Scope.make("sequential");
|
|
@@ -149,8 +192,9 @@ const live = (options) => Effect.gen(function* () {
|
|
|
149
192
|
};
|
|
150
193
|
yield* Effect.gen(function* () {
|
|
151
194
|
yield* Effect.acquireRelease(mountPermit.take(1), () => mountPermit.release(1), { interruptible: true });
|
|
152
|
-
yield* Effect.acquireRelease(Effect.sync(() => overrides.onMount?.()), () => Effect.sync(() => overrides.onUnmount?.(void 0)));
|
|
195
|
+
yield* Effect.acquireRelease(Effect.sync(() => overrides.onMount?.("live")), () => Effect.sync(() => overrides.onUnmount?.(void 0)));
|
|
153
196
|
const { ink, react } = yield* loadInk;
|
|
197
|
+
yield* Effect.orDie(loadView(options.render));
|
|
154
198
|
yield* withInkColour(colour);
|
|
155
199
|
const frame = yield* frameOf;
|
|
156
200
|
const initial = elementOf(state, frame);
|
|
@@ -183,8 +227,8 @@ const live = (options) => Effect.gen(function* () {
|
|
|
183
227
|
interactive: true,
|
|
184
228
|
exitOnCtrlC: false,
|
|
185
229
|
patchConsole: false,
|
|
186
|
-
...overrides.
|
|
187
|
-
...overrides.
|
|
230
|
+
...overrides.onRender === void 0 ? {} : { onRender: overrides.onRender },
|
|
231
|
+
...overrides.maxFps === void 0 ? {} : { maxFps: overrides.maxFps }
|
|
188
232
|
});
|
|
189
233
|
drainPerformance(drain);
|
|
190
234
|
return instance;
|
|
@@ -242,7 +286,10 @@ const live = (options) => Effect.gen(function* () {
|
|
|
242
286
|
/** End the run: unmount, which commits its frame; a degraded run that never painted prints its frame instead. */
|
|
243
287
|
const endRun = Effect.flatMap(takeRun, (current) => {
|
|
244
288
|
if (current === void 0) return Effect.void;
|
|
245
|
-
if (!interactive)
|
|
289
|
+
if (!interactive) {
|
|
290
|
+
if (options.mode === "hosted") return Effect.void;
|
|
291
|
+
return options.final === void 0 ? printFrame(current) : printFinal(current, options.final);
|
|
292
|
+
}
|
|
246
293
|
return Effect.suspend(() => {
|
|
247
294
|
const failed = current.failed;
|
|
248
295
|
const warned = failed === void 0 || current.degraded ? Effect.void : Effect.suspend(() => {
|
package/ui/Select.js
CHANGED
|
@@ -160,7 +160,11 @@ var Select = class Select {
|
|
|
160
160
|
* Draw the select: the message, the list (the highlighted row in the accent token with the arrow glyph, disabled
|
|
161
161
|
* rows muted, and at colour `none` ending in ` (disabled)` instead, every row cut to the width with the glyph set's
|
|
162
162
|
* ellipsis), the highlighted choice's detail, and the
|
|
163
|
-
* key help.
|
|
163
|
+
* key help.
|
|
164
|
+
*
|
|
165
|
+
* @remarks
|
|
166
|
+
* A choice's `detail` is drawn only while that choice is highlighted, as one muted line under the list, so the others'
|
|
167
|
+
* details are not on screen until the cursor reaches them. Enter calls `onSubmit` with the value; `q` cancels the screen with `"escape"`.
|
|
164
168
|
*
|
|
165
169
|
* Single-shot: the choices and the starting choice are read once, when the view mounts, and later changes to them
|
|
166
170
|
* are ignored; after a submit it stays as it is. Render a new view (a new screen) to ask again.
|
package/ui/TextInput.js
CHANGED
|
@@ -16,12 +16,24 @@ const init = (options = {}) => {
|
|
|
16
16
|
submitted: false
|
|
17
17
|
};
|
|
18
18
|
};
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
const segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
|
|
20
|
+
/** The grapheme boundary before `at`: the start of the grapheme `at` is in or just after; 0 at the start. */
|
|
21
|
+
const previous = (value, at) => {
|
|
22
|
+
let boundary = 0;
|
|
23
|
+
for (const { index } of segmenter.segment(value)) {
|
|
24
|
+
if (index >= at) break;
|
|
25
|
+
boundary = index;
|
|
26
|
+
}
|
|
27
|
+
return boundary;
|
|
28
|
+
};
|
|
29
|
+
/** The grapheme boundary after `at`: the end of the grapheme that starts at or contains `at`; the length at the end. */
|
|
30
|
+
const following = (value, at) => {
|
|
31
|
+
for (const { index, segment } of segmenter.segment(value)) {
|
|
32
|
+
const end = index + segment.length;
|
|
33
|
+
if (end > at) return end;
|
|
34
|
+
}
|
|
35
|
+
return value.length;
|
|
36
|
+
};
|
|
25
37
|
const insert = (state, text) => ({
|
|
26
38
|
value: state.value.slice(0, state.cursor) + text + state.value.slice(state.cursor),
|
|
27
39
|
cursor: state.cursor + text.length,
|
|
@@ -137,6 +149,19 @@ const windowAround = (before, after, width, ellipsis) => {
|
|
|
137
149
|
const room = Math.max(0, width - Fmt.width(shownBefore));
|
|
138
150
|
return [shownBefore, Fmt.width(after) <= room ? after : room > mark ? `${head(after, room - mark)}${ellipsis}` : head(after, room)];
|
|
139
151
|
};
|
|
152
|
+
/**
|
|
153
|
+
* The value masked either side of the cursor, one `mask` per grapheme of the whole value: the graphemes that start
|
|
154
|
+
* before the cursor, then the rest, so the two always add up to the value's graphemes, wherever the cursor is.
|
|
155
|
+
*/
|
|
156
|
+
const maskedAround = (value, cursor, mask) => {
|
|
157
|
+
let before = 0;
|
|
158
|
+
let total = 0;
|
|
159
|
+
for (const { index } of segmenter.segment(value)) {
|
|
160
|
+
total++;
|
|
161
|
+
if (index < cursor) before++;
|
|
162
|
+
}
|
|
163
|
+
return [mask.repeat(before), mask.repeat(total - before)];
|
|
164
|
+
};
|
|
140
165
|
/** Shown in the help line only; the input reads every key itself. */
|
|
141
166
|
const HELP = KeyTable.make([{
|
|
142
167
|
keys: ["enter"],
|
|
@@ -179,8 +204,8 @@ var TextInput = class TextInput {
|
|
|
179
204
|
static init = init;
|
|
180
205
|
/**
|
|
181
206
|
* Apply a key: a typed character (any, `q` included) or space is inserted at the cursor; backspace and delete
|
|
182
|
-
* remove
|
|
183
|
-
* key changes nothing.
|
|
207
|
+
* remove the grapheme before or after it; left and right move it a grapheme, home and end to either end, clamped
|
|
208
|
+
* to the text; enter marks it submitted. Every other key changes nothing.
|
|
184
209
|
*
|
|
185
210
|
* @param state - where the input is
|
|
186
211
|
* @param key - the key pressed
|
|
@@ -188,7 +213,8 @@ var TextInput = class TextInput {
|
|
|
188
213
|
static step = step;
|
|
189
214
|
/**
|
|
190
215
|
* Draw the input: the message, the value with the cursor shown as `▏` (`|` under ASCII glyphs, so it stays visible
|
|
191
|
-
* without colour),
|
|
216
|
+
* without colour), or one mask per grapheme in its place with `mask`, the placeholder while empty, a validation
|
|
217
|
+
* message in the error token, and the key help. Enter
|
|
192
218
|
* submits when `validate` passes; otherwise its message is shown until the next key other than enter, or a paste.
|
|
193
219
|
*
|
|
194
220
|
* @remarks
|
|
@@ -232,13 +258,27 @@ var TextInput = class TextInput {
|
|
|
232
258
|
for (const pressed of keys) setState((current) => step(current, pressed));
|
|
233
259
|
}));
|
|
234
260
|
const cursorGlyph = glyphs.kind === "unicode" ? "▏" : "|";
|
|
235
|
-
const
|
|
261
|
+
const latched = react.useRef(false);
|
|
262
|
+
if (state.value === "") latched.current = false;
|
|
263
|
+
else if (typeof props.mask === "function" && !latched.current && props.mask(state.value)) latched.current = true;
|
|
264
|
+
const masking = typeof props.mask === "function" ? latched.current : props.mask;
|
|
265
|
+
const mask = masking === void 0 || masking === false ? void 0 : masking === true ? glyphs.kind === "unicode" ? "•" : "*" : lineText(masking);
|
|
266
|
+
const [shownBefore, shownAfter] = mask === void 0 ? [state.value.slice(0, state.cursor), state.value.slice(state.cursor)] : maskedAround(state.value, state.cursor, mask);
|
|
267
|
+
const [before, after] = windowAround(shownBefore, shownAfter, columns - Fmt.width(cursorGlyph), glyphs.ellipsis);
|
|
236
268
|
return react.createElement(ink.Box, { flexDirection: "column" }, react.createElement(Styled, { token: "emphasis" }, Fmt.truncate(lineText(props.message), columns, { ellipsis: glyphs.ellipsis })), react.createElement(ink.Text, null, before, cursorGlyph, after, state.value === "" && props.placeholder !== void 0 ? react.createElement(Styled, { token: "muted" }, Fmt.truncate(lineText(props.placeholder), Math.max(0, columns - Fmt.width(cursorGlyph)), { ellipsis: glyphs.ellipsis })) : null), error === void 0 ? null : react.createElement(Styled, { token: "error" }, Fmt.truncate(lineText(error), columns, { ellipsis: glyphs.ellipsis })), react.createElement(KeyHelp, { tables: [HELP] }));
|
|
237
269
|
};
|
|
238
270
|
/**
|
|
239
271
|
* A ready-made screen for `CliUi.run`: the input, resolving with the submitted text.
|
|
240
272
|
*
|
|
241
|
-
* @
|
|
273
|
+
* @remarks
|
|
274
|
+
* With `mask`, a secret is drawn as one mask per grapheme and never as itself, while the screen still resolves with
|
|
275
|
+
* the real text:
|
|
276
|
+
*
|
|
277
|
+
* ```ts
|
|
278
|
+
* const token = CliUi.run(TextInput.screen({ message: "Token reference?", mask: true }), { clear: true })
|
|
279
|
+
* ```
|
|
280
|
+
*
|
|
281
|
+
* @param options - the message, the starting text, the placeholder, the validator and the mask
|
|
242
282
|
*/
|
|
243
283
|
static screen = (options) => (control) => inkModules().react.createElement(TextInput.View, {
|
|
244
284
|
...options,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/ui/internal/lazyView.ts
|
|
4
|
+
/** Where a lazy view keeps its loader: a `Symbol.for` key, so two copies of the package agree on it. */
|
|
5
|
+
const LOAD = Symbol.for("@effected/cli/ui/lazyView");
|
|
6
|
+
const NOT_LOADED = "@effected/cli/ui: a CliUi.lazyView render was called before its module loaded; only CliUi.live loads it first";
|
|
7
|
+
const kindOf = (value) => value === null ? "null" : Array.isArray(value) ? "an array" : typeof value === "object" ? "an object" : typeof value;
|
|
8
|
+
const EXPECTED = "a view, (state, frame) => ReactElement, or a module whose default export is one: { default: view }";
|
|
9
|
+
/** What a `load` resolved to that is no view: said with what was received and what is expected. */
|
|
10
|
+
const NO_VIEW = (resolved) => {
|
|
11
|
+
if (typeof resolved !== "object" || resolved === null) return `@effected/cli/ui: CliUi.lazyView's load resolved to ${kindOf(resolved)}. Expected ${EXPECTED}`;
|
|
12
|
+
const named = Object.keys(resolved).filter((key) => key !== "default");
|
|
13
|
+
const exports = named.length === 0 ? "" : ` (it exports ${named.join(", ")})`;
|
|
14
|
+
return `@effected/cli/ui: CliUi.lazyView's load resolved to ${Object.hasOwn(resolved, "default") ? `a module whose default export is ${kindOf(resolved.default)}, not a function${exports}; a CommonJS module imported as ESM nests it one level deeper, as default.default` : `a module with no default export${exports}; resolve to the export itself, as .then((module) => module.name)`}. Expected ${EXPECTED}`;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* A load that resolved to no view: deterministic, so a lazy view keeps it (one error object for the handle's life) and
|
|
18
|
+
* the live view warns about it once, where a failed import is tried again by the next run.
|
|
19
|
+
*
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
var LazyViewShapeError = class extends Error {};
|
|
23
|
+
/** The view `load` resolved to: the value itself when it is a function (a `default` property on it is ignored), else
|
|
24
|
+
* its `default` when that is a function; anything else throws, saying what it got. */
|
|
25
|
+
const pick = (resolved) => {
|
|
26
|
+
if (typeof resolved === "function") return resolved;
|
|
27
|
+
if (typeof resolved === "object" && resolved !== null) {
|
|
28
|
+
const fallback = resolved.default;
|
|
29
|
+
if (typeof fallback === "function") return fallback;
|
|
30
|
+
}
|
|
31
|
+
throw new LazyViewShapeError(NO_VIEW(resolved));
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* `CliUi.lazyView`: a `render` that draws with what `load` resolves to, loaded on first use: the render itself, or a
|
|
35
|
+
* module whose default export it is. One load is shared: an import that rejected is cleared, so the next run loads
|
|
36
|
+
* again; a load that resolved to no view is kept, a `LazyViewShapeError` every later run gets as is. Calling the render
|
|
37
|
+
* before it has loaded is a defect, since only `CliUi.live` knows to load it first.
|
|
38
|
+
*
|
|
39
|
+
* @internal
|
|
40
|
+
*/
|
|
41
|
+
const lazyView = (load) => {
|
|
42
|
+
let loaded;
|
|
43
|
+
let pending;
|
|
44
|
+
const ensure = () => {
|
|
45
|
+
pending ??= load().then((resolved) => {
|
|
46
|
+
loaded = pick(resolved);
|
|
47
|
+
}, (error) => {
|
|
48
|
+
pending = void 0;
|
|
49
|
+
throw error;
|
|
50
|
+
});
|
|
51
|
+
return pending;
|
|
52
|
+
};
|
|
53
|
+
const render = (state, frame) => {
|
|
54
|
+
if (loaded === void 0) throw new Error(NOT_LOADED);
|
|
55
|
+
return loaded(state, frame);
|
|
56
|
+
};
|
|
57
|
+
return Object.assign(render, { [LOAD]: ensure });
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Load a lazy view's module before its render is first called; nothing for a render that is not lazy. Fails with what
|
|
61
|
+
* the import failed with.
|
|
62
|
+
*
|
|
63
|
+
* @internal
|
|
64
|
+
*/
|
|
65
|
+
const loadView = (render) => {
|
|
66
|
+
const ensure = render[LOAD];
|
|
67
|
+
return ensure === void 0 ? Effect.void : Effect.asVoid(Effect.tryPromise({
|
|
68
|
+
try: ensure,
|
|
69
|
+
catch: (error) => error
|
|
70
|
+
}));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
//#endregion
|
|
74
|
+
export { LazyViewShapeError, lazyView, loadView };
|