@effected/cli-kit 0.0.0-stage → 0.27.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 +66 -0
- package/CliEnv.js +101 -0
- package/CliExit.js +117 -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 +240 -0
- package/CliTheme.js +142 -0
- package/ConfigIssueRenderer.js +62 -0
- package/Doc.js +548 -0
- package/Fmt.js +295 -0
- package/GithubAnnotation.js +39 -0
- package/Glyphs.js +82 -0
- package/LICENSE +21 -0
- package/NotInteractive.js +41 -0
- package/README.md +213 -2
- package/Render.js +280 -0
- package/SchemaIssueRenderer.js +47 -0
- package/Status.js +165 -0
- package/TestTerminal.js +107 -0
- package/Token.js +70 -0
- package/index.d.ts +4035 -0
- package/index.js +27 -0
- package/internal/ExitRequested.js +21 -0
- package/internal/HelpRouting.js +63 -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 +173 -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 +87 -5
- package/testing.d.ts +283 -0
- package/testing.js +4 -0
- package/tsdoc-metadata.json +11 -0
- package/ui/CliUi.js +509 -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 +166 -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 +2013 -0
- package/ui.js +17 -0
package/ui/Viewport.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { inkModules } from "./internal/ink.js";
|
|
2
|
+
import { KeyTable } from "./KeyTable.js";
|
|
3
|
+
import { useTerminalSize } from "./UiTheme.js";
|
|
4
|
+
|
|
5
|
+
// src/ui/Viewport.ts
|
|
6
|
+
/**
|
|
7
|
+
* Throws on a repeated item key. Checked before React sees the rows: React reports a duplicate key on
|
|
8
|
+
* `console.error`, which under a screen's unpatched console lands on the real stderr, over the frame.
|
|
9
|
+
*/
|
|
10
|
+
const assertUniqueItemKeys = (rows) => {
|
|
11
|
+
const seen = /* @__PURE__ */ new Set();
|
|
12
|
+
for (const row of rows) {
|
|
13
|
+
if (row._tag !== "Item") continue;
|
|
14
|
+
if (seen.has(row.key)) throw new Error(`@effected/cli-kit/ui: Viewport item keys must be unique; "${row.key}" repeats`);
|
|
15
|
+
seen.add(row.key);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const clamp = (value, low, high) => Math.min(Math.max(value, low), high);
|
|
19
|
+
/** The offset that keeps `cursor` in view, moving as little as possible, never past the last full window. */
|
|
20
|
+
const follow = (cursor, offset, height, count) => {
|
|
21
|
+
if (count === 0) return 0;
|
|
22
|
+
let next = offset;
|
|
23
|
+
if (cursor < next) next = cursor;
|
|
24
|
+
if (cursor >= next + height) next = cursor - height + 1;
|
|
25
|
+
return clamp(next, 0, Math.max(0, count - height));
|
|
26
|
+
};
|
|
27
|
+
const init = (count, height, cursor = 0) => {
|
|
28
|
+
const items = Math.max(0, Math.floor(count));
|
|
29
|
+
const window = Math.max(1, Math.floor(height));
|
|
30
|
+
const selected = items === 0 ? 0 : clamp(Math.floor(cursor), 0, items - 1);
|
|
31
|
+
return {
|
|
32
|
+
cursor: selected,
|
|
33
|
+
offset: follow(selected, 0, window, items),
|
|
34
|
+
height: window,
|
|
35
|
+
count: items
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
const step = (state, move) => {
|
|
39
|
+
if (state.count === 0) return state;
|
|
40
|
+
const last = state.count - 1;
|
|
41
|
+
const target = move === "up" ? state.cursor - 1 : move === "down" ? state.cursor + 1 : move === "home" ? 0 : move === "end" ? last : move === "pageup" ? state.cursor - state.height : state.cursor + state.height;
|
|
42
|
+
const cursor = clamp(target, 0, last);
|
|
43
|
+
return {
|
|
44
|
+
...state,
|
|
45
|
+
cursor,
|
|
46
|
+
offset: follow(cursor, state.offset, state.height, state.count)
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
const resize = (state, height) => {
|
|
50
|
+
const window = Math.max(1, Math.floor(height));
|
|
51
|
+
return {
|
|
52
|
+
...state,
|
|
53
|
+
height: window,
|
|
54
|
+
offset: follow(state.cursor, state.offset, window, state.count)
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* The rows to draw, as indexes into `rows`, for a window of `budget` lines starting at item `start`: the section
|
|
59
|
+
* header of the first item first (re-emitted when it has scrolled off), then rows in order until the budget is spent.
|
|
60
|
+
*/
|
|
61
|
+
const linesFrom = (rows, items, start, budget) => {
|
|
62
|
+
const first = items[start] ?? 0;
|
|
63
|
+
const lines = [];
|
|
64
|
+
if (budget > 1) {
|
|
65
|
+
for (let index = first - 1; index >= 0; index--) if (rows[index]?._tag === "Header") {
|
|
66
|
+
lines.push(index);
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (let index = first; index < rows.length && lines.length < budget; index++) lines.push(index);
|
|
71
|
+
return lines;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* The visible slice, starting where the window last started (`previous`, or the state's offset on first draw) and
|
|
75
|
+
* moving only as far as the selected item needs: back to it when it is above the window, forward until it is drawn
|
|
76
|
+
* when it is below. So moving inside the window moves only the highlight, even when headers take lines the
|
|
77
|
+
* item-counting reducer does not. The cursor is clamped into the items here, for both the slice and the highlight.
|
|
78
|
+
*/
|
|
79
|
+
const slice = (rows, state, budget, previous) => {
|
|
80
|
+
const items = rows.flatMap((row, index) => row._tag === "Item" ? [index] : []);
|
|
81
|
+
if (items.length === 0) return {
|
|
82
|
+
lines: rows.slice(0, budget).map((_, index) => index),
|
|
83
|
+
start: 0,
|
|
84
|
+
selected: -1
|
|
85
|
+
};
|
|
86
|
+
const cursor = clamp(state.cursor, 0, items.length - 1);
|
|
87
|
+
const selected = items[cursor] ?? 0;
|
|
88
|
+
let start = clamp(previous ?? state.offset, 0, cursor);
|
|
89
|
+
let lines = linesFrom(rows, items, start, budget);
|
|
90
|
+
while (!lines.includes(selected) && start < cursor) {
|
|
91
|
+
start++;
|
|
92
|
+
lines = linesFrom(rows, items, start, budget);
|
|
93
|
+
}
|
|
94
|
+
while (lines.length < budget && start > 0) {
|
|
95
|
+
const earlier = linesFrom(rows, items, start - 1, budget);
|
|
96
|
+
if (!earlier.includes(selected)) break;
|
|
97
|
+
start--;
|
|
98
|
+
lines = earlier;
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
lines,
|
|
102
|
+
start,
|
|
103
|
+
selected
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* A scrolling list: a pure reducer over a window of items, its key table, and a view that draws the window.
|
|
108
|
+
*
|
|
109
|
+
* @remarks
|
|
110
|
+
* The view never draws more lines than fit: its height is `min(state.height, terminal rows - 1 - reserved)`, and
|
|
111
|
+
* every row is clipped to one line of `columns - 1` cells, so a frame never fills the terminal and Ink never clears
|
|
112
|
+
* the screen and scrollback to redraw it. A section header stays visible: when the header of the first visible item
|
|
113
|
+
* has scrolled off, it is drawn again atop the slice.
|
|
114
|
+
*
|
|
115
|
+
* @public
|
|
116
|
+
*/
|
|
117
|
+
var Viewport = class {
|
|
118
|
+
constructor() {}
|
|
119
|
+
/**
|
|
120
|
+
* A viewport over `count` items, `height` of them in view, with `cursor` selected (clamped; 0 by default).
|
|
121
|
+
*
|
|
122
|
+
* @param count - how many items
|
|
123
|
+
* @param height - how many fit (at least 1)
|
|
124
|
+
* @param cursor - the item to select
|
|
125
|
+
*/
|
|
126
|
+
static init = init;
|
|
127
|
+
/**
|
|
128
|
+
* Move the cursor, clamped at both ends with no wrap, keeping it in view. A page is the window height.
|
|
129
|
+
*
|
|
130
|
+
* @param state - where the viewport is
|
|
131
|
+
* @param move - the move
|
|
132
|
+
*/
|
|
133
|
+
static step = step;
|
|
134
|
+
/**
|
|
135
|
+
* Change the window height, keeping the cursor where it is and in view.
|
|
136
|
+
*
|
|
137
|
+
* @param state - where the viewport is
|
|
138
|
+
* @param height - the new height (at least 1)
|
|
139
|
+
*/
|
|
140
|
+
static resize = resize;
|
|
141
|
+
/** The keys: ↑/↓ move, pgup/pgdn page, home top, end bottom. */
|
|
142
|
+
static keys = KeyTable.make([
|
|
143
|
+
{
|
|
144
|
+
keys: ["up"],
|
|
145
|
+
action: "up",
|
|
146
|
+
help: "move"
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
keys: ["down"],
|
|
150
|
+
action: "down",
|
|
151
|
+
help: "move"
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
keys: ["pageup"],
|
|
155
|
+
action: "pageup",
|
|
156
|
+
help: "page"
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
keys: ["pagedown"],
|
|
160
|
+
action: "pagedown",
|
|
161
|
+
help: "page"
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
keys: ["home"],
|
|
165
|
+
action: "home",
|
|
166
|
+
help: "top"
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
keys: ["end"],
|
|
170
|
+
action: "end",
|
|
171
|
+
help: "bottom"
|
|
172
|
+
}
|
|
173
|
+
]);
|
|
174
|
+
/**
|
|
175
|
+
* Draw the window: the visible rows, each clipped to one line. A repeated item key is a defect: the view throws, so
|
|
176
|
+
* the screen dies with the reason.
|
|
177
|
+
*
|
|
178
|
+
* @param props - the rows, the state, how to draw a row, and the lines reserved for the rest of the screen
|
|
179
|
+
*/
|
|
180
|
+
static View = (props) => {
|
|
181
|
+
assertUniqueItemKeys(props.rows);
|
|
182
|
+
const { ink, react } = inkModules();
|
|
183
|
+
const size = useTerminalSize();
|
|
184
|
+
const budget = Math.max(1, Math.min(props.state.height, size.rows - (props.reserved ?? 0)));
|
|
185
|
+
const started = react.useRef(void 0);
|
|
186
|
+
const { lines, start, selected } = slice(props.rows, props.state, budget, started.current);
|
|
187
|
+
started.current = start;
|
|
188
|
+
return react.createElement(ink.Box, {
|
|
189
|
+
flexDirection: "column",
|
|
190
|
+
width: size.columns
|
|
191
|
+
}, ...lines.map((index) => {
|
|
192
|
+
const row = props.rows[index];
|
|
193
|
+
return react.createElement(ink.Box, {
|
|
194
|
+
key: row._tag === "Item" ? `item:${row.key}` : `header:${index}`,
|
|
195
|
+
height: 1,
|
|
196
|
+
width: size.columns,
|
|
197
|
+
overflow: "hidden"
|
|
198
|
+
}, props.renderRow(row, index === selected));
|
|
199
|
+
}));
|
|
200
|
+
};
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
export { Viewport };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { fromReact } from "./ink.js";
|
|
2
|
+
|
|
3
|
+
// src/ui/internal/ErrorBoundary.ts
|
|
4
|
+
/**
|
|
5
|
+
* The screen's error boundary: catches a render error anywhere in the screen, renders nothing, and reports it.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* It sits inside Ink's own boundary, so Ink's `ErrorOverview` (which Ink writes to stdout) never renders: a probe on
|
|
9
|
+
* fake streams found zero bytes of it with this boundary in place, against an `ERROR` header, a stack and a
|
|
10
|
+
* screen-and-scrollback clear without it. A class over the loaded React, built on first use, because the kit holds
|
|
11
|
+
* no runtime React at module scope.
|
|
12
|
+
*
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
const errorBoundary = fromReact((react) => class ScreenErrorBoundary extends react.Component {
|
|
16
|
+
static displayName = "CliUiErrorBoundary";
|
|
17
|
+
static getDerivedStateFromError() {
|
|
18
|
+
return { failed: true };
|
|
19
|
+
}
|
|
20
|
+
state = { failed: false };
|
|
21
|
+
componentDidCatch(error) {
|
|
22
|
+
this.props.onError(error);
|
|
23
|
+
}
|
|
24
|
+
render() {
|
|
25
|
+
return this.state.failed ? this.props.fallback?.() ?? null : this.props.children ?? null;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export { errorBoundary };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { fromReact } from "./ink.js";
|
|
2
|
+
|
|
3
|
+
// src/ui/internal/Holder.ts
|
|
4
|
+
/**
|
|
5
|
+
* A component that shows one element and lets its owner swap it in place.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Everything above the holder stays mounted across a swap: the screen's error boundary, its context, the root keys
|
|
9
|
+
* and the colour hold. Only the held subtree changes, so the screen's `ScreenControl` keeps meaning the same screen.
|
|
10
|
+
* The swap function is handed over in a layout effect, before the first frame is written, and taken back in that
|
|
11
|
+
* effect's cleanup. A swap is a state update, which React commits in a microtask rather than at once, so a swap can
|
|
12
|
+
* ask to be told when its element is committed: the live view waits for that before it draws on or unmounts. Built on
|
|
13
|
+
* the loaded React, like every kit component; the screen harness's `rerender` and the live view's pushes use it.
|
|
14
|
+
*
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
const holder = fromReact((react) => {
|
|
18
|
+
const Holder = (props) => {
|
|
19
|
+
const [shown, setShown] = react.useState(() => ({
|
|
20
|
+
element: props.initial,
|
|
21
|
+
committed: void 0
|
|
22
|
+
}));
|
|
23
|
+
react.useLayoutEffect(() => {
|
|
24
|
+
props.bind((element, committed) => setShown({
|
|
25
|
+
element,
|
|
26
|
+
committed
|
|
27
|
+
}));
|
|
28
|
+
return () => props.bind(void 0);
|
|
29
|
+
}, [props.bind]);
|
|
30
|
+
react.useLayoutEffect(() => shown.committed?.(), [shown]);
|
|
31
|
+
return shown.element;
|
|
32
|
+
};
|
|
33
|
+
Holder.displayName = "CliUiHolder";
|
|
34
|
+
return Holder;
|
|
35
|
+
});
|
|
36
|
+
/**
|
|
37
|
+
* A fresh {@link HolderSlot}.
|
|
38
|
+
*
|
|
39
|
+
* @internal
|
|
40
|
+
*/
|
|
41
|
+
const holderSlot = () => {
|
|
42
|
+
let bound;
|
|
43
|
+
const pending = [];
|
|
44
|
+
const release = (through) => {
|
|
45
|
+
for (const committed of pending.splice(0, through + 1)) committed();
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
bind: (swap) => {
|
|
49
|
+
bound = swap;
|
|
50
|
+
if (swap === void 0) release(pending.length - 1);
|
|
51
|
+
},
|
|
52
|
+
isBound: () => bound !== void 0,
|
|
53
|
+
swap: (next, committed) => {
|
|
54
|
+
if (bound === void 0) {
|
|
55
|
+
committed?.();
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
if (committed === void 0) {
|
|
59
|
+
bound(next);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
const own = () => committed();
|
|
63
|
+
pending.push(own);
|
|
64
|
+
bound(next, () => {
|
|
65
|
+
const at = pending.indexOf(own);
|
|
66
|
+
if (at !== -1) release(at);
|
|
67
|
+
});
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export { holder, holderSlot };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { fromReact, inkModules } from "./ink.js";
|
|
2
|
+
|
|
3
|
+
// src/ui/internal/ScreenContext.ts
|
|
4
|
+
/**
|
|
5
|
+
* The React context carrying {@link ScreenContextValue}, built on the loaded React.
|
|
6
|
+
*
|
|
7
|
+
* @internal
|
|
8
|
+
*/
|
|
9
|
+
const screenContext = fromReact((react) => react.createContext(void 0));
|
|
10
|
+
const ignore = () => void 0;
|
|
11
|
+
/**
|
|
12
|
+
* The mounted screen's cancel, for a widget whose own key (such as Select's `q`) ends the screen.
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* A React hook; throws outside a screen mounted by `CliUi.run` or a `UiProvider`. Under a tree that is not a screen
|
|
16
|
+
* (a `UiProvider`, a live view) there is nothing to cancel, and it does nothing.
|
|
17
|
+
*
|
|
18
|
+
* @internal
|
|
19
|
+
*/
|
|
20
|
+
const useScreenCancel = () => {
|
|
21
|
+
const screen = inkModules().react.useContext(screenContext());
|
|
22
|
+
if (screen === void 0) throw new Error("@effected/cli-kit/ui: a widget was used outside a screen mounted by CliUi.run or a UiProvider");
|
|
23
|
+
return screen.cancel ?? ignore;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Wrap an input handler so a throw ends the screen as a defect instead of escaping.
|
|
27
|
+
*
|
|
28
|
+
* @remarks
|
|
29
|
+
* Ink calls `useInput` and `usePaste` handlers from its stdin listener, outside React's render, so an error boundary
|
|
30
|
+
* never sees what they throw: unguarded, it is an uncaught exception (the process dies, Effect finalizers skipped)
|
|
31
|
+
* or, where something keeps the process alive, a screen left waiting. Guarded, the screen dies with the error and
|
|
32
|
+
* `CliUi.run` unmounts it like any other defect. Outside a screen mounted by `CliUi.run`, a `UiProvider` tree
|
|
33
|
+
* included, a handler is called as is.
|
|
34
|
+
*
|
|
35
|
+
* A React hook.
|
|
36
|
+
*
|
|
37
|
+
* @internal
|
|
38
|
+
*/
|
|
39
|
+
const useScreenGuard = () => {
|
|
40
|
+
const die = inkModules().react.useContext(screenContext())?.die;
|
|
41
|
+
return (handler) => (...args) => {
|
|
42
|
+
if (die === void 0) return handler(...args);
|
|
43
|
+
try {
|
|
44
|
+
handler(...args);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
die(error);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export { screenContext, useScreenCancel, useScreenGuard };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { underGithubActions } from "../../internal/autoFormat.js";
|
|
2
|
+
import { CurrentDisplayPath } from "../../internal/displayPath.js";
|
|
3
|
+
import { inkModules } from "./ink.js";
|
|
4
|
+
import { screenContext } from "./ScreenContext.js";
|
|
5
|
+
import { Effect } from "effect";
|
|
6
|
+
|
|
7
|
+
// src/ui/internal/UiProviders.ts
|
|
8
|
+
/**
|
|
9
|
+
* Wrap `children` in the kit's providers: the theme, the glyph set, the optional size override, and a screen's cancel
|
|
10
|
+
* and defect route when it has them.
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* The one place a kit tree's context is built, for `CliUi.run`'s screens, a live view and the public `UiProvider`,
|
|
14
|
+
* so every kit hook reads the same value under each.
|
|
15
|
+
*
|
|
16
|
+
* @internal
|
|
17
|
+
*/
|
|
18
|
+
const uiProviders = (value, children) => inkModules().react.createElement(screenContext().Provider, {
|
|
19
|
+
value,
|
|
20
|
+
children
|
|
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
|
+
});
|
|
37
|
+
|
|
38
|
+
export { treeBase, uiProviders };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { inkChalk } from "./inkChalk.js";
|
|
2
|
+
import { Effect, Option } from "effect";
|
|
3
|
+
|
|
4
|
+
// src/ui/internal/ink.ts
|
|
5
|
+
const MISSING_PEERS = "@effected/cli-kit/ui could not load its optional peers ink and react: install both beside @effected/cli-kit to mount a screen";
|
|
6
|
+
const READ_BEFORE_LOAD = "@effected/cli-kit/ui read Ink before loading it: run CliUi.context (or mount a screen with CliUi.run) before rendering UiProvider or a kit component in a tree of your own";
|
|
7
|
+
const UNRESOLVED_CHALK = "@effected/cli-kit/ui could not resolve the chalk Ink uses (is ink bundled?), so Ink decides its own colour level";
|
|
8
|
+
let modules;
|
|
9
|
+
let loading;
|
|
10
|
+
const importPeers = async () => {
|
|
11
|
+
const [ink, react] = await Promise.all([import("ink"), import("react")]);
|
|
12
|
+
return {
|
|
13
|
+
ink,
|
|
14
|
+
react: react.default
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Load `ink` and `react`, once. The only runtime access the kit has to either package: nothing imports a value
|
|
19
|
+
* from them, so importing `./ui` loads neither, and only mounting a screen does.
|
|
20
|
+
*
|
|
21
|
+
* @remarks
|
|
22
|
+
* Concurrent loads share one import, and a failed one is retried by the next call. A missing peer is a defect, not
|
|
23
|
+
* a typed failure, because it is an installation error no handler can recover from; its message names both peers.
|
|
24
|
+
*
|
|
25
|
+
* @internal
|
|
26
|
+
*/
|
|
27
|
+
const loadInk = Effect.suspend(() => {
|
|
28
|
+
if (modules !== void 0) return Effect.succeed(modules);
|
|
29
|
+
return Effect.tryPromise({
|
|
30
|
+
try: () => {
|
|
31
|
+
loading ??= importPeers();
|
|
32
|
+
return loading;
|
|
33
|
+
},
|
|
34
|
+
catch: (cause) => new Error(MISSING_PEERS, { cause })
|
|
35
|
+
}).pipe(Effect.tap((loaded) => Effect.sync(() => {
|
|
36
|
+
modules = loaded;
|
|
37
|
+
})), Effect.tapError(() => Effect.sync(() => {
|
|
38
|
+
loading = void 0;
|
|
39
|
+
})), Effect.orDie);
|
|
40
|
+
});
|
|
41
|
+
/**
|
|
42
|
+
* The modules {@link loadInk} loaded, read at render time by kit components.
|
|
43
|
+
*
|
|
44
|
+
* @remarks
|
|
45
|
+
* Throws when nothing has loaded them: a kit component rendered outside a screen. Inside a render that throw is
|
|
46
|
+
* caught by the screen's error boundary and becomes a defect.
|
|
47
|
+
*
|
|
48
|
+
* @internal
|
|
49
|
+
*/
|
|
50
|
+
const inkModules = () => {
|
|
51
|
+
if (modules === void 0) throw new Error(READ_BEFORE_LOAD);
|
|
52
|
+
return modules;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* A value built once from the loaded React, such as a class component or a context, which cannot be declared at
|
|
56
|
+
* module scope because the kit holds no runtime React until {@link loadInk} runs.
|
|
57
|
+
*
|
|
58
|
+
* @remarks
|
|
59
|
+
* The returned accessor builds on first call and returns the same value thereafter; like {@link inkModules}, it
|
|
60
|
+
* throws if called before the load.
|
|
61
|
+
*
|
|
62
|
+
* @internal
|
|
63
|
+
*/
|
|
64
|
+
const fromReact = (build) => {
|
|
65
|
+
let built;
|
|
66
|
+
return () => {
|
|
67
|
+
const { react } = inkModules();
|
|
68
|
+
if (built === void 0 || built.react !== react) built = {
|
|
69
|
+
react,
|
|
70
|
+
value: build(react)
|
|
71
|
+
};
|
|
72
|
+
return built.value;
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
const LEVELS = {
|
|
76
|
+
none: 0,
|
|
77
|
+
basic: 1,
|
|
78
|
+
"256": 2,
|
|
79
|
+
truecolor: 3
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* A stream's colour level as a chalk level: `none` 0, `basic` 1, `256` 2, `truecolor` 3.
|
|
83
|
+
*
|
|
84
|
+
* @internal
|
|
85
|
+
*/
|
|
86
|
+
const levelOf = (colour) => LEVELS[colour];
|
|
87
|
+
let chalk;
|
|
88
|
+
const resolveChalk = Effect.promise(() => {
|
|
89
|
+
chalk ??= inkChalk();
|
|
90
|
+
return chalk;
|
|
91
|
+
});
|
|
92
|
+
let warned = false;
|
|
93
|
+
/**
|
|
94
|
+
* Hold `chalk` at `colour`'s level for the enclosing scope, restoring the saved level when the scope closes, by
|
|
95
|
+
* release or by interruption. With no chalk to hold, warn once and leave Ink's own detection in place.
|
|
96
|
+
*
|
|
97
|
+
* @internal
|
|
98
|
+
*/
|
|
99
|
+
const holdChalkLevel = (found, colour) => Option.match(found, {
|
|
100
|
+
onNone: () => Effect.suspend(() => {
|
|
101
|
+
if (warned) return Effect.void;
|
|
102
|
+
warned = true;
|
|
103
|
+
return Effect.logWarning(UNRESOLVED_CHALK);
|
|
104
|
+
}),
|
|
105
|
+
onSome: (instance) => Effect.asVoid(Effect.acquireRelease(Effect.sync(() => {
|
|
106
|
+
const saved = instance.level;
|
|
107
|
+
instance.level = levelOf(colour);
|
|
108
|
+
return saved;
|
|
109
|
+
}), (saved) => Effect.sync(() => {
|
|
110
|
+
instance.level = saved;
|
|
111
|
+
})))
|
|
112
|
+
});
|
|
113
|
+
/**
|
|
114
|
+
* Set Ink's colour level from the stream's `ColorLevel` for the enclosing scope, on Ink's own chalk. The level is process-global while held: the last screen
|
|
115
|
+
* mounted wins.
|
|
116
|
+
*
|
|
117
|
+
* @internal
|
|
118
|
+
*/
|
|
119
|
+
const withInkColour = (colour) => Effect.flatMap(resolveChalk, (found) => holdChalkLevel(found, colour));
|
|
120
|
+
|
|
121
|
+
export { fromReact, holdChalkLevel, inkModules, levelOf, loadInk, withInkColour };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Option } from "effect";
|
|
2
|
+
|
|
3
|
+
// src/ui/internal/inkChalk.ts
|
|
4
|
+
const isInkChalk = (value) => (typeof value === "function" || typeof value === "object" && value !== null) && typeof value.level === "number";
|
|
5
|
+
/**
|
|
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.
|
|
8
|
+
*
|
|
9
|
+
* @internal
|
|
10
|
+
*/
|
|
11
|
+
const esmResolve = typeof import.meta.resolve === "function" ? (specifier) => import.meta.resolve(specifier) : void 0;
|
|
12
|
+
/**
|
|
13
|
+
* The chalk instance Ink itself imports, resolved from Ink's own location; `None` when it cannot be resolved.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
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.
|
|
23
|
+
*
|
|
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
|
|
26
|
+
*
|
|
27
|
+
* @internal
|
|
28
|
+
*/
|
|
29
|
+
const inkChalk = async (esm = esmResolve) => {
|
|
30
|
+
try {
|
|
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);
|
|
41
|
+
return isInkChalk(chalk.default) ? Option.some(chalk.default) : Option.none();
|
|
42
|
+
} catch {
|
|
43
|
+
return Option.none();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export { esmResolve, inkChalk };
|