@effected/cli 0.22.2 → 0.24.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/CliEnv.js +17 -4
- package/CliFailure.js +15 -29
- package/CliFormat.js +124 -0
- package/CliInteractive.js +28 -4
- package/CliLog.js +38 -16
- package/CliPrompt.js +7 -0
- package/CliRuntime.js +80 -49
- package/CliTest.js +134 -22
- package/ConfigIssueRenderer.js +8 -2
- package/Fmt.js +194 -1
- package/Render.js +5 -2
- package/index.d.ts +438 -18
- package/index.js +2 -1
- package/internal/HelpRouting.js +21 -75
- package/internal/displayPath.js +46 -0
- package/internal/displayWidth.js +11 -3
- package/internal/failureTarget.js +2 -1
- package/internal/format.js +74 -3
- package/internal/inspect.js +40 -0
- package/internal/logSafety.js +1 -1
- package/internal/terminalCapabilities.js +23 -0
- package/package.json +8 -6
- package/testing.d.ts +68 -5
- package/ui/CliUi.js +90 -15
- package/ui/CliUiDetached.js +36 -0
- package/ui/CliUiLive.js +6 -9
- package/ui/CliUiRouteStream.js +72 -0
- package/ui/DocView.js +7 -4
- package/ui/UiProvider.js +4 -1
- package/ui/internal/UiProviders.js +19 -1
- package/ui/internal/inkChalk.js +24 -34
- package/ui/internal/lineRouter.js +70 -0
- package/ui/testing/CliUiTest.js +4 -1
- package/ui.d.ts +204 -4
package/ui/CliUi.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { Cancelled } from "../Cancelled.js";
|
|
2
2
|
import { CliInteractive } from "../CliInteractive.js";
|
|
3
|
-
import { underGithubActions } from "../internal/autoFormat.js";
|
|
4
3
|
import { CliTheme } from "../CliTheme.js";
|
|
5
4
|
import { NotInteractive } from "../NotInteractive.js";
|
|
6
5
|
import { answerWithoutPerson } from "../internal/fallbackAnswer.js";
|
|
@@ -11,8 +10,10 @@ import { lazyView } from "./internal/lazyView.js";
|
|
|
11
10
|
import { mountPermit } from "./internal/mountPermit.js";
|
|
12
11
|
import { UiRenderOptions } from "./internal/renderOptions.js";
|
|
13
12
|
import { useScreenGuard } from "./internal/ScreenContext.js";
|
|
14
|
-
import { uiProviders } from "./internal/UiProviders.js";
|
|
13
|
+
import { treeBase, uiProviders } from "./internal/UiProviders.js";
|
|
15
14
|
import { live } from "./CliUiLive.js";
|
|
15
|
+
import { liveDetached } from "./CliUiDetached.js";
|
|
16
|
+
import { routeStream } from "./CliUiRouteStream.js";
|
|
16
17
|
import { KeyTable, useKeys } from "./KeyTable.js";
|
|
17
18
|
import { Cause, Deferred, Effect, Exit, Option, Semaphore } from "effect";
|
|
18
19
|
import { Audience } from "@effected/env";
|
|
@@ -36,8 +37,9 @@ const RootKeys = (props) => {
|
|
|
36
37
|
return props.children;
|
|
37
38
|
};
|
|
38
39
|
const SCREEN_EXITED = "@effected/cli/ui: the screen exited without resolving or cancelling";
|
|
39
|
-
const mount = (screen, theme, clear, crash
|
|
40
|
+
const mount = (screen, theme, clear, crash) => Effect.gen(function* () {
|
|
40
41
|
const overrides = yield* UiRenderOptions;
|
|
42
|
+
const base = yield* treeBase(theme);
|
|
41
43
|
yield* Effect.acquireRelease(Effect.sync(() => overrides.onMount?.("screen")), (_, exit) => Effect.sync(() => {
|
|
42
44
|
const died = Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isDieReason) : void 0;
|
|
43
45
|
const interrupted = Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause);
|
|
@@ -63,11 +65,9 @@ const mount = (screen, theme, clear, crash, neutralize) => Effect.gen(function*
|
|
|
63
65
|
const tree = react.createElement(errorBoundary(), {
|
|
64
66
|
onError: die,
|
|
65
67
|
children: uiProviders({
|
|
68
|
+
...base,
|
|
66
69
|
cancel: control.cancel,
|
|
67
|
-
die
|
|
68
|
-
theme,
|
|
69
|
-
glyphs: theme.glyphs,
|
|
70
|
-
...neutralize ? { neutralizeWorkflowCommands: true } : {}
|
|
70
|
+
die
|
|
71
71
|
}, react.createElement(RootKeys, {
|
|
72
72
|
cancel: control.cancel,
|
|
73
73
|
children: element
|
|
@@ -153,9 +153,8 @@ var CliUi = class CliUi {
|
|
|
153
153
|
static run = (screen, options) => Effect.gen(function* () {
|
|
154
154
|
if (!(yield* CliInteractive)) return yield* Effect.fail(new NotInteractive());
|
|
155
155
|
const theme = yield* audienceTheme;
|
|
156
|
-
const neutralize = yield* underGithubActions;
|
|
157
156
|
const crash = { current: void 0 };
|
|
158
|
-
const exit = yield* Effect.exit(Semaphore.withPermit(mountPermit, Effect.scoped(mount(screen, theme, options?.clear === true, crash
|
|
157
|
+
const exit = yield* Effect.exit(Semaphore.withPermit(mountPermit, Effect.scoped(mount(screen, theme, options?.clear === true, crash))));
|
|
159
158
|
const crashed = crash.current;
|
|
160
159
|
const interrupted = Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause);
|
|
161
160
|
if (crashed === void 0 || interrupted) return yield* exit;
|
|
@@ -184,14 +183,11 @@ var CliUi = class CliUi {
|
|
|
184
183
|
* ```
|
|
185
184
|
*/
|
|
186
185
|
static context = Effect.gen(function* () {
|
|
187
|
-
const
|
|
188
|
-
const neutralize = yield* underGithubActions;
|
|
186
|
+
const base = yield* treeBase(yield* audienceTheme);
|
|
189
187
|
yield* loadInk;
|
|
190
188
|
return {
|
|
191
189
|
"~@effected/cli/ui/UiContextValue": true,
|
|
192
|
-
|
|
193
|
-
glyphs: theme.glyphs,
|
|
194
|
-
...neutralize ? { neutralizeWorkflowCommands: true } : {}
|
|
190
|
+
...base
|
|
195
191
|
};
|
|
196
192
|
});
|
|
197
193
|
/**
|
|
@@ -272,6 +268,82 @@ var CliUi = class CliUi {
|
|
|
272
268
|
*/
|
|
273
269
|
static live = live;
|
|
274
270
|
/**
|
|
271
|
+
* A live view hosted by code that is not built on Effect: a test runner's reporter, a bundler plugin, a build hook.
|
|
272
|
+
*
|
|
273
|
+
* @remarks
|
|
274
|
+
* It runs `CliUi.live` in a fiber of its own, under `env` and in a scope of its own, and returns at once. A `PubSub`
|
|
275
|
+
* given as `events` is subscribed before it returns, so the first event published after the call is not lost.
|
|
276
|
+
* `env` is the environment the view reads, built by the host, which owns the platform: under Node,
|
|
277
|
+
* `CliEnv.layer().pipe(Layer.provide(NodeServices.layer))`; a test gives `CliEnv.layerTest` with a session's layer.
|
|
278
|
+
*
|
|
279
|
+
* End it with `close`, and only then shut the `PubSub` down: `close` folds the subscription's queued tail, commits
|
|
280
|
+
* the last run and releases the scope, and a shutdown before it drops that tail. `close` never rejects; `exit`
|
|
281
|
+
* says how the view ended. Forward output the host did not write through `printAbove`, or hand
|
|
282
|
+
* `CliUi.routeStream` the detached view's `printAbove` as its printer.
|
|
283
|
+
*
|
|
284
|
+
* A render that needs an environment service is given as an effect that makes the render function: it runs once
|
|
285
|
+
* under `env`, before the view is set up, so `CliLinks` or `TerminalEnv` come from the same build the view reads and
|
|
286
|
+
* the layer is not built a second time to resolve them. `env` must provide what that effect reads.
|
|
287
|
+
*
|
|
288
|
+
* Inside an Effect program, use `CliUi.live` in a scope instead.
|
|
289
|
+
*
|
|
290
|
+
* @example
|
|
291
|
+
* ```ts
|
|
292
|
+
* const view = CliUi.liveDetached(
|
|
293
|
+
* {
|
|
294
|
+
* ...options,
|
|
295
|
+
* render: Effect.gen(function* () {
|
|
296
|
+
* const links = yield* CliLinks
|
|
297
|
+
* return (state: State, frame: number) => drawWith(links, state, frame)
|
|
298
|
+
* }),
|
|
299
|
+
* },
|
|
300
|
+
* CliEnv.layer().pipe(Layer.provide(NodeServices.layer)),
|
|
301
|
+
* )
|
|
302
|
+
* ```
|
|
303
|
+
*
|
|
304
|
+
* @param options - the live view's options, with the events as a `PubSub` or a stream and the render given directly
|
|
305
|
+
* or as an effect that makes it
|
|
306
|
+
* @param env - the environment the view runs under, providing what a render effect reads
|
|
307
|
+
*/
|
|
308
|
+
static liveDetached = liveDetached;
|
|
309
|
+
/**
|
|
310
|
+
* A stream to hand to a writer in place of a terminal stream, which routes what it writes line by line through
|
|
311
|
+
* `printer`, usually a live view's `printAbove`, and writes whatever the printer declines to `original`.
|
|
312
|
+
*
|
|
313
|
+
* @remarks
|
|
314
|
+
* For output the program does not write itself: a child process's stderr, or the streams of a test runner or a
|
|
315
|
+
* bundler running in-process. Each complete line is offered to `printer`; a `false` (no frame mounted) writes it,
|
|
316
|
+
* with its line break, to `original`. Text without a line break yet is held until one comes, until `flush`, or
|
|
317
|
+
* until it passes `maxHeld` characters, when it is offered as a line of its own. Bytes are decoded as UTF-8 across
|
|
318
|
+
* writes, so a character split between two writes arrives whole.
|
|
319
|
+
*
|
|
320
|
+
* Everything written to `original` is the decoded text, so a character split across two writes, by whichever path,
|
|
321
|
+
* arrives once and whole. A write that is nothing but escape sequences (a cursor hide, a clear-screen) goes
|
|
322
|
+
* straight to `original` when no partial line is held, since there is nothing to print above a frame. Ending
|
|
323
|
+
* `writable` flushes the held text, so a writer's last line with no line break is printed, not lost. A line's own cursor
|
|
324
|
+
* movements still tear the frame; `carriageReturn: "last"` keeps only what a progress bar redrawn with `\r` would
|
|
325
|
+
* have shown.
|
|
326
|
+
*
|
|
327
|
+
* The returned `writable` reports the original's `isTTY`, `columns`, `rows`, `getColorDepth`, `hasColors` and
|
|
328
|
+
* `getWindowSize` where the original has them. A printer that throws fails the write. Node only, as `./ui` is.
|
|
329
|
+
*
|
|
330
|
+
* @example
|
|
331
|
+
* ```ts
|
|
332
|
+
* const view = CliUi.liveDetached(options, env)
|
|
333
|
+
* const routed = CliUi.routeStream(process.stderr, view.printAbove, { stream: "stderr" })
|
|
334
|
+
* runner.errorStream = routed.writable
|
|
335
|
+
* // ...at the end
|
|
336
|
+
* routed.flush()
|
|
337
|
+
* await view.close()
|
|
338
|
+
* ```
|
|
339
|
+
*
|
|
340
|
+
* @param original - the stream the writer would have written to
|
|
341
|
+
* @param printer - where each line is offered first: `true` when it printed it, `false` with nothing written
|
|
342
|
+
* @param options - the printer's stream, the hold limit, carriage returns and a text tap
|
|
343
|
+
* @throws RangeError when `maxHeld` is not a non-negative integer
|
|
344
|
+
*/
|
|
345
|
+
static routeStream = routeStream;
|
|
346
|
+
/**
|
|
275
347
|
* Run `screen` from a handler when the run is interactive; otherwise answer with `otherwise`, or fail with
|
|
276
348
|
* `NotInteractive` when there is none.
|
|
277
349
|
*
|
|
@@ -312,7 +384,10 @@ var CliUi = class CliUi {
|
|
|
312
384
|
* It runs during parsing, whose environment is core's alone, so it reads `CliTheme` if one is there: with
|
|
313
385
|
* `CliRuntime.main`'s `env` (`CliEnv.layer`), or provided around the program. With no theme it treats the run as
|
|
314
386
|
* not interactive, and when `CliInteractive` is on it says so once, at debug level. Interactivity is
|
|
315
|
-
* `CliInteractive`, which an audience flag can set before parsing under `CliAudience`.
|
|
387
|
+
* `CliInteractive`, which an audience flag can set before parsing under `CliAudience`. Core calls a fallback only
|
|
388
|
+
* when its `TerminalCapabilities.canPrompt` is `true`; `CliInteractive.layer` and `layerTest` hold it there so that
|
|
389
|
+
* `otherwise` survives a piped run, and a `canPrompt: false` provided inside the program bypasses this fallback,
|
|
390
|
+
* as it does `CliPrompt.fallback`.
|
|
316
391
|
*
|
|
317
392
|
* As with `CliPrompt.fallback`, the screen runs here rather than being handed to core, whose fallback runner
|
|
318
393
|
* turns a quit into the missing-parameter error, which would exit `64`. A cancel (Esc, Ctrl-C) is `Cancelled`,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { live } from "./CliUiLive.js";
|
|
2
|
+
import { Deferred, Effect, Exit, Fiber, PubSub, Scope } from "effect";
|
|
3
|
+
|
|
4
|
+
// src/ui/CliUiDetached.ts
|
|
5
|
+
/** @internal */
|
|
6
|
+
const liveDetached = (options, env) => {
|
|
7
|
+
const scope = Effect.runSync(Scope.make());
|
|
8
|
+
const { events: source, render: drawing, ...rest } = options;
|
|
9
|
+
const render = Effect.isEffect(drawing) ? drawing : Effect.succeed(drawing);
|
|
10
|
+
const events = PubSub.isPubSub(source) ? Effect.runSync(PubSub.subscribe(source).pipe(Scope.provide(scope))) : source;
|
|
11
|
+
const mounted = Effect.runSync(Deferred.make());
|
|
12
|
+
const program = Effect.flatMap(render, (draw) => live({
|
|
13
|
+
...rest,
|
|
14
|
+
render: draw,
|
|
15
|
+
events
|
|
16
|
+
})).pipe(Effect.tap((handle) => Deferred.succeed(mounted, handle)), Effect.flatMap((handle) => handle.done), Scope.provide(scope), Effect.provide(env), Effect.onExit((exit) => Deferred.done(mounted, Exit.isFailure(exit) ? Exit.failCause(exit.cause) : Exit.die(/* @__PURE__ */ new Error("CliUi.liveDetached: the view ended")))));
|
|
17
|
+
const fiber = Effect.runFork(program);
|
|
18
|
+
const exit = Effect.runPromise(Fiber.await(fiber));
|
|
19
|
+
const handle = Effect.runPromise(Deferred.await(mounted));
|
|
20
|
+
let closing;
|
|
21
|
+
let print;
|
|
22
|
+
handle.then((mountedHandle) => {
|
|
23
|
+
print = mountedHandle.printAbove;
|
|
24
|
+
}, () => void 0);
|
|
25
|
+
return {
|
|
26
|
+
handle,
|
|
27
|
+
exit,
|
|
28
|
+
printAbove: (stream, line) => closing === void 0 && print !== void 0 ? print(stream, line) : false,
|
|
29
|
+
close: () => {
|
|
30
|
+
closing ??= Effect.runPromise(Deferred.await(mounted).pipe(Effect.flatMap((mountedHandle) => mountedHandle.close), Effect.exit, Effect.ensuring(Scope.close(scope, Exit.void)), Effect.asVoid));
|
|
31
|
+
return closing;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export { liveDetached };
|
package/ui/CliUiLive.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CliInteractive } from "../CliInteractive.js";
|
|
2
|
-
import { autoFormat
|
|
2
|
+
import { autoFormat } from "../internal/autoFormat.js";
|
|
3
3
|
import { CliLinks } from "../CliLinks.js";
|
|
4
4
|
import { CliTheme } from "../CliTheme.js";
|
|
5
5
|
import { Render } from "../Render.js";
|
|
@@ -11,7 +11,7 @@ import { makeInkConsole } from "./internal/inkConsole.js";
|
|
|
11
11
|
import { LazyViewShapeError, loadView } from "./internal/lazyView.js";
|
|
12
12
|
import { mountPermit } from "./internal/mountPermit.js";
|
|
13
13
|
import { UiRenderOptions } from "./internal/renderOptions.js";
|
|
14
|
-
import { uiProviders } from "./internal/UiProviders.js";
|
|
14
|
+
import { treeBase, uiProviders } from "./internal/UiProviders.js";
|
|
15
15
|
import { useTerminalSize } from "./UiTheme.js";
|
|
16
16
|
import { Cause, Clock, Duration, Effect, Exit, Fiber, Option, PubSub, Pull, Queue, Schedule, Scheduler, Scope, Stream } from "effect";
|
|
17
17
|
import { Audience, TerminalEnv } from "@effected/env";
|
|
@@ -53,12 +53,8 @@ const live = (options) => Effect.gen(function* () {
|
|
|
53
53
|
const cliTheme = yield* CliTheme;
|
|
54
54
|
const theme = CliTheme.forAudience(cliTheme.forStream("stdout"), Option.isSome(audience) ? audience.value.kind : void 0);
|
|
55
55
|
const colour = theme.color;
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
theme,
|
|
59
|
-
glyphs: theme.glyphs,
|
|
60
|
-
...neutralize ? { neutralizeWorkflowCommands: true } : {}
|
|
61
|
-
};
|
|
56
|
+
const provided = yield* treeBase(theme);
|
|
57
|
+
const neutralize = provided.neutralizeWorkflowCommands === true;
|
|
62
58
|
const interactive = yield* CliInteractive;
|
|
63
59
|
const streams = yield* UiStreams;
|
|
64
60
|
const overrides = yield* UiRenderOptions;
|
|
@@ -158,7 +154,8 @@ const live = (options) => Effect.gen(function* () {
|
|
|
158
154
|
audience: Option.isSome(audience) ? audience.value.kind : "human",
|
|
159
155
|
color: theme.color,
|
|
160
156
|
glyphs: theme.glyphs,
|
|
161
|
-
...neutralize ? { neutralizeWorkflowCommands: true } : {}
|
|
157
|
+
...neutralize ? { neutralizeWorkflowCommands: true } : {},
|
|
158
|
+
displayPath: provided.displayPath
|
|
162
159
|
});
|
|
163
160
|
});
|
|
164
161
|
/** A run's `final` document, printed as `Doc.print` would, to the view's stdout; never Ink. */
|
|
@@ -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 };
|
package/ui/DocView.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { identity } from "../internal/displayPath.js";
|
|
1
2
|
import { Render } from "../Render.js";
|
|
2
3
|
import { fromReact, inkModules } from "./internal/ink.js";
|
|
3
4
|
import { screenContext } from "./internal/ScreenContext.js";
|
|
@@ -6,7 +7,7 @@ import { useTerminalSize } from "./UiTheme.js";
|
|
|
6
7
|
// src/ui/DocView.ts
|
|
7
8
|
const OUTSIDE = "@effected/cli/ui: DocView was drawn with no ctx outside a screen, a live view or a UiProvider, so it has no theme";
|
|
8
9
|
/** The context a `DocView` builds from its tree's theme: the theme's own paint, so token overrides hold. */
|
|
9
|
-
const contextOf = (theme, width, neutralize) => ({
|
|
10
|
+
const contextOf = (theme, width, neutralize, displayPath) => ({
|
|
10
11
|
...neutralize ? { neutralizeWorkflowCommands: true } : {},
|
|
11
12
|
width,
|
|
12
13
|
audience: "human",
|
|
@@ -14,7 +15,7 @@ const contextOf = (theme, width, neutralize) => ({
|
|
|
14
15
|
paint: theme.paint,
|
|
15
16
|
glyphs: theme.glyphs,
|
|
16
17
|
link: (_target, label) => label,
|
|
17
|
-
displayPath
|
|
18
|
+
displayPath
|
|
18
19
|
});
|
|
19
20
|
/** The document's lines as the kit's own renderer lays them out: plain at colour `none`, painted otherwise. */
|
|
20
21
|
const linesOf = (doc, ctx) => {
|
|
@@ -31,12 +32,14 @@ const docView = fromReact((react) => {
|
|
|
31
32
|
const given = props.ctx;
|
|
32
33
|
const theme = screen?.theme;
|
|
33
34
|
const neutralize = screen?.neutralizeWorkflowCommands === true;
|
|
35
|
+
const displayPath = screen?.displayPath ?? identity;
|
|
34
36
|
if (given === void 0 && theme === void 0) throw new Error(OUTSIDE);
|
|
35
|
-
const ctx = react.useMemo(() => given ?? contextOf(theme, columns, neutralize), [
|
|
37
|
+
const ctx = react.useMemo(() => given ?? contextOf(theme, columns, neutralize, displayPath), [
|
|
36
38
|
given,
|
|
37
39
|
theme,
|
|
38
40
|
columns,
|
|
39
|
-
neutralize
|
|
41
|
+
neutralize,
|
|
42
|
+
displayPath
|
|
40
43
|
]);
|
|
41
44
|
const lines = react.useMemo(() => linesOf(props.doc, ctx), [props.doc, ctx]);
|
|
42
45
|
return react.createElement(ink.Box, {
|
package/ui/UiProvider.js
CHANGED
|
@@ -34,9 +34,11 @@ const UiProvider = (props) => {
|
|
|
34
34
|
const cancel = parent?.cancel;
|
|
35
35
|
const die = parent?.die;
|
|
36
36
|
const neutralize = props.value.neutralizeWorkflowCommands === true || parent?.neutralizeWorkflowCommands === true;
|
|
37
|
+
const displayPath = props.value.displayPath ?? parent?.displayPath;
|
|
37
38
|
const value = react.useMemo(() => ({
|
|
38
39
|
theme,
|
|
39
40
|
glyphs,
|
|
41
|
+
...displayPath === void 0 ? {} : { displayPath },
|
|
40
42
|
...columns === void 0 || rows === void 0 ? {} : { size: {
|
|
41
43
|
columns,
|
|
42
44
|
rows
|
|
@@ -51,7 +53,8 @@ const UiProvider = (props) => {
|
|
|
51
53
|
rows,
|
|
52
54
|
cancel,
|
|
53
55
|
die,
|
|
54
|
-
neutralize
|
|
56
|
+
neutralize,
|
|
57
|
+
displayPath
|
|
55
58
|
]);
|
|
56
59
|
return uiProviders(value, props.children);
|
|
57
60
|
};
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { underGithubActions } from "../../internal/autoFormat.js";
|
|
2
|
+
import { CurrentDisplayPath } from "../../internal/displayPath.js";
|
|
1
3
|
import { inkModules } from "./ink.js";
|
|
2
4
|
import { screenContext } from "./ScreenContext.js";
|
|
5
|
+
import { Effect } from "effect";
|
|
3
6
|
|
|
4
7
|
// src/ui/internal/UiProviders.ts
|
|
5
8
|
/**
|
|
@@ -16,5 +19,20 @@ const uiProviders = (value, children) => inkModules().react.createElement(screen
|
|
|
16
19
|
value,
|
|
17
20
|
children
|
|
18
21
|
});
|
|
22
|
+
/**
|
|
23
|
+
* The base of a kit tree drawn with `theme`: its glyphs, `neutralizeWorkflowCommands` under the GitHub Actions
|
|
24
|
+
* runner, and the run's path display. Read once per mount, so a screen, a live view and `CliUi.context` agree.
|
|
25
|
+
*
|
|
26
|
+
* @internal
|
|
27
|
+
*/
|
|
28
|
+
const treeBase = (theme) => Effect.gen(function* () {
|
|
29
|
+
const neutralize = yield* underGithubActions;
|
|
30
|
+
return {
|
|
31
|
+
theme,
|
|
32
|
+
glyphs: theme.glyphs,
|
|
33
|
+
...neutralize ? { neutralizeWorkflowCommands: true } : {},
|
|
34
|
+
displayPath: yield* CurrentDisplayPath
|
|
35
|
+
};
|
|
36
|
+
});
|
|
19
37
|
|
|
20
|
-
export { uiProviders };
|
|
38
|
+
export { treeBase, uiProviders };
|
package/ui/internal/inkChalk.js
CHANGED
|
@@ -1,57 +1,47 @@
|
|
|
1
|
-
import { createRequire } from "node:module";
|
|
2
1
|
import { Option } from "effect";
|
|
3
|
-
import { realpathSync } from "node:fs";
|
|
4
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
2
|
|
|
6
3
|
// src/ui/internal/inkChalk.ts
|
|
7
4
|
const isInkChalk = (value) => (typeof value === "function" || typeof value === "object" && value !== null) && typeof value.level === "number";
|
|
8
|
-
/** The runtime's `import.meta.resolve`, when it has one. */
|
|
9
|
-
const esmResolve = typeof import.meta.resolve === "function" ? (specifier) => import.meta.resolve(specifier) : void 0;
|
|
10
5
|
/**
|
|
11
|
-
* The
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* @remarks
|
|
15
|
-
* Vite's module runner, which evaluates a Vitest reporter loaded by path, has an `import.meta.resolve` that throws
|
|
16
|
-
* ("not supported"); the CommonJS resolution finds the same file, since Ink's `exports` has a `default` condition.
|
|
17
|
-
*
|
|
18
|
-
* @param resolve - an ESM resolver, to stand in for the runtime's in a test
|
|
6
|
+
* The runtime's `import.meta.resolve` for THIS module, when it has one. It resolves relative to the module that owns
|
|
7
|
+
* it, so it has to be the kit's own: `ModuleResolution` cannot supply it.
|
|
19
8
|
*
|
|
20
9
|
* @internal
|
|
21
10
|
*/
|
|
22
|
-
const
|
|
23
|
-
if (resolve !== void 0) try {
|
|
24
|
-
return fileURLToPath(resolve("ink"));
|
|
25
|
-
} catch {}
|
|
26
|
-
return createRequire(import.meta.url).resolve("ink");
|
|
27
|
-
};
|
|
11
|
+
const esmResolve = typeof import.meta.resolve === "function" ? (specifier) => import.meta.resolve(specifier) : void 0;
|
|
28
12
|
/**
|
|
29
13
|
* The chalk instance Ink itself imports, resolved from Ink's own location; `None` when it cannot be resolved.
|
|
30
14
|
*
|
|
31
15
|
* @remarks
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* modules by realpath
|
|
37
|
-
*
|
|
16
|
+
* Ink's `exports` has only `"."` and chalk is its own dependency, so the kit cannot import Ink's chalk by name: a
|
|
17
|
+
* `chalk` of the kit's own could be a different copy, and setting its level would silently change nothing. So Ink's
|
|
18
|
+
* entry is resolved from here (`esm` first, Node's CommonJS resolution where it is missing or throws, as under Vite's
|
|
19
|
+
* module runner, which evaluates a Vitest reporter loaded by path), `chalk` is resolved from that entry to its
|
|
20
|
+
* realpath, and that file is imported: Node keys ES modules by realpath, so it is the very module Ink imports. The
|
|
21
|
+
* resolution is `@effected/commands/node-sync`'s `ModuleResolution`, loaded only when this runs, so importing `./ui`
|
|
22
|
+
* loads none of it. A consumer that bundles Ink leaves nothing to resolve, and the answer is `None`; it never rejects.
|
|
38
23
|
*
|
|
39
|
-
* @param
|
|
24
|
+
* @param esm - an ESM resolver relative to this module; the runtime's `import.meta.resolve` by default, and a stand-in
|
|
25
|
+
* in a test
|
|
40
26
|
*
|
|
41
27
|
* @internal
|
|
42
28
|
*/
|
|
43
|
-
const inkChalk = async (
|
|
29
|
+
const inkChalk = async (esm = esmResolve) => {
|
|
44
30
|
try {
|
|
45
|
-
const
|
|
46
|
-
const chalkPath =
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
31
|
+
const { ModuleResolution } = await import("@effected/commands/node-sync");
|
|
32
|
+
const chalkPath = Option.flatMap(ModuleResolution.resolve("ink", {
|
|
33
|
+
from: import.meta.url,
|
|
34
|
+
esm
|
|
35
|
+
}), (entry) => ModuleResolution.resolve("chalk", {
|
|
36
|
+
from: entry,
|
|
37
|
+
realPath: true
|
|
38
|
+
}));
|
|
39
|
+
if (Option.isNone(chalkPath)) return Option.none();
|
|
40
|
+
const chalk = await ModuleResolution.load(chalkPath.value);
|
|
51
41
|
return isInkChalk(chalk.default) ? Option.some(chalk.default) : Option.none();
|
|
52
42
|
} catch {
|
|
53
43
|
return Option.none();
|
|
54
44
|
}
|
|
55
45
|
};
|
|
56
46
|
|
|
57
|
-
export {
|
|
47
|
+
export { esmResolve, inkChalk };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { isEscapeOnly } from "../../internal/displayWidth.js";
|
|
2
|
+
|
|
3
|
+
// src/ui/internal/lineRouter.ts
|
|
4
|
+
/** The text a printer is handed for one line: a CRLF's `\r` dropped, and under `last` only what the line ends on. */
|
|
5
|
+
const printable = (line, carriageReturn) => {
|
|
6
|
+
const text = line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
7
|
+
if (carriageReturn === "keep") return text;
|
|
8
|
+
const cut = text.lastIndexOf("\r");
|
|
9
|
+
return cut === -1 ? text : text.slice(cut + 1);
|
|
10
|
+
};
|
|
11
|
+
/** @internal */
|
|
12
|
+
const makeLineRouter = (options) => {
|
|
13
|
+
const decoder = new TextDecoder("utf-8");
|
|
14
|
+
let held = "";
|
|
15
|
+
let routing = true;
|
|
16
|
+
const offer = (line) => {
|
|
17
|
+
if (!options.printer(options.stream, printable(line, options.carriageReturn))) options.write(`${line}\n`);
|
|
18
|
+
};
|
|
19
|
+
const route = (text) => {
|
|
20
|
+
if (text === "") return;
|
|
21
|
+
if (!routing) {
|
|
22
|
+
options.write(text);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (held === "" && isEscapeOnly(text)) {
|
|
26
|
+
options.write(text);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (!text.includes("\n")) {
|
|
30
|
+
held += text;
|
|
31
|
+
if (held.length > options.maxHeld) {
|
|
32
|
+
const line = held;
|
|
33
|
+
held = "";
|
|
34
|
+
offer(line);
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const lines = (held + text).split("\n");
|
|
39
|
+
const rest = lines.pop() ?? "";
|
|
40
|
+
if (rest.length > options.maxHeld) {
|
|
41
|
+
lines.push(rest);
|
|
42
|
+
held = "";
|
|
43
|
+
} else held = rest;
|
|
44
|
+
for (const line of lines) offer(line);
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
take: (chunk) => {
|
|
48
|
+
const text = typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
|
|
49
|
+
if (text !== "") options.onText?.(text);
|
|
50
|
+
route(text);
|
|
51
|
+
},
|
|
52
|
+
route: (on) => {
|
|
53
|
+
if (!on && held !== "") {
|
|
54
|
+
options.write(held);
|
|
55
|
+
held = "";
|
|
56
|
+
}
|
|
57
|
+
routing = on;
|
|
58
|
+
},
|
|
59
|
+
flush: () => {
|
|
60
|
+
const pending = decoder.decode();
|
|
61
|
+
if (pending !== "") options.onText?.(pending);
|
|
62
|
+
const text = held + pending;
|
|
63
|
+
held = "";
|
|
64
|
+
if (text === "") return;
|
|
65
|
+
if (!routing || !options.printer(options.stream, printable(text, options.carriageReturn))) options.write(text);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export { makeLineRouter };
|
package/ui/testing/CliUiTest.js
CHANGED
|
@@ -236,7 +236,10 @@ const makeTerminal = (options, settings = { screens: "debug" }) => {
|
|
|
236
236
|
if (mode === "debug") return chunk;
|
|
237
237
|
const text = chunk.replace(FRAME_BRACKETS, "");
|
|
238
238
|
if (text === "" || CONTROLS_ONLY.test(text)) return void 0;
|
|
239
|
-
|
|
239
|
+
const frame = text.replace(LEADING_MOVES, "");
|
|
240
|
+
let end = frame.length;
|
|
241
|
+
while (end > 0 && frame[end - 1] === "\n") end -= 1;
|
|
242
|
+
return frame.slice(0, end);
|
|
240
243
|
};
|
|
241
244
|
const fake = makeFakeStreams({
|
|
242
245
|
columns,
|