@effected/cli-kit 0.0.0-stage → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cancelled.js +43 -0
- package/CliAudience.js +177 -0
- package/CliColor.js +59 -0
- package/CliEnv.js +101 -0
- package/CliExit.js +64 -0
- package/CliFailure.js +287 -0
- package/CliFormat.js +124 -0
- package/CliInteractive.js +94 -0
- package/CliLinks.js +153 -0
- package/CliLog.js +367 -0
- package/CliLogger.js +91 -0
- package/CliMessage.js +79 -0
- package/CliPrompt.js +110 -0
- package/CliRuntime.js +254 -0
- package/CliTest.js +206 -0
- package/CliTheme.js +142 -0
- package/ConfigIssueRenderer.js +63 -0
- package/Doc.js +548 -0
- package/Fmt.js +335 -0
- package/GithubAnnotation.js +39 -0
- package/Glyphs.js +82 -0
- package/LICENSE +21 -0
- package/NotInteractive.js +41 -0
- package/README.md +215 -2
- package/Render.js +280 -0
- package/SchemaIssueRenderer.js +47 -0
- package/Status.js +165 -0
- package/TestTerminal.js +101 -0
- package/Token.js +68 -0
- package/index.d.ts +3951 -0
- package/index.js +27 -0
- package/internal/ExitRequested.js +21 -0
- package/internal/HelpRouting.js +35 -0
- package/internal/ansi.js +229 -0
- package/internal/autoFormat.js +33 -0
- package/internal/canPrompt.js +14 -0
- package/internal/counts.js +83 -0
- package/internal/diagnostics.js +31 -0
- package/internal/displayPath.js +46 -0
- package/internal/displayWidth.js +42 -0
- package/internal/failureTarget.js +195 -0
- package/internal/fallbackAnswer.js +17 -0
- package/internal/fileSink.js +61 -0
- package/internal/format.js +175 -0
- package/internal/inspect.js +40 -0
- package/internal/isExitCode.js +11 -0
- package/internal/layout.js +249 -0
- package/internal/linkScheme.js +29 -0
- package/internal/linkTarget.js +49 -0
- package/internal/logSafety.js +45 -0
- package/internal/renderAnsi.js +51 -0
- package/internal/renderDoc.js +322 -0
- package/internal/renderGithubLog.js +59 -0
- package/internal/renderMarkdown.js +367 -0
- package/internal/renderPlain.js +49 -0
- package/internal/scanAudience.js +105 -0
- package/internal/splitFrame.js +55 -0
- package/internal/terminalCapabilities.js +23 -0
- package/internal/wizardGate.js +17 -0
- package/package.json +90 -5
- package/testing.d.ts +246 -0
- package/testing.js +4 -0
- package/tsdoc-metadata.json +11 -0
- package/ui/CliUi.js +507 -0
- package/ui/CliUiDetached.js +36 -0
- package/ui/CliUiLive.js +436 -0
- package/ui/CliUiRouteStream.js +72 -0
- package/ui/Confirm.js +244 -0
- package/ui/DocView.js +76 -0
- package/ui/KeyHelp.js +61 -0
- package/ui/KeyTable.js +198 -0
- package/ui/MultiSelect.js +259 -0
- package/ui/Select.js +229 -0
- package/ui/Tabs.js +201 -0
- package/ui/TextInput.js +289 -0
- package/ui/Toggle.js +31 -0
- package/ui/UiKey.js +48 -0
- package/ui/UiProvider.js +62 -0
- package/ui/UiStreams.js +17 -0
- package/ui/UiTheme.js +147 -0
- package/ui/Viewport.js +203 -0
- package/ui/internal/ErrorBoundary.js +29 -0
- package/ui/internal/Holder.js +73 -0
- package/ui/internal/ScreenContext.js +51 -0
- package/ui/internal/UiProviders.js +38 -0
- package/ui/internal/ink.js +121 -0
- package/ui/internal/inkChalk.js +47 -0
- package/ui/internal/inkConsole.js +150 -0
- package/ui/internal/lazyView.js +73 -0
- package/ui/internal/lineRouter.js +70 -0
- package/ui/internal/lineText.js +18 -0
- package/ui/internal/mountPermit.js +15 -0
- package/ui/internal/processStreams.js +18 -0
- package/ui/internal/renderOptions.js +12 -0
- package/ui/testing/CliUiTest.js +792 -0
- package/ui/testing/fakeStreams.js +104 -0
- package/ui/testing/terminalModel.js +67 -0
- package/ui-testing-serializer.d.ts +12 -0
- package/ui-testing-serializer.js +32 -0
- package/ui-testing.d.ts +558 -0
- package/ui-testing.js +3 -0
- package/ui.d.ts +1990 -0
- package/ui.js +17 -0
package/ui/CliUiLive.js
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import { CliInteractive } from "../CliInteractive.js";
|
|
2
|
+
import { CliLinks } from "../CliLinks.js";
|
|
3
|
+
import { CliTheme } from "../CliTheme.js";
|
|
4
|
+
import { autoFormat } from "../internal/autoFormat.js";
|
|
5
|
+
import { Render } from "../Render.js";
|
|
6
|
+
import { errorBoundary } from "./internal/ErrorBoundary.js";
|
|
7
|
+
import { holder, holderSlot } from "./internal/Holder.js";
|
|
8
|
+
import { fromReact, inkModules, loadInk, withInkColour } from "./internal/ink.js";
|
|
9
|
+
import { makeInkConsole } from "./internal/inkConsole.js";
|
|
10
|
+
import { LazyViewShapeError, loadView } from "./internal/lazyView.js";
|
|
11
|
+
import { mountPermit } from "./internal/mountPermit.js";
|
|
12
|
+
import { UiRenderOptions } from "./internal/renderOptions.js";
|
|
13
|
+
import { treeBase, uiProviders } from "./internal/UiProviders.js";
|
|
14
|
+
import { UiStreams } from "./UiStreams.js";
|
|
15
|
+
import { useTerminalSize } from "./UiTheme.js";
|
|
16
|
+
import { Cause, Clock, Duration, Effect, Exit, Fiber, Option, PubSub, Pull, Queue, Schedule, Scheduler, Scope, Stream } from "effect";
|
|
17
|
+
import { Audience, TerminalEnv } from "@effected/env";
|
|
18
|
+
import { CommandNeutralizer } from "@effected/github-commands";
|
|
19
|
+
|
|
20
|
+
// src/ui/CliUiLive.ts
|
|
21
|
+
/**
|
|
22
|
+
* The live tree's height clamp: at most the terminal's rows less one, re-read on every render and when the terminal
|
|
23
|
+
* resizes, so a tall frame never takes Ink's clear-terminal path, which wipes the scrollback. No width: Ink sizes the root itself.
|
|
24
|
+
*/
|
|
25
|
+
const heightClamp = fromReact(() => {
|
|
26
|
+
const HeightClamp = (props) => {
|
|
27
|
+
const { ink, react } = inkModules();
|
|
28
|
+
const { rows } = useTerminalSize();
|
|
29
|
+
return react.createElement(ink.Box, {
|
|
30
|
+
flexDirection: "column",
|
|
31
|
+
maxHeight: rows,
|
|
32
|
+
overflow: "hidden"
|
|
33
|
+
}, react.createElement(ink.Box, {
|
|
34
|
+
flexDirection: "column",
|
|
35
|
+
flexShrink: 0
|
|
36
|
+
}, props.children));
|
|
37
|
+
};
|
|
38
|
+
HeightClamp.displayName = "CliUiLiveHeightClamp";
|
|
39
|
+
return HeightClamp;
|
|
40
|
+
});
|
|
41
|
+
const TICK_INVALID = (tickMillis) => `@effected/cli-kit/ui: CliUi.live's tickMillis must be a positive, finite number of milliseconds, not ${tickMillis}`;
|
|
42
|
+
const DEGRADED = (error) => `@effected/cli-kit/ui: the live view stopped drawing this run: ${error instanceof Error ? error.message : String(error)}`;
|
|
43
|
+
/**
|
|
44
|
+
* `CliUi.live`, kept in its own module: see `CliUi.live` for the contract.
|
|
45
|
+
*
|
|
46
|
+
* @internal
|
|
47
|
+
*/
|
|
48
|
+
const live = (options) => Effect.gen(function* () {
|
|
49
|
+
const tickMillis = options.tickMillis ?? 80;
|
|
50
|
+
const begins = options.begins ?? ((event) => options.isStart(event));
|
|
51
|
+
if (!(Number.isFinite(tickMillis) && tickMillis > 0)) return yield* Effect.die(new Error(TICK_INVALID(tickMillis)));
|
|
52
|
+
const audience = yield* Effect.serviceOption(Audience);
|
|
53
|
+
const cliTheme = yield* CliTheme;
|
|
54
|
+
const theme = CliTheme.forAudience(cliTheme.forStream("stdout"), Option.isSome(audience) ? audience.value.kind : void 0);
|
|
55
|
+
const colour = theme.color;
|
|
56
|
+
const provided = yield* treeBase(theme);
|
|
57
|
+
const neutralize = provided.neutralizeWorkflowCommands === true;
|
|
58
|
+
const interactive = yield* CliInteractive;
|
|
59
|
+
const streams = yield* UiStreams;
|
|
60
|
+
const overrides = yield* UiRenderOptions;
|
|
61
|
+
const bridge = yield* makeInkConsole;
|
|
62
|
+
const inbox = yield* Queue.unbounded();
|
|
63
|
+
const source = options.events;
|
|
64
|
+
const stream = Stream.isStream(source) ? source : void 0;
|
|
65
|
+
const subscription = stream === void 0 ? source : void 0;
|
|
66
|
+
let state = options.initial;
|
|
67
|
+
let run;
|
|
68
|
+
let finalTaken = false;
|
|
69
|
+
const Frame = (props) => options.render(props.state, props.frame);
|
|
70
|
+
const frameOf = Effect.map(Clock.currentTimeMillis, (now) => Math.floor(now / tickMillis));
|
|
71
|
+
const elementOf = (shown, frame) => inkModules().react.createElement(Frame, {
|
|
72
|
+
state: shown,
|
|
73
|
+
frame
|
|
74
|
+
});
|
|
75
|
+
/**
|
|
76
|
+
* Unmount what a run has mounted: Ink's own unmount commits the last frame. Nothing is written after it. The clear
|
|
77
|
+
* and the close are one uninterruptible step: an interrupt landing between them would leave a scope nobody closes,
|
|
78
|
+
* its permit held, its instance mounted and its tick running.
|
|
79
|
+
*/
|
|
80
|
+
const unmount = (current) => Effect.uninterruptible(Effect.suspend(() => {
|
|
81
|
+
const mounted = current.mounted;
|
|
82
|
+
current.mounted = void 0;
|
|
83
|
+
return mounted === void 0 ? Effect.void : Scope.close(mounted.scope, Exit.void);
|
|
84
|
+
}));
|
|
85
|
+
/** Take the run off and unmount it, in one step an interrupt cannot split; the run taken, if any. */
|
|
86
|
+
const takeRun = Effect.uninterruptible(Effect.suspend(() => {
|
|
87
|
+
const current = run;
|
|
88
|
+
run = void 0;
|
|
89
|
+
return current === void 0 ? Effect.succeed(void 0) : Effect.as(unmount(current), current);
|
|
90
|
+
}));
|
|
91
|
+
/**
|
|
92
|
+
* A lazy view's shape errors already warned about. A shape error is deterministic, and the lazy view keeps one
|
|
93
|
+
* error object for it, so a long watch session warns once for it, not once per run; any other failure is a fresh
|
|
94
|
+
* object, and warns each time.
|
|
95
|
+
*/
|
|
96
|
+
const shapesWarned = /* @__PURE__ */ new Set();
|
|
97
|
+
/** The degraded-run warning, unless it is a shape error this view has already warned about. */
|
|
98
|
+
const warning = (error) => {
|
|
99
|
+
if (error instanceof LazyViewShapeError) {
|
|
100
|
+
if (shapesWarned.has(error)) return Effect.void;
|
|
101
|
+
shapesWarned.add(error);
|
|
102
|
+
}
|
|
103
|
+
return Effect.logWarning(DEGRADED(error));
|
|
104
|
+
};
|
|
105
|
+
/** Stop drawing a run: unmount first, so the one warning never lands inside a frame, then warn. */
|
|
106
|
+
const degrade = (current, error) => Effect.suspend(() => {
|
|
107
|
+
if (current.degraded) return unmount(current);
|
|
108
|
+
current.degraded = true;
|
|
109
|
+
return Effect.andThen(unmount(current), warning(error));
|
|
110
|
+
});
|
|
111
|
+
/** Act on a failure the boundary reported for the run mounted now, if any. */
|
|
112
|
+
const checkFailure = Effect.suspend(() => {
|
|
113
|
+
const current = run;
|
|
114
|
+
const failed = current?.failed;
|
|
115
|
+
return current === void 0 || failed === void 0 ? Effect.void : degrade(current, failed.error);
|
|
116
|
+
});
|
|
117
|
+
/** Say once that a run stopped drawing: already said for a degraded run, and said here for one that never mounted. */
|
|
118
|
+
const warnOnce = (current, error) => Effect.suspend(() => {
|
|
119
|
+
if (current.degraded) return Effect.void;
|
|
120
|
+
current.degraded = true;
|
|
121
|
+
return warning(error);
|
|
122
|
+
});
|
|
123
|
+
/** The final frame as a string, at the stdout width (80 when it reports none) and with no height to fit. */
|
|
124
|
+
const printFrame = (current) => Effect.gen(function* () {
|
|
125
|
+
const viewLoaded = yield* Effect.exit(loadView(options.render));
|
|
126
|
+
if (Exit.isFailure(viewLoaded)) return yield* warnOnce(current, Cause.squash(viewLoaded.cause));
|
|
127
|
+
const { ink, react } = yield* loadInk;
|
|
128
|
+
const frame = yield* frameOf;
|
|
129
|
+
const reported = streams.stdout.columns;
|
|
130
|
+
const columns = reported !== void 0 && reported > 0 ? reported : 80;
|
|
131
|
+
let failure;
|
|
132
|
+
const tree = react.createElement(errorBoundary(), {
|
|
133
|
+
onError: (error) => {
|
|
134
|
+
failure ??= { error };
|
|
135
|
+
},
|
|
136
|
+
children: uiProviders({
|
|
137
|
+
...provided,
|
|
138
|
+
size: {
|
|
139
|
+
columns,
|
|
140
|
+
rows: Number.POSITIVE_INFINITY
|
|
141
|
+
}
|
|
142
|
+
}, elementOf(state, frame))
|
|
143
|
+
});
|
|
144
|
+
const text = yield* Effect.scoped(Effect.andThen(withInkColour(colour), Effect.sync(() => ink.renderToString(tree, { columns }))));
|
|
145
|
+
if (failure !== void 0) return yield* warnOnce(current, failure.error);
|
|
146
|
+
bridge.print(neutralize ? CommandNeutralizer.text(text) : text);
|
|
147
|
+
});
|
|
148
|
+
/** The context `final`'s document is rendered with: `Doc.print`'s when the environment is there. */
|
|
149
|
+
const finalContext = Effect.gen(function* () {
|
|
150
|
+
const terminal = yield* Effect.serviceOption(TerminalEnv);
|
|
151
|
+
const links = yield* Effect.serviceOption(CliLinks);
|
|
152
|
+
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));
|
|
153
|
+
return Render.contextOf({
|
|
154
|
+
audience: Option.isSome(audience) ? audience.value.kind : "human",
|
|
155
|
+
color: theme.color,
|
|
156
|
+
glyphs: theme.glyphs,
|
|
157
|
+
...neutralize ? { neutralizeWorkflowCommands: true } : {},
|
|
158
|
+
displayPath: provided.displayPath
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
/** A run's `final` document, printed as `Doc.print` would, to the view's stdout; never Ink. */
|
|
162
|
+
const printFinal = (current, final) => Effect.gen(function* () {
|
|
163
|
+
const built = yield* Effect.exit(Effect.try({
|
|
164
|
+
try: () => final(state),
|
|
165
|
+
catch: (error) => error
|
|
166
|
+
}));
|
|
167
|
+
if (Exit.isFailure(built)) return yield* warnOnce(current, Cause.squash(built.cause));
|
|
168
|
+
const ctx = yield* finalContext;
|
|
169
|
+
const text = Render[yield* autoFormat(ctx.audience)](built.value, ctx);
|
|
170
|
+
if (text !== "") bridge.print(text);
|
|
171
|
+
});
|
|
172
|
+
/** Mount a run's view with the current state, its tick beside it; a failure degrades the run. */
|
|
173
|
+
const mount = (current) => Effect.gen(function* () {
|
|
174
|
+
const scope = yield* Scope.make("sequential");
|
|
175
|
+
const slot = holderSlot();
|
|
176
|
+
current.mounted = {
|
|
177
|
+
scope,
|
|
178
|
+
slot
|
|
179
|
+
};
|
|
180
|
+
const report = (error) => {
|
|
181
|
+
current.failed ??= { error };
|
|
182
|
+
Queue.offerUnsafe(inbox, {
|
|
183
|
+
_tag: "Failed",
|
|
184
|
+
run: current
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
yield* Effect.gen(function* () {
|
|
188
|
+
yield* Effect.acquireRelease(mountPermit.take(1), () => mountPermit.release(1), { interruptible: true });
|
|
189
|
+
yield* Effect.acquireRelease(Effect.sync(() => overrides.onMount?.("live")), () => Effect.sync(() => overrides.onUnmount?.(void 0)));
|
|
190
|
+
const { ink, react } = yield* loadInk;
|
|
191
|
+
yield* Effect.orDie(loadView(options.render));
|
|
192
|
+
yield* withInkColour(colour);
|
|
193
|
+
const frame = yield* frameOf;
|
|
194
|
+
const initial = elementOf(state, frame);
|
|
195
|
+
const shown = state;
|
|
196
|
+
const lastGood = () => {
|
|
197
|
+
const good = current.good;
|
|
198
|
+
return good === void 0 ? null : react.createElement(errorBoundary(), {
|
|
199
|
+
onError: () => {
|
|
200
|
+
current.painted = false;
|
|
201
|
+
},
|
|
202
|
+
children: elementOf(good.state, good.frame)
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
const tree = react.createElement(errorBoundary(), {
|
|
206
|
+
onError: report,
|
|
207
|
+
children: uiProviders(provided, react.createElement(bridge.Bridge, null, react.createElement(heightClamp(), null, react.createElement(errorBoundary(), {
|
|
208
|
+
onError: report,
|
|
209
|
+
fallback: lastGood,
|
|
210
|
+
children: react.createElement(holder(), {
|
|
211
|
+
initial,
|
|
212
|
+
bind: slot.bind
|
|
213
|
+
})
|
|
214
|
+
}))))
|
|
215
|
+
});
|
|
216
|
+
yield* Effect.acquireRelease(Effect.sync(() => {
|
|
217
|
+
return ink.render(tree, {
|
|
218
|
+
stdin: streams.stdin,
|
|
219
|
+
stdout: streams.stdout,
|
|
220
|
+
stderr: streams.stderr,
|
|
221
|
+
interactive: true,
|
|
222
|
+
exitOnCtrlC: false,
|
|
223
|
+
patchConsole: false,
|
|
224
|
+
...overrides.onRender === void 0 ? {} : { onRender: overrides.onRender },
|
|
225
|
+
...overrides.maxFps === void 0 ? {} : { maxFps: overrides.maxFps }
|
|
226
|
+
});
|
|
227
|
+
}), (instance) => Effect.promise(async () => {
|
|
228
|
+
bridge.detach();
|
|
229
|
+
const exited = instance.waitUntilExit();
|
|
230
|
+
instance.unmount();
|
|
231
|
+
await exited.catch(() => void 0);
|
|
232
|
+
}));
|
|
233
|
+
yield* Effect.forkIn(Effect.repeat(Effect.flatMap(frameOf, (at) => Queue.offer(inbox, {
|
|
234
|
+
_tag: "Tick",
|
|
235
|
+
frame: at
|
|
236
|
+
})), Schedule.spaced(Duration.millis(tickMillis))), scope);
|
|
237
|
+
current.frame = frame;
|
|
238
|
+
if (current.failed === void 0) {
|
|
239
|
+
current.painted = true;
|
|
240
|
+
current.good = {
|
|
241
|
+
state: shown,
|
|
242
|
+
frame
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
}).pipe(Scope.provide(scope), Effect.onExit((exit) => Exit.isSuccess(exit) ? Effect.void : Effect.andThen(Effect.sync(() => {
|
|
246
|
+
if (current.mounted?.scope === scope) current.mounted = void 0;
|
|
247
|
+
}), Scope.close(scope, exit))));
|
|
248
|
+
yield* checkFailure;
|
|
249
|
+
}).pipe(Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : degrade(current, Cause.squash(cause))));
|
|
250
|
+
/**
|
|
251
|
+
* Push the current state to the mounted run, at `at` or the clock's frame, and wait for React to commit it; a
|
|
252
|
+
* frame that throws degrades the run.
|
|
253
|
+
*/
|
|
254
|
+
const drawAt = (at) => Effect.suspend(() => {
|
|
255
|
+
const current = run;
|
|
256
|
+
const mounted = current?.mounted;
|
|
257
|
+
if (current === void 0 || mounted === void 0) return Effect.void;
|
|
258
|
+
const shown = state;
|
|
259
|
+
return Effect.gen(function* () {
|
|
260
|
+
const frame = at ?? (yield* frameOf);
|
|
261
|
+
yield* Effect.callback((resume) => {
|
|
262
|
+
mounted.slot.swap(elementOf(shown, frame), () => queueMicrotask(() => resume(Effect.void)));
|
|
263
|
+
});
|
|
264
|
+
current.frame = frame;
|
|
265
|
+
if (current.failed === void 0) {
|
|
266
|
+
current.painted = true;
|
|
267
|
+
current.good = {
|
|
268
|
+
state: shown,
|
|
269
|
+
frame
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
yield* checkFailure;
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
const draw = drawAt(void 0);
|
|
276
|
+
/** End the run: unmount, which commits its frame; a degraded run that never painted prints its frame instead. */
|
|
277
|
+
const endRun = Effect.flatMap(takeRun, (current) => {
|
|
278
|
+
if (current === void 0) return Effect.void;
|
|
279
|
+
if (!interactive) {
|
|
280
|
+
if (options.mode === "hosted") return Effect.void;
|
|
281
|
+
return options.final === void 0 ? printFrame(current) : printFinal(current, options.final);
|
|
282
|
+
}
|
|
283
|
+
return Effect.suspend(() => {
|
|
284
|
+
const failed = current.failed;
|
|
285
|
+
const warned = failed === void 0 || current.degraded ? Effect.void : Effect.suspend(() => {
|
|
286
|
+
current.degraded = true;
|
|
287
|
+
return Effect.logWarning(DEGRADED(failed.error));
|
|
288
|
+
});
|
|
289
|
+
return Effect.andThen(warned, Effect.suspend(() => current.painted ? Effect.void : printFrame(current)));
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
const beginRun = Effect.suspend(() => {
|
|
293
|
+
const current = {
|
|
294
|
+
mounted: void 0,
|
|
295
|
+
degraded: false,
|
|
296
|
+
painted: false,
|
|
297
|
+
failed: void 0,
|
|
298
|
+
frame: 0,
|
|
299
|
+
good: void 0
|
|
300
|
+
};
|
|
301
|
+
run = current;
|
|
302
|
+
return interactive ? mount(current) : Effect.void;
|
|
303
|
+
});
|
|
304
|
+
/** Fold a chunk, starting and ending runs at its events, and draw once for what is left of it. */
|
|
305
|
+
const onChunk = (chunk) => Effect.gen(function* () {
|
|
306
|
+
let dirty = false;
|
|
307
|
+
for (const event of chunk) {
|
|
308
|
+
const folded = yield* Effect.try({
|
|
309
|
+
try: () => options.reduce(state, event),
|
|
310
|
+
catch: (error) => error
|
|
311
|
+
}).pipe(Effect.catch((error) => Effect.andThen(Effect.suspend(() => run === void 0 ? Effect.void : unmount(run)), Effect.die(error))));
|
|
312
|
+
const before = state;
|
|
313
|
+
if (run?.degraded === true && options.isStart(event)) {
|
|
314
|
+
yield* endRun;
|
|
315
|
+
state = folded;
|
|
316
|
+
yield* beginRun;
|
|
317
|
+
dirty = false;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
state = folded;
|
|
321
|
+
if (options.isTerminal(event)) {
|
|
322
|
+
if (dirty || run !== void 0) yield* draw;
|
|
323
|
+
dirty = false;
|
|
324
|
+
yield* endRun;
|
|
325
|
+
} else if (run === void 0) {
|
|
326
|
+
if (begins(event, before, folded)) yield* beginRun;
|
|
327
|
+
dirty = false;
|
|
328
|
+
} else dirty = true;
|
|
329
|
+
}
|
|
330
|
+
if (dirty) yield* draw;
|
|
331
|
+
});
|
|
332
|
+
const onTick = (frame) => Effect.suspend(() => {
|
|
333
|
+
const current = run;
|
|
334
|
+
return current?.mounted === void 0 || frame <= current.frame ? Effect.void : drawAt(frame);
|
|
335
|
+
});
|
|
336
|
+
/**
|
|
337
|
+
* What a subscription holds now, taken without waiting: its buffered messages, then, once its PubSub has been
|
|
338
|
+
* ended with `PubSub.end`, the final message, the first time only. A shut-down subscription holds nothing (and a
|
|
339
|
+
* take from it would interrupt): it is not asked.
|
|
340
|
+
*/
|
|
341
|
+
const takeQueued = (sub) => Effect.suspend(() => {
|
|
342
|
+
const queued = PubSub.remainingUnsafe(sub);
|
|
343
|
+
if (Option.isNone(queued)) return Effect.succeed([]);
|
|
344
|
+
const ended = sub.ended.current;
|
|
345
|
+
const final = Option.isSome(ended) && !finalTaken ? [ended.value] : [];
|
|
346
|
+
if (final.length > 0) finalTaken = true;
|
|
347
|
+
return queued.value > 0 ? Effect.map(PubSub.takeUpTo(sub, queued.value), (taken) => [...taken, ...final]) : Effect.succeed(final);
|
|
348
|
+
});
|
|
349
|
+
/**
|
|
350
|
+
* What a subscription still queues, taken without waiting; nothing for a stream. Read once the pump has stopped
|
|
351
|
+
* (an `Ended` comes after it), so nothing else is taking. A subscription whose PubSub was shut down has nothing
|
|
352
|
+
* left, and a take from it would interrupt: it is not asked.
|
|
353
|
+
*/
|
|
354
|
+
const queuedTail = subscription === void 0 ? Effect.succeed([]) : takeQueued(subscription);
|
|
355
|
+
const control = Effect.gen(function* () {
|
|
356
|
+
while (true) {
|
|
357
|
+
const messages = yield* Queue.takeAll(inbox);
|
|
358
|
+
let pending = [];
|
|
359
|
+
const flush = Effect.suspend(() => {
|
|
360
|
+
const chunk = pending;
|
|
361
|
+
pending = [];
|
|
362
|
+
return chunk.length === 0 ? Effect.void : onChunk(chunk);
|
|
363
|
+
});
|
|
364
|
+
for (const message of messages) {
|
|
365
|
+
if (message._tag === "Events") {
|
|
366
|
+
pending.push(...message.chunk);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
yield* flush;
|
|
370
|
+
switch (message._tag) {
|
|
371
|
+
case "Tick":
|
|
372
|
+
yield* onTick(message.frame);
|
|
373
|
+
break;
|
|
374
|
+
case "Failed":
|
|
375
|
+
if (message.run === run) yield* checkFailure;
|
|
376
|
+
break;
|
|
377
|
+
case "Ended": {
|
|
378
|
+
const tail = yield* queuedTail;
|
|
379
|
+
if (tail.length > 0) yield* onChunk(tail);
|
|
380
|
+
return yield* endRun;
|
|
381
|
+
}
|
|
382
|
+
case "Died":
|
|
383
|
+
yield* takeRun;
|
|
384
|
+
return yield* Effect.failCause(message.cause);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
yield* flush;
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
const streamPull = stream === void 0 ? void 0 : yield* Stream.toPull(stream);
|
|
391
|
+
const offerEvents = (chunk) => chunk.length === 0 ? Effect.void : Queue.offer(inbox, {
|
|
392
|
+
_tag: "Events",
|
|
393
|
+
chunk
|
|
394
|
+
});
|
|
395
|
+
/**
|
|
396
|
+
* One take from a subscription, taken directly (no stream machinery), true once its events have ended: its
|
|
397
|
+
* PubSub was shut down, or ended with `PubSub.end` and its final message taken. Only the wait for a message can
|
|
398
|
+
* be interrupted, and nothing from the wait's end to the hand-off to the inbox yields to the scheduler, so an
|
|
399
|
+
* interrupt (`close`) never lands while a taken message is held: it is in the subscription or in the inbox. Each
|
|
400
|
+
* step waits or ends, so holding off the scheduler never spins.
|
|
401
|
+
*/
|
|
402
|
+
const subscriptionStep = (sub) => Effect.uninterruptibleMask((restore) => Effect.suspend(() => {
|
|
403
|
+
if (Option.isNone(PubSub.remainingUnsafe(sub))) return Effect.succeed(true);
|
|
404
|
+
if (Option.isSome(sub.ended.current)) return Effect.as(Effect.flatMap(takeQueued(sub), offerEvents), true);
|
|
405
|
+
return restore(PubSub.takeAll(sub)).pipe(Effect.catchCause((cause) => Option.isNone(PubSub.remainingUnsafe(sub)) ? Effect.succeed(void 0) : Effect.failCause(cause)), Effect.flatMap((chunk) => {
|
|
406
|
+
if (chunk === void 0) return Effect.succeed(true);
|
|
407
|
+
const ended = sub.ended.current;
|
|
408
|
+
const final = Option.isSome(ended) && chunk.length === 1 && chunk[0] === ended.value && Option.getOrElse(PubSub.remainingUnsafe(sub), () => 0) === 0;
|
|
409
|
+
if (final) finalTaken = true;
|
|
410
|
+
return Effect.as(offerEvents(chunk), final);
|
|
411
|
+
}));
|
|
412
|
+
})).pipe(Effect.provideService(Scheduler.PreventSchedulerYield, true));
|
|
413
|
+
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, {
|
|
414
|
+
_tag: "Died",
|
|
415
|
+
cause
|
|
416
|
+
}))) : Effect.gen(function* () {
|
|
417
|
+
let ended = false;
|
|
418
|
+
while (!ended) ended = yield* subscriptionStep(source);
|
|
419
|
+
yield* Queue.offer(inbox, { _tag: "Ended" });
|
|
420
|
+
});
|
|
421
|
+
const pumping = yield* Effect.forkScoped(pump, { startImmediately: true });
|
|
422
|
+
const controlling = yield* Effect.forkScoped(Effect.onExit(control, () => Fiber.interrupt(pumping)));
|
|
423
|
+
yield* Effect.addFinalizer(() => Effect.andThen(Fiber.interruptAll([controlling, pumping]), takeRun));
|
|
424
|
+
const done = Fiber.join(controlling);
|
|
425
|
+
const settled = Effect.flatMap(Fiber.await(controlling), (exit) => Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause) ? Effect.void : Effect.failCause(exit.cause));
|
|
426
|
+
const ending = yield* Effect.cached(Effect.uninterruptible(Effect.andThen(Fiber.interrupt(pumping), Queue.offer(inbox, { _tag: "Ended" }))));
|
|
427
|
+
return {
|
|
428
|
+
state: Effect.sync(() => state),
|
|
429
|
+
logConsole: bridge.writer,
|
|
430
|
+
printAbove: bridge.printAbove,
|
|
431
|
+
done,
|
|
432
|
+
close: Effect.andThen(ending, settled)
|
|
433
|
+
};
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
export { live };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { makeLineRouter } from "./internal/lineRouter.js";
|
|
2
|
+
import { Writable } from "node:stream";
|
|
3
|
+
|
|
4
|
+
// src/ui/CliUiRouteStream.ts
|
|
5
|
+
/** The terminal facts a writer reads off its stream, delegated only where the original carries them. */
|
|
6
|
+
const DELEGATED = [
|
|
7
|
+
"isTTY",
|
|
8
|
+
"columns",
|
|
9
|
+
"rows",
|
|
10
|
+
"getColorDepth",
|
|
11
|
+
"hasColors",
|
|
12
|
+
"getWindowSize"
|
|
13
|
+
];
|
|
14
|
+
const DEFAULT_MAX_HELD = 8192;
|
|
15
|
+
/** Run one `Writable` step and settle its callback: with nothing, or with what the step threw, as an `Error`. */
|
|
16
|
+
const guarded = (step, callback) => {
|
|
17
|
+
try {
|
|
18
|
+
step();
|
|
19
|
+
callback();
|
|
20
|
+
} catch (error) {
|
|
21
|
+
callback(error instanceof Error ? error : new Error(String(error)));
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
/** @internal */
|
|
25
|
+
const routeStream = (original, printer, options) => {
|
|
26
|
+
const maxHeld = options.maxHeld ?? DEFAULT_MAX_HELD;
|
|
27
|
+
if (!Number.isInteger(maxHeld) || maxHeld < 0) throw new RangeError(`CliUi.routeStream: maxHeld must be a non-negative integer, got ${String(maxHeld)}`);
|
|
28
|
+
const router = makeLineRouter({
|
|
29
|
+
stream: options.stream,
|
|
30
|
+
printer,
|
|
31
|
+
write: (text) => {
|
|
32
|
+
original.write(text);
|
|
33
|
+
},
|
|
34
|
+
maxHeld,
|
|
35
|
+
carriageReturn: options.carriageReturn ?? "keep",
|
|
36
|
+
onText: options.onText
|
|
37
|
+
});
|
|
38
|
+
const writable = new Writable({
|
|
39
|
+
decodeStrings: false,
|
|
40
|
+
write(chunk, _encoding, callback) {
|
|
41
|
+
guarded(() => router.take(chunk), callback);
|
|
42
|
+
},
|
|
43
|
+
final(callback) {
|
|
44
|
+
guarded(router.flush, callback);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
for (const key of DELEGATED) {
|
|
48
|
+
if (!(key in original)) continue;
|
|
49
|
+
let source;
|
|
50
|
+
let bound;
|
|
51
|
+
Object.defineProperty(writable, key, {
|
|
52
|
+
configurable: true,
|
|
53
|
+
enumerable: true,
|
|
54
|
+
get: () => {
|
|
55
|
+
const value = original[key];
|
|
56
|
+
if (typeof value !== "function") return value;
|
|
57
|
+
if (value !== source) {
|
|
58
|
+
source = value;
|
|
59
|
+
bound = value.bind(original);
|
|
60
|
+
}
|
|
61
|
+
return bound;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
writable,
|
|
67
|
+
route: router.route,
|
|
68
|
+
flush: router.flush
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export { routeStream };
|