@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/cli",
3
- "version": "0.11.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.0",
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, themeForAudience } from "../CliTheme.js";
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 themeForAudience((yield* CliTheme).forStream("stdout"), Option.isSome(audience) ? audience.value.kind : void 0);
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. In the
252
- * `hosted` mode nothing is written.
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 { CliTheme, themeForAudience } from "../CliTheme.js";
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 theme = themeForAudience((yield* CliTheme).forStream("stdout"), Option.isSome(audience) ? audience.value.kind : void 0);
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), Effect.logWarning(DEGRADED(error)));
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.debug === true ? { debug: true } : {},
187
- ...overrides.onRender === void 0 ? {} : { onRender: overrides.onRender }
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) return options.mode === "hosted" ? Effect.void : printFrame(current);
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. Enter calls `onSubmit` with the value; `q` cancels the screen with `"escape"`.
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 isHigh = (code) => code >= 55296 && code <= 56319;
20
- const isLow = (code) => code >= 56320 && code <= 57343;
21
- /** The code-point boundary before `at`: one code unit back, two when that would land inside a surrogate pair. */
22
- const previous = (value, at) => at >= 2 && isLow(value.charCodeAt(at - 1)) && isHigh(value.charCodeAt(at - 2)) ? at - 2 : Math.max(0, at - 1);
23
- /** The code-point boundary after `at`. */
24
- const following = (value, at) => at + 1 < value.length && isHigh(value.charCodeAt(at)) && isLow(value.charCodeAt(at + 1)) ? at + 2 : Math.min(value.length, at + 1);
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 around it; left, right, home and end move it, clamped to the text; enter marks it submitted. Every other
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), the placeholder while empty, a validation message in the error token, and the key help. Enter
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 [before, after] = windowAround(state.value.slice(0, state.cursor), state.value.slice(state.cursor), columns - Fmt.width(cursorGlyph), glyphs.ellipsis);
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
- * @param options - the message, the starting text, the placeholder and the validator
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 };