@effected/cli 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/Cancelled.js +44 -0
  2. package/CliAudience.js +178 -0
  3. package/CliColor.js +13 -19
  4. package/CliEnv.js +89 -0
  5. package/CliExit.js +1 -1
  6. package/CliFailure.js +253 -0
  7. package/CliInteractive.js +71 -0
  8. package/CliLinks.js +154 -0
  9. package/CliLog.js +294 -0
  10. package/CliLogger.js +34 -33
  11. package/CliMessage.js +83 -0
  12. package/CliPrompt.js +104 -0
  13. package/CliRuntime.js +103 -53
  14. package/CliTest.js +16 -0
  15. package/CliTheme.js +128 -0
  16. package/ConfigIssueRenderer.js +14 -33
  17. package/Doc.js +512 -0
  18. package/Fmt.js +133 -0
  19. package/GithubAnnotation.js +40 -0
  20. package/Glyphs.js +83 -0
  21. package/NotInteractive.js +42 -0
  22. package/README.md +129 -131
  23. package/Render.js +254 -0
  24. package/SchemaIssueRenderer.js +7 -10
  25. package/Status.js +163 -0
  26. package/TestTerminal.js +80 -0
  27. package/Token.js +69 -0
  28. package/index.d.ts +2923 -171
  29. package/index.js +19 -1
  30. package/internal/ansi.js +230 -0
  31. package/internal/autoFormat.js +34 -0
  32. package/internal/canPrompt.js +15 -0
  33. package/internal/counts.js +69 -0
  34. package/internal/diagnostics.js +32 -0
  35. package/internal/displayWidth.js +35 -0
  36. package/internal/failureTarget.js +156 -0
  37. package/internal/fallbackAnswer.js +18 -0
  38. package/internal/fileSink.js +62 -0
  39. package/internal/format.js +62 -7
  40. package/internal/layout.js +250 -0
  41. package/internal/linkScheme.js +30 -0
  42. package/internal/linkTarget.js +50 -0
  43. package/internal/logSafety.js +46 -0
  44. package/internal/renderAnsi.js +52 -0
  45. package/internal/renderDoc.js +319 -0
  46. package/internal/renderGithubLog.js +46 -0
  47. package/internal/renderMarkdown.js +367 -0
  48. package/internal/renderPlain.js +50 -0
  49. package/internal/scanAudience.js +106 -0
  50. package/internal/splitFrame.js +56 -0
  51. package/internal/wizardGate.js +18 -0
  52. package/package.json +35 -5
  53. package/testing.d.ts +88 -2
  54. package/testing.js +2 -1
  55. package/ui/CliUi.js +348 -0
  56. package/ui/CliUiLive.js +399 -0
  57. package/ui/Confirm.js +245 -0
  58. package/ui/DocView.js +74 -0
  59. package/ui/KeyHelp.js +62 -0
  60. package/ui/KeyTable.js +199 -0
  61. package/ui/MultiSelect.js +260 -0
  62. package/ui/Select.js +226 -0
  63. package/ui/Tabs.js +202 -0
  64. package/ui/TextInput.js +250 -0
  65. package/ui/Toggle.js +32 -0
  66. package/ui/UiKey.js +44 -0
  67. package/ui/UiProvider.js +60 -0
  68. package/ui/UiStreams.js +18 -0
  69. package/ui/UiTheme.js +119 -0
  70. package/ui/Viewport.js +204 -0
  71. package/ui/internal/ErrorBoundary.js +30 -0
  72. package/ui/internal/Holder.js +74 -0
  73. package/ui/internal/ScreenContext.js +52 -0
  74. package/ui/internal/UiProviders.js +21 -0
  75. package/ui/internal/ink.js +122 -0
  76. package/ui/internal/inkChalk.js +58 -0
  77. package/ui/internal/inkConsole.js +146 -0
  78. package/ui/internal/lineText.js +19 -0
  79. package/ui/internal/mountPermit.js +16 -0
  80. package/ui/internal/perfDrain.js +33 -0
  81. package/ui/internal/processStreams.js +19 -0
  82. package/ui/internal/renderOptions.js +13 -0
  83. package/ui/testing/CliUiTest.js +735 -0
  84. package/ui/testing/fakeStreams.js +76 -0
  85. package/ui/testing/terminalModel.js +59 -0
  86. package/ui-testing.d.ts +446 -0
  87. package/ui-testing.js +3 -0
  88. package/ui.d.ts +1648 -0
  89. package/ui.js +17 -0
@@ -0,0 +1,399 @@
1
+ import { CliInteractive } from "../CliInteractive.js";
2
+ import { underGithubActions } from "../internal/autoFormat.js";
3
+ import { CliTheme, themeForAudience } from "../CliTheme.js";
4
+ import { fromReact, inkModules, loadInk, withInkColour } from "./internal/ink.js";
5
+ import { errorBoundary } from "./internal/ErrorBoundary.js";
6
+ import { holder, holderSlot } from "./internal/Holder.js";
7
+ import { UiStreams } from "./UiStreams.js";
8
+ import { makeInkConsole } from "./internal/inkConsole.js";
9
+ import { mountPermit } from "./internal/mountPermit.js";
10
+ import { drainPerformance, resolveDrain } from "./internal/perfDrain.js";
11
+ import { UiRenderOptions } from "./internal/renderOptions.js";
12
+ import { uiProviders } from "./internal/UiProviders.js";
13
+ import { useTerminalSize } from "./UiTheme.js";
14
+ import { Cause, Clock, Duration, Effect, Exit, Fiber, Option, PubSub, Pull, Queue, Schedule, Scheduler, Scope, Stream } from "effect";
15
+ import { Audience } from "@effected/env";
16
+ import { CommandNeutralizer } from "@effected/github-commands";
17
+
18
+ //#region src/ui/CliUiLive.ts
19
+ /**
20
+ * The live tree's height clamp: at most the terminal's rows less one, re-read on every render and when the terminal
21
+ * resizes, so a tall frame never takes Ink's clear-terminal path, which wipes the scrollback. No width: Ink sizes the root itself.
22
+ */
23
+ const heightClamp = fromReact(() => {
24
+ const HeightClamp = (props) => {
25
+ const { ink, react } = inkModules();
26
+ const { rows } = useTerminalSize();
27
+ return react.createElement(ink.Box, {
28
+ flexDirection: "column",
29
+ maxHeight: rows,
30
+ overflow: "hidden"
31
+ }, react.createElement(ink.Box, {
32
+ flexDirection: "column",
33
+ flexShrink: 0
34
+ }, props.children));
35
+ };
36
+ HeightClamp.displayName = "CliUiLiveHeightClamp";
37
+ return HeightClamp;
38
+ });
39
+ const TICK_INVALID = (tickMillis) => `@effected/cli/ui: CliUi.live's tickMillis must be a positive, finite number of milliseconds, not ${tickMillis}`;
40
+ const DEGRADED = (error) => `@effected/cli/ui: the live view stopped drawing this run: ${error instanceof Error ? error.message : String(error)}`;
41
+ /**
42
+ * `CliUi.live`, kept in its own module: see `CliUi.live` for the contract.
43
+ *
44
+ * @internal
45
+ */
46
+ const live = (options) => Effect.gen(function* () {
47
+ const tickMillis = options.tickMillis ?? 80;
48
+ const begins = options.begins ?? ((event) => options.isStart(event));
49
+ if (!(Number.isFinite(tickMillis) && tickMillis > 0)) return yield* Effect.die(new Error(TICK_INVALID(tickMillis)));
50
+ const audience = yield* Effect.serviceOption(Audience);
51
+ const theme = themeForAudience((yield* CliTheme).forStream("stdout"), Option.isSome(audience) ? audience.value.kind : void 0);
52
+ const colour = theme.color;
53
+ const neutralize = yield* underGithubActions;
54
+ const provided = {
55
+ theme,
56
+ glyphs: theme.glyphs,
57
+ ...neutralize ? { neutralizeWorkflowCommands: true } : {}
58
+ };
59
+ const interactive = yield* CliInteractive;
60
+ const streams = yield* UiStreams;
61
+ const overrides = yield* UiRenderOptions;
62
+ const drain = yield* resolveDrain(options.drainPerformance ?? "auto");
63
+ const bridge = yield* makeInkConsole;
64
+ const inbox = yield* Queue.unbounded();
65
+ const source = options.events;
66
+ const stream = Stream.isStream(source) ? source : void 0;
67
+ const subscription = stream === void 0 ? source : void 0;
68
+ let state = options.initial;
69
+ let run;
70
+ let finalTaken = false;
71
+ const Frame = (props) => options.render(props.state, props.frame);
72
+ const frameOf = Effect.map(Clock.currentTimeMillis, (now) => Math.floor(now / tickMillis));
73
+ const elementOf = (shown, frame) => inkModules().react.createElement(Frame, {
74
+ state: shown,
75
+ frame
76
+ });
77
+ /**
78
+ * Unmount what a run has mounted: Ink's own unmount commits the last frame. Nothing is written after it. The clear
79
+ * and the close are one uninterruptible step: an interrupt landing between them would leave a scope nobody closes,
80
+ * its permit held, its instance mounted and its tick running.
81
+ */
82
+ const unmount = (current) => Effect.uninterruptible(Effect.suspend(() => {
83
+ const mounted = current.mounted;
84
+ current.mounted = void 0;
85
+ return mounted === void 0 ? Effect.void : Scope.close(mounted.scope, Exit.void);
86
+ }));
87
+ /** Take the run off and unmount it, in one step an interrupt cannot split; the run taken, if any. */
88
+ const takeRun = Effect.uninterruptible(Effect.suspend(() => {
89
+ const current = run;
90
+ run = void 0;
91
+ return current === void 0 ? Effect.succeed(void 0) : Effect.as(unmount(current), current);
92
+ }));
93
+ /** Stop drawing a run: unmount first, so the one warning never lands inside a frame, then warn. */
94
+ const degrade = (current, error) => Effect.suspend(() => {
95
+ if (current.degraded) return unmount(current);
96
+ current.degraded = true;
97
+ return Effect.andThen(unmount(current), Effect.logWarning(DEGRADED(error)));
98
+ });
99
+ /** Act on a failure the boundary reported for the run mounted now, if any. */
100
+ const checkFailure = Effect.suspend(() => {
101
+ const current = run;
102
+ const failed = current?.failed;
103
+ return current === void 0 || failed === void 0 ? Effect.void : degrade(current, failed.error);
104
+ });
105
+ /** The final frame as a string, at the stdout width (80 when it reports none) and with no height to fit. */
106
+ const printFrame = (current) => Effect.gen(function* () {
107
+ const { ink, react } = yield* loadInk;
108
+ const frame = yield* frameOf;
109
+ const reported = streams.stdout.columns;
110
+ const columns = reported !== void 0 && reported > 0 ? reported : 80;
111
+ let failure;
112
+ const tree = react.createElement(errorBoundary(), {
113
+ onError: (error) => {
114
+ failure ??= { error };
115
+ },
116
+ children: uiProviders({
117
+ ...provided,
118
+ size: {
119
+ columns,
120
+ rows: Number.POSITIVE_INFINITY
121
+ }
122
+ }, elementOf(state, frame))
123
+ });
124
+ const text = yield* Effect.scoped(Effect.andThen(withInkColour(colour), Effect.sync(() => ink.renderToString(tree, { columns }))));
125
+ 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
+ }
133
+ bridge.print(neutralize ? CommandNeutralizer.text(text) : text);
134
+ });
135
+ /** Mount a run's view with the current state, its tick beside it; a failure degrades the run. */
136
+ const mount = (current) => Effect.gen(function* () {
137
+ const scope = yield* Scope.make("sequential");
138
+ const slot = holderSlot();
139
+ current.mounted = {
140
+ scope,
141
+ slot
142
+ };
143
+ const report = (error) => {
144
+ current.failed ??= { error };
145
+ Queue.offerUnsafe(inbox, {
146
+ _tag: "Failed",
147
+ run: current
148
+ });
149
+ };
150
+ yield* Effect.gen(function* () {
151
+ 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)));
153
+ const { ink, react } = yield* loadInk;
154
+ yield* withInkColour(colour);
155
+ const frame = yield* frameOf;
156
+ const initial = elementOf(state, frame);
157
+ const shown = state;
158
+ const lastGood = () => {
159
+ const good = current.good;
160
+ return good === void 0 ? null : react.createElement(errorBoundary(), {
161
+ onError: () => {
162
+ current.painted = false;
163
+ },
164
+ children: elementOf(good.state, good.frame)
165
+ });
166
+ };
167
+ const tree = react.createElement(errorBoundary(), {
168
+ onError: report,
169
+ children: uiProviders(provided, react.createElement(bridge.Bridge, null, react.createElement(heightClamp(), null, react.createElement(errorBoundary(), {
170
+ onError: report,
171
+ fallback: lastGood,
172
+ children: react.createElement(holder(), {
173
+ initial,
174
+ bind: slot.bind
175
+ })
176
+ }))))
177
+ });
178
+ yield* Effect.acquireRelease(Effect.sync(() => {
179
+ const instance = ink.render(tree, {
180
+ stdin: streams.stdin,
181
+ stdout: streams.stdout,
182
+ stderr: streams.stderr,
183
+ interactive: true,
184
+ exitOnCtrlC: false,
185
+ patchConsole: false,
186
+ ...overrides.debug === true ? { debug: true } : {},
187
+ ...overrides.onRender === void 0 ? {} : { onRender: overrides.onRender }
188
+ });
189
+ drainPerformance(drain);
190
+ return instance;
191
+ }), (instance) => Effect.promise(async () => {
192
+ bridge.detach();
193
+ const exited = instance.waitUntilExit();
194
+ instance.unmount();
195
+ drainPerformance(drain);
196
+ await exited.catch(() => void 0);
197
+ }));
198
+ yield* Effect.forkIn(Effect.repeat(Effect.flatMap(frameOf, (at) => Queue.offer(inbox, {
199
+ _tag: "Tick",
200
+ frame: at
201
+ })), Schedule.spaced(Duration.millis(tickMillis))), scope);
202
+ current.frame = frame;
203
+ if (current.failed === void 0) {
204
+ current.painted = true;
205
+ current.good = {
206
+ state: shown,
207
+ frame
208
+ };
209
+ }
210
+ }).pipe(Scope.provide(scope), Effect.onExit((exit) => Exit.isSuccess(exit) ? Effect.void : Effect.andThen(Effect.sync(() => {
211
+ if (current.mounted?.scope === scope) current.mounted = void 0;
212
+ }), Scope.close(scope, exit))));
213
+ yield* checkFailure;
214
+ }).pipe(Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : degrade(current, Cause.squash(cause))));
215
+ /**
216
+ * Push the current state to the mounted run, at `at` or the clock's frame, and wait for React to commit it; a
217
+ * frame that throws degrades the run.
218
+ */
219
+ const drawAt = (at) => Effect.suspend(() => {
220
+ const current = run;
221
+ const mounted = current?.mounted;
222
+ if (current === void 0 || mounted === void 0) return Effect.void;
223
+ const shown = state;
224
+ return Effect.gen(function* () {
225
+ const frame = at ?? (yield* frameOf);
226
+ yield* Effect.callback((resume) => {
227
+ mounted.slot.swap(elementOf(shown, frame), () => queueMicrotask(() => resume(Effect.void)));
228
+ });
229
+ drainPerformance(drain);
230
+ current.frame = frame;
231
+ if (current.failed === void 0) {
232
+ current.painted = true;
233
+ current.good = {
234
+ state: shown,
235
+ frame
236
+ };
237
+ }
238
+ yield* checkFailure;
239
+ });
240
+ });
241
+ const draw = drawAt(void 0);
242
+ /** End the run: unmount, which commits its frame; a degraded run that never painted prints its frame instead. */
243
+ const endRun = Effect.flatMap(takeRun, (current) => {
244
+ if (current === void 0) return Effect.void;
245
+ if (!interactive) return options.mode === "hosted" ? Effect.void : printFrame(current);
246
+ return Effect.suspend(() => {
247
+ const failed = current.failed;
248
+ const warned = failed === void 0 || current.degraded ? Effect.void : Effect.suspend(() => {
249
+ current.degraded = true;
250
+ return Effect.logWarning(DEGRADED(failed.error));
251
+ });
252
+ return Effect.andThen(warned, Effect.suspend(() => current.painted ? Effect.void : printFrame(current)));
253
+ });
254
+ });
255
+ const beginRun = Effect.suspend(() => {
256
+ const current = {
257
+ mounted: void 0,
258
+ degraded: false,
259
+ painted: false,
260
+ failed: void 0,
261
+ frame: 0,
262
+ good: void 0
263
+ };
264
+ run = current;
265
+ return interactive ? mount(current) : Effect.void;
266
+ });
267
+ /** Fold a chunk, starting and ending runs at its events, and draw once for what is left of it. */
268
+ const onChunk = (chunk) => Effect.gen(function* () {
269
+ let dirty = false;
270
+ for (const event of chunk) {
271
+ const folded = yield* Effect.try({
272
+ try: () => options.reduce(state, event),
273
+ catch: (error) => error
274
+ }).pipe(Effect.catch((error) => Effect.andThen(Effect.suspend(() => run === void 0 ? Effect.void : unmount(run)), Effect.die(error))));
275
+ const before = state;
276
+ if (run?.degraded === true && options.isStart(event)) {
277
+ yield* endRun;
278
+ state = folded;
279
+ yield* beginRun;
280
+ dirty = false;
281
+ continue;
282
+ }
283
+ state = folded;
284
+ if (options.isTerminal(event)) {
285
+ if (dirty || run !== void 0) yield* draw;
286
+ dirty = false;
287
+ yield* endRun;
288
+ } else if (run === void 0) {
289
+ if (begins(event, before, folded)) yield* beginRun;
290
+ dirty = false;
291
+ } else dirty = true;
292
+ }
293
+ if (dirty) yield* draw;
294
+ });
295
+ const onTick = (frame) => Effect.suspend(() => {
296
+ const current = run;
297
+ return current?.mounted === void 0 || frame <= current.frame ? Effect.void : drawAt(frame);
298
+ });
299
+ /**
300
+ * What a subscription holds now, taken without waiting: its buffered messages, then, once its PubSub has been
301
+ * ended with `PubSub.end`, the final message, the first time only. A shut-down subscription holds nothing (and a
302
+ * take from it would interrupt): it is not asked.
303
+ */
304
+ const takeQueued = (sub) => Effect.suspend(() => {
305
+ const queued = PubSub.remainingUnsafe(sub);
306
+ if (Option.isNone(queued)) return Effect.succeed([]);
307
+ const ended = sub.ended.current;
308
+ const final = Option.isSome(ended) && !finalTaken ? [ended.value] : [];
309
+ if (final.length > 0) finalTaken = true;
310
+ return queued.value > 0 ? Effect.map(PubSub.takeUpTo(sub, queued.value), (taken) => [...taken, ...final]) : Effect.succeed(final);
311
+ });
312
+ /**
313
+ * What a subscription still queues, taken without waiting; nothing for a stream. Read once the pump has stopped
314
+ * (an `Ended` comes after it), so nothing else is taking. A subscription whose PubSub was shut down has nothing
315
+ * left, and a take from it would interrupt: it is not asked.
316
+ */
317
+ const queuedTail = subscription === void 0 ? Effect.succeed([]) : takeQueued(subscription);
318
+ const control = Effect.gen(function* () {
319
+ while (true) {
320
+ const messages = yield* Queue.takeAll(inbox);
321
+ let pending = [];
322
+ const flush = Effect.suspend(() => {
323
+ const chunk = pending;
324
+ pending = [];
325
+ return chunk.length === 0 ? Effect.void : onChunk(chunk);
326
+ });
327
+ for (const message of messages) {
328
+ if (message._tag === "Events") {
329
+ pending.push(...message.chunk);
330
+ continue;
331
+ }
332
+ yield* flush;
333
+ switch (message._tag) {
334
+ case "Tick":
335
+ yield* onTick(message.frame);
336
+ break;
337
+ case "Failed":
338
+ if (message.run === run) yield* checkFailure;
339
+ break;
340
+ case "Ended": {
341
+ const tail = yield* queuedTail;
342
+ if (tail.length > 0) yield* onChunk(tail);
343
+ return yield* endRun;
344
+ }
345
+ case "Died":
346
+ yield* takeRun;
347
+ return yield* Effect.failCause(message.cause);
348
+ }
349
+ }
350
+ yield* flush;
351
+ }
352
+ });
353
+ const streamPull = stream === void 0 ? void 0 : yield* Stream.toPull(stream);
354
+ const offerEvents = (chunk) => chunk.length === 0 ? Effect.void : Queue.offer(inbox, {
355
+ _tag: "Events",
356
+ chunk
357
+ });
358
+ /**
359
+ * One take from a subscription, taken directly (no stream machinery), true once its events have ended: its
360
+ * PubSub was shut down, or ended with `PubSub.end` and its final message taken. Only the wait for a message can
361
+ * be interrupted, and nothing from the wait's end to the hand-off to the inbox yields to the scheduler, so an
362
+ * interrupt (`close`) never lands while a taken message is held: it is in the subscription or in the inbox. Each
363
+ * step waits or ends, so holding off the scheduler never spins.
364
+ */
365
+ const subscriptionStep = (sub) => Effect.uninterruptibleMask((restore) => Effect.suspend(() => {
366
+ if (Option.isNone(PubSub.remainingUnsafe(sub))) return Effect.succeed(true);
367
+ if (Option.isSome(sub.ended.current)) return Effect.as(Effect.flatMap(takeQueued(sub), offerEvents), true);
368
+ return restore(PubSub.takeAll(sub)).pipe(Effect.catchCause((cause) => Option.isNone(PubSub.remainingUnsafe(sub)) ? Effect.succeed(void 0) : Effect.failCause(cause)), Effect.flatMap((chunk) => {
369
+ if (chunk === void 0) return Effect.succeed(true);
370
+ const ended = sub.ended.current;
371
+ const final = Option.isSome(ended) && chunk.length === 1 && chunk[0] === ended.value && Option.getOrElse(PubSub.remainingUnsafe(sub), () => 0) === 0;
372
+ if (final) finalTaken = true;
373
+ return Effect.as(offerEvents(chunk), final);
374
+ }));
375
+ })).pipe(Effect.provideService(Scheduler.PreventSchedulerYield, true));
376
+ const pump = streamPull !== void 0 ? Effect.forever(Effect.uninterruptibleMask((restore) => Effect.flatMap(restore(streamPull), offerEvents))).pipe(Pull.catchDone(() => Queue.offer(inbox, { _tag: "Ended" })), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Queue.offer(inbox, {
377
+ _tag: "Died",
378
+ cause
379
+ }))) : Effect.gen(function* () {
380
+ let ended = false;
381
+ while (!ended) ended = yield* subscriptionStep(source);
382
+ yield* Queue.offer(inbox, { _tag: "Ended" });
383
+ });
384
+ const pumping = yield* Effect.forkScoped(pump, { startImmediately: true });
385
+ const controlling = yield* Effect.forkScoped(Effect.onExit(control, () => Fiber.interrupt(pumping)));
386
+ yield* Effect.addFinalizer(() => Effect.andThen(Fiber.interruptAll([controlling, pumping]), takeRun));
387
+ const done = Fiber.join(controlling);
388
+ const settled = Effect.flatMap(Fiber.await(controlling), (exit) => Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause) ? Effect.void : Effect.failCause(exit.cause));
389
+ const ending = yield* Effect.cached(Effect.uninterruptible(Effect.andThen(Fiber.interrupt(pumping), Queue.offer(inbox, { _tag: "Ended" }))));
390
+ return {
391
+ state: Effect.sync(() => state),
392
+ logConsole: bridge.writer,
393
+ done,
394
+ close: Effect.andThen(ending, settled)
395
+ };
396
+ });
397
+
398
+ //#endregion
399
+ export { live };
package/ui/Confirm.js ADDED
@@ -0,0 +1,245 @@
1
+ import { Fmt } from "../Fmt.js";
2
+ import { inkModules } from "./internal/ink.js";
3
+ import { useScreenCancel } from "./internal/ScreenContext.js";
4
+ import { Styled, useGlyphs, useTerminalSize } from "./UiTheme.js";
5
+ import { KeyTable, useKeys } from "./KeyTable.js";
6
+ import { lineText } from "./internal/lineText.js";
7
+ import { KeyHelp } from "./KeyHelp.js";
8
+ import { Toggle } from "./Toggle.js";
9
+ import { Viewport } from "./Viewport.js";
10
+
11
+ //#region src/ui/Confirm.ts
12
+ const assertUniqueKeys = (toggles) => {
13
+ const seen = /* @__PURE__ */ new Set();
14
+ for (const toggle of toggles) {
15
+ if (seen.has(toggle.key)) throw new Error(`@effected/cli/ui: Confirm toggle keys must be unique; "${toggle.key}" repeats`);
16
+ seen.add(toggle.key);
17
+ }
18
+ };
19
+ const init = (options = {}) => {
20
+ const toggles = options.toggles ?? [];
21
+ assertUniqueKeys(toggles);
22
+ return {
23
+ confirmed: options.initial ?? false,
24
+ row: 0,
25
+ toggles,
26
+ submitted: false
27
+ };
28
+ };
29
+ const step = (state, action) => {
30
+ switch (action) {
31
+ case "up": return {
32
+ ...state,
33
+ row: Math.max(0, state.row - 1)
34
+ };
35
+ case "down": return {
36
+ ...state,
37
+ row: Math.min(state.toggles.length, state.row + 1)
38
+ };
39
+ case "toggle": return state.row === 0 ? state : {
40
+ ...state,
41
+ toggles: state.toggles.map((toggle, index) => index === state.row - 1 ? {
42
+ ...toggle,
43
+ value: !toggle.value
44
+ } : toggle)
45
+ };
46
+ case "yes": return {
47
+ ...state,
48
+ confirmed: true
49
+ };
50
+ case "no": return {
51
+ ...state,
52
+ confirmed: false
53
+ };
54
+ case "flip": return {
55
+ ...state,
56
+ confirmed: !state.confirmed
57
+ };
58
+ case "submit": return {
59
+ ...state,
60
+ submitted: true
61
+ };
62
+ case "cancel": return state;
63
+ }
64
+ };
65
+ const result = (state) => ({
66
+ confirmed: state.confirmed,
67
+ toggles: Object.fromEntries(state.toggles.map((toggle) => [toggle.key, toggle.value]))
68
+ });
69
+ /** The bindings; with no toggles the row and toggle keys stay bound (they do nothing) but leave the help line. */
70
+ const bindings = (toggles) => KeyTable.make([
71
+ {
72
+ keys: [{ char: "y" }],
73
+ action: "yes",
74
+ help: "yes"
75
+ },
76
+ {
77
+ keys: [{ char: "n" }],
78
+ action: "no",
79
+ help: "no"
80
+ },
81
+ {
82
+ keys: ["left"],
83
+ action: "flip",
84
+ help: "flip"
85
+ },
86
+ {
87
+ keys: ["right"],
88
+ action: "flip",
89
+ help: "flip"
90
+ },
91
+ {
92
+ keys: ["up"],
93
+ action: "up",
94
+ help: "row",
95
+ hidden: !toggles
96
+ },
97
+ {
98
+ keys: ["down"],
99
+ action: "down",
100
+ help: "row",
101
+ hidden: !toggles
102
+ },
103
+ {
104
+ keys: ["space"],
105
+ action: "toggle",
106
+ help: "toggle",
107
+ hidden: !toggles
108
+ },
109
+ {
110
+ keys: ["enter"],
111
+ action: "submit",
112
+ help: "submit"
113
+ },
114
+ {
115
+ keys: [{ char: "q" }],
116
+ action: "cancel",
117
+ help: "cancel"
118
+ }
119
+ ]);
120
+ const KEYS = bindings(true);
121
+ const ANSWER_KEYS = bindings(false);
122
+ /** The lines around the toggle rows: the question, the answer row and the help line. */
123
+ const RESERVED = 3;
124
+ /**
125
+ * A yes/no question, optionally with extra on/off rows beneath it: a pure reducer, its key table, a view and a
126
+ * ready-made screen.
127
+ *
128
+ * @remarks
129
+ * The rows are the yes/no row first, then each toggle. `y` and `n` set the answer and `←`/`→` flip it, from any
130
+ * row; `↑`/`↓` move between rows; space flips the highlighted toggle and does nothing on the yes/no row; enter
131
+ * submits; `q` cancels with `"escape"`. Toggle keys must be unique; `init` throws, and `screen` dies, on a repeat.
132
+ * With no toggles, the help line leaves out the row and toggle keys. Toggles that do not fit the terminal scroll
133
+ * in a window under the answer row, so the question, the answer and the help line stay on screen.
134
+ * The answer starts as no unless `initial` says otherwise, and `←`/`→` flip it whichever row is highlighted.
135
+ *
136
+ * A toggle passed conditionally is absent from the result when it was left out, so read it back with a fallback:
137
+ *
138
+ * ```ts
139
+ * import { CliUi, Confirm } from "@effected/cli/ui"
140
+ * import { Effect } from "effect"
141
+ *
142
+ * const publish = (drafts: number) =>
143
+ * Effect.gen(function* () {
144
+ * const { confirmed, toggles } = yield* CliUi.run(
145
+ * Confirm.screen({
146
+ * message: "Publish the release?",
147
+ * toggles: drafts > 0 ? [{ key: "promote", label: `promote ${drafts} drafts to stable`, value: true }] : [],
148
+ * }),
149
+ * )
150
+ * const promote = toggles.promote ?? false
151
+ * return { confirmed, promote }
152
+ * })
153
+ * ```
154
+ *
155
+ * @public
156
+ */
157
+ var Confirm = class Confirm {
158
+ constructor() {}
159
+ /**
160
+ * A confirm with its answer (no by default) and toggles, on the yes/no row.
161
+ *
162
+ * @param options - the starting answer and the toggles
163
+ */
164
+ static init = init;
165
+ /**
166
+ * Apply an action (see the class remarks for what each key does).
167
+ *
168
+ * @param state - where the confirm is
169
+ * @param action - the action
170
+ */
171
+ static step = step;
172
+ /**
173
+ * The answer and every toggle's value by key.
174
+ *
175
+ * @param state - where the confirm is
176
+ */
177
+ static result = result;
178
+ /** The keys: y yes, n no, ←/→ flip, ↑/↓ row, space toggle, enter submit, q cancel (the view hides row and toggle from its help when there are no toggles). */
179
+ static keys = KEYS;
180
+ /**
181
+ * Draw the confirm: the question, the answer row (`[Yes] No` or ` Yes [No]`, the chosen answer in brackets so it
182
+ * shows without colour, and in the accent token), a {@link Toggle} row per toggle, and the key help.
183
+ *
184
+ * @remarks
185
+ * Single-shot, like `Select.View`: the options are read once at mount.
186
+ *
187
+ * @param props - the question, the starting answer, the toggles and where the result goes
188
+ */
189
+ static View = (props) => {
190
+ const { ink, react } = inkModules();
191
+ const glyphs = useGlyphs();
192
+ const { columns } = useTerminalSize();
193
+ const cancel = useScreenCancel();
194
+ const [state, setState] = react.useState(() => init({
195
+ ...props.initial === void 0 ? {} : { initial: props.initial },
196
+ ...props.toggles === void 0 ? {} : { toggles: props.toggles }
197
+ }));
198
+ const { onSubmit } = props;
199
+ react.useEffect(() => {
200
+ if (state.submitted) onSubmit(result(state));
201
+ }, [state.submitted]);
202
+ const keys = state.toggles.length === 0 ? ANSWER_KEYS : KEYS;
203
+ useKeys(keys, (action) => {
204
+ if (action === "cancel") cancel("escape");
205
+ else setState((current) => step(current, action));
206
+ });
207
+ const toggleRows = state.toggles.map((toggle) => ({
208
+ _tag: "Item",
209
+ key: toggle.key
210
+ }));
211
+ const numberOf = new Map(state.toggles.map((toggle, index) => [toggle.key, index]));
212
+ const ellipsis = { ellipsis: glyphs.ellipsis };
213
+ const lead = state.row === 0 ? glyphs.arrow : " ".repeat(Fmt.width(glyphs.arrow));
214
+ const answer = (label, chosen) => chosen ? react.createElement(Styled, { token: "accent" }, `[${label}]`) : react.createElement(ink.Text, null, ` ${label} `);
215
+ return react.createElement(ink.Box, { flexDirection: "column" }, react.createElement(Styled, { token: "emphasis" }, Fmt.truncate(lineText(props.message), columns, ellipsis)), react.createElement(ink.Text, null, `${lead} `, answer("Yes", state.confirmed), " ", answer("No", !state.confirmed)), state.toggles.length === 0 ? null : react.createElement(Viewport.View, {
216
+ rows: toggleRows,
217
+ state: Viewport.init(state.toggles.length, state.toggles.length, Math.max(0, state.row - 1)),
218
+ reserved: RESERVED,
219
+ renderRow: (row) => {
220
+ const index = row._tag === "Item" ? numberOf.get(row.key) ?? 0 : 0;
221
+ const toggle = state.toggles[index];
222
+ return react.createElement(Toggle.View, {
223
+ label: toggle?.label ?? "",
224
+ value: toggle?.value ?? false,
225
+ highlighted: state.row === index + 1
226
+ });
227
+ }
228
+ }), react.createElement(KeyHelp, { tables: [keys] }));
229
+ };
230
+ /**
231
+ * A ready-made screen for `CliUi.run`: the confirm, resolving with the answer and the toggles.
232
+ *
233
+ * @param options - the question, the starting answer and the toggles
234
+ */
235
+ static screen = (options) => (control) => {
236
+ assertUniqueKeys(options.toggles ?? []);
237
+ return inkModules().react.createElement(Confirm.View, {
238
+ ...options,
239
+ onSubmit: control.resolve
240
+ });
241
+ };
242
+ };
243
+
244
+ //#endregion
245
+ export { Confirm };