@effected/cli 0.10.0 → 0.12.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 +44 -0
- package/CliAudience.js +178 -0
- package/CliColor.js +13 -19
- package/CliEnv.js +89 -0
- package/CliExit.js +1 -1
- package/CliFailure.js +302 -0
- package/CliInteractive.js +71 -0
- package/CliLinks.js +154 -0
- package/CliLog.js +346 -0
- package/CliLogger.js +34 -33
- package/CliMessage.js +80 -0
- package/CliPrompt.js +104 -0
- package/CliRuntime.js +110 -54
- package/CliTest.js +16 -0
- package/CliTheme.js +141 -0
- package/ConfigIssueRenderer.js +14 -33
- package/Doc.js +536 -0
- package/Fmt.js +133 -0
- package/GithubAnnotation.js +40 -0
- package/Glyphs.js +83 -0
- package/NotInteractive.js +42 -0
- package/README.md +145 -131
- package/Render.js +255 -0
- package/SchemaIssueRenderer.js +7 -10
- package/Status.js +166 -0
- package/TestTerminal.js +80 -0
- package/Token.js +69 -0
- package/index.d.ts +3089 -169
- package/index.js +19 -1
- package/internal/ansi.js +230 -0
- package/internal/autoFormat.js +34 -0
- package/internal/canPrompt.js +15 -0
- package/internal/counts.js +84 -0
- package/internal/diagnostics.js +32 -0
- package/internal/displayWidth.js +35 -0
- package/internal/failureTarget.js +195 -0
- package/internal/fallbackAnswer.js +18 -0
- package/internal/fileSink.js +62 -0
- package/internal/format.js +62 -7
- package/internal/layout.js +250 -0
- package/internal/linkScheme.js +30 -0
- package/internal/linkTarget.js +50 -0
- package/internal/logSafety.js +46 -0
- package/internal/renderAnsi.js +52 -0
- package/internal/renderDoc.js +320 -0
- package/internal/renderGithubLog.js +46 -0
- package/internal/renderMarkdown.js +368 -0
- package/internal/renderPlain.js +50 -0
- package/internal/scanAudience.js +106 -0
- package/internal/splitFrame.js +56 -0
- package/internal/wizardGate.js +18 -0
- package/package.json +40 -5
- package/testing.d.ts +88 -2
- package/testing.js +2 -1
- package/ui/CliUi.js +432 -0
- package/ui/CliUiLive.js +446 -0
- package/ui/Confirm.js +245 -0
- package/ui/DocView.js +74 -0
- package/ui/KeyHelp.js +62 -0
- package/ui/KeyTable.js +199 -0
- package/ui/MultiSelect.js +260 -0
- package/ui/Select.js +230 -0
- package/ui/Tabs.js +202 -0
- package/ui/TextInput.js +290 -0
- package/ui/Toggle.js +32 -0
- package/ui/UiKey.js +44 -0
- package/ui/UiProvider.js +60 -0
- package/ui/UiStreams.js +18 -0
- package/ui/UiTheme.js +119 -0
- package/ui/Viewport.js +204 -0
- package/ui/internal/ErrorBoundary.js +30 -0
- package/ui/internal/Holder.js +74 -0
- package/ui/internal/ScreenContext.js +52 -0
- package/ui/internal/UiProviders.js +21 -0
- package/ui/internal/ink.js +122 -0
- package/ui/internal/inkChalk.js +58 -0
- package/ui/internal/inkConsole.js +146 -0
- package/ui/internal/lazyView.js +74 -0
- package/ui/internal/lineText.js +19 -0
- package/ui/internal/mountPermit.js +16 -0
- package/ui/internal/perfDrain.js +33 -0
- package/ui/internal/processStreams.js +19 -0
- package/ui/internal/renderOptions.js +13 -0
- package/ui/testing/CliUiTest.js +760 -0
- package/ui/testing/fakeStreams.js +79 -0
- package/ui/testing/terminalModel.js +59 -0
- package/ui-testing-serializer.d.ts +14 -0
- package/ui-testing-serializer.js +33 -0
- package/ui-testing.d.ts +527 -0
- package/ui-testing.js +3 -0
- package/ui.d.ts +1790 -0
- package/ui.js +17 -0
package/ui/DocView.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Render } from "../Render.js";
|
|
2
|
+
import { fromReact, inkModules } from "./internal/ink.js";
|
|
3
|
+
import { screenContext } from "./internal/ScreenContext.js";
|
|
4
|
+
import { useTerminalSize } from "./UiTheme.js";
|
|
5
|
+
|
|
6
|
+
//#region src/ui/DocView.ts
|
|
7
|
+
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
|
+
/** 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
|
+
...neutralize ? { neutralizeWorkflowCommands: true } : {},
|
|
11
|
+
width,
|
|
12
|
+
audience: "human",
|
|
13
|
+
color: theme.color,
|
|
14
|
+
paint: theme.paint,
|
|
15
|
+
glyphs: theme.glyphs,
|
|
16
|
+
link: (_target, label) => label,
|
|
17
|
+
displayPath: (absolute) => absolute
|
|
18
|
+
});
|
|
19
|
+
/** The document's lines as the kit's own renderer lays them out: plain at colour `none`, painted otherwise. */
|
|
20
|
+
const linesOf = (doc, ctx) => {
|
|
21
|
+
const document = Array.isArray(doc) ? doc : [doc];
|
|
22
|
+
const text = ctx.color === "none" ? Render.plain(document, ctx) : Render.ansi(document, ctx);
|
|
23
|
+
return text === "" ? [] : text.split("\n");
|
|
24
|
+
};
|
|
25
|
+
/** The view, memoised on its props and built on the loaded React, like every kit component. */
|
|
26
|
+
const docView = fromReact((react) => {
|
|
27
|
+
const View = (props) => {
|
|
28
|
+
const { ink } = inkModules();
|
|
29
|
+
const screen = react.useContext(screenContext());
|
|
30
|
+
const { columns } = useTerminalSize();
|
|
31
|
+
const given = props.ctx;
|
|
32
|
+
const theme = screen?.theme;
|
|
33
|
+
const neutralize = screen?.neutralizeWorkflowCommands === true;
|
|
34
|
+
if (given === void 0 && theme === void 0) throw new Error(OUTSIDE);
|
|
35
|
+
const ctx = react.useMemo(() => given ?? contextOf(theme, columns, neutralize), [
|
|
36
|
+
given,
|
|
37
|
+
theme,
|
|
38
|
+
columns,
|
|
39
|
+
neutralize
|
|
40
|
+
]);
|
|
41
|
+
const lines = react.useMemo(() => linesOf(props.doc, ctx), [props.doc, ctx]);
|
|
42
|
+
return react.createElement(ink.Box, {
|
|
43
|
+
flexDirection: "column",
|
|
44
|
+
flexShrink: 0
|
|
45
|
+
}, ...lines.map((line, index) => react.createElement(ink.Text, {
|
|
46
|
+
key: index,
|
|
47
|
+
wrap: "truncate-end"
|
|
48
|
+
}, line === "" ? " " : line)));
|
|
49
|
+
};
|
|
50
|
+
View.displayName = "CliUiDocView";
|
|
51
|
+
return react.memo(View);
|
|
52
|
+
});
|
|
53
|
+
/**
|
|
54
|
+
* The kit's document IR (`Doc`) drawn as Ink rows, laid out by the kit's own renderers, so a live view and a static
|
|
55
|
+
* report show a document the same way.
|
|
56
|
+
*
|
|
57
|
+
* @remarks
|
|
58
|
+
* The document is rendered with `Render.ansi` (`Render.plain` at colour `none`) at the width, and each line becomes
|
|
59
|
+
* one Ink `Text` row cut with `wrap: "truncate-end"`, so Ink never re-wraps what the renderer laid out. Everything the
|
|
60
|
+
* static renderers do holds: a collapsible is drawn open, an annotation is skipped, text from data is sanitised.
|
|
61
|
+
*
|
|
62
|
+
* Without a `ctx` the view takes its theme from the tree (a screen, a live view, or a `UiProvider`) and its width from
|
|
63
|
+
* `useTerminalSize`, so it follows a resize; for an agent the theme is colourless, so the view is escape-free. Links
|
|
64
|
+
* are off. The layout is memoised on the document's identity and the context: re-render with the same document, as a
|
|
65
|
+
* live view's tick does, and the renderer does not run again; build a new document only when it changes.
|
|
66
|
+
*
|
|
67
|
+
* @param props - the document, and optionally the render context
|
|
68
|
+
*
|
|
69
|
+
* @public
|
|
70
|
+
*/
|
|
71
|
+
const DocView = (props) => inkModules().react.createElement(docView(), props);
|
|
72
|
+
|
|
73
|
+
//#endregion
|
|
74
|
+
export { DocView };
|
package/ui/KeyHelp.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Fmt } from "../Fmt.js";
|
|
2
|
+
import { inkModules } from "./internal/ink.js";
|
|
3
|
+
import { Styled, useGlyphs, useTerminalSize } from "./UiTheme.js";
|
|
4
|
+
import { KeyTable } from "./KeyTable.js";
|
|
5
|
+
import { lineText } from "./internal/lineText.js";
|
|
6
|
+
|
|
7
|
+
//#region src/ui/KeyHelp.ts
|
|
8
|
+
/** The fewest cells worth giving the widget's own keys when the line is cut; below it they are left out. */
|
|
9
|
+
const MIN_OWN = 4;
|
|
10
|
+
/**
|
|
11
|
+
* A one-line footer naming every visible binding of the given tables, then the root keys:
|
|
12
|
+
* `↑/↓ move · space toggle · enter continue · esc cancel`.
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* Drawn from the same tables that dispatch the keys, so the help cannot name a key the screen ignores. Neighbouring
|
|
16
|
+
* rows with the same help share one entry (`↑/↓ move`). It stays one line, cut to the terminal width with the glyph
|
|
17
|
+
* set's ellipsis; when it must be cut, the widget's own keys give way and the root hint (`esc cancel`) stays whole at
|
|
18
|
+
* the end. Painted with the `muted` token; labels follow the screen's glyph set.
|
|
19
|
+
*
|
|
20
|
+
* @param props - the tables, and whether to append the root keys
|
|
21
|
+
*
|
|
22
|
+
* @public
|
|
23
|
+
*/
|
|
24
|
+
const KeyHelp = (props) => {
|
|
25
|
+
const glyphs = useGlyphs();
|
|
26
|
+
const { columns } = useTerminalSize();
|
|
27
|
+
const separator = glyphs.kind === "unicode" ? " · " : " | ";
|
|
28
|
+
const rowsOf = (tables) => {
|
|
29
|
+
const rows = [];
|
|
30
|
+
for (const drawn of tables.flatMap((table) => table.help(glyphs))) {
|
|
31
|
+
const row = {
|
|
32
|
+
label: lineText(drawn.label),
|
|
33
|
+
help: lineText(drawn.help)
|
|
34
|
+
};
|
|
35
|
+
const previous = rows.at(-1);
|
|
36
|
+
if (previous !== void 0 && previous.help === row.help) previous.label = `${previous.label}/${row.label}`;
|
|
37
|
+
else rows.push({ ...row });
|
|
38
|
+
}
|
|
39
|
+
return rows;
|
|
40
|
+
};
|
|
41
|
+
const ownRows = rowsOf(props.tables);
|
|
42
|
+
const rootRows = props.root === false ? [] : rowsOf([KeyTable.root]);
|
|
43
|
+
const lastOwn = ownRows.at(-1);
|
|
44
|
+
const firstRoot = rootRows[0];
|
|
45
|
+
if (lastOwn !== void 0 && firstRoot !== void 0 && lastOwn.help === firstRoot.help) {
|
|
46
|
+
rootRows[0] = {
|
|
47
|
+
label: `${lastOwn.label}/${firstRoot.label}`,
|
|
48
|
+
help: firstRoot.help
|
|
49
|
+
};
|
|
50
|
+
ownRows.pop();
|
|
51
|
+
}
|
|
52
|
+
const own = ownRows.map((row) => `${row.label} ${row.help}`).join(separator);
|
|
53
|
+
const root = rootRows.map((row) => `${row.label} ${row.help}`).join(separator);
|
|
54
|
+
const whole = [own, root].filter((part) => part !== "").join(separator);
|
|
55
|
+
const ellipsis = { ellipsis: glyphs.ellipsis };
|
|
56
|
+
const room = columns - Fmt.width(separator) - Fmt.width(root);
|
|
57
|
+
const line = Fmt.width(whole) <= columns || root === "" || own === "" ? Fmt.truncate(whole, columns, ellipsis) : room >= MIN_OWN ? `${Fmt.truncate(own, room, ellipsis)}${separator}${root}` : Fmt.truncate(root, columns, ellipsis);
|
|
58
|
+
return inkModules().react.createElement(Styled, { token: "muted" }, line);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
//#endregion
|
|
62
|
+
export { KeyHelp };
|
package/ui/KeyTable.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { graphemes } from "../internal/displayWidth.js";
|
|
2
|
+
import { inkModules } from "./internal/ink.js";
|
|
3
|
+
import { useScreenGuard } from "./internal/ScreenContext.js";
|
|
4
|
+
import { UiKey } from "./UiKey.js";
|
|
5
|
+
import { Option } from "effect";
|
|
6
|
+
|
|
7
|
+
//#region src/ui/KeyTable.ts
|
|
8
|
+
/** Arrows under a Unicode glyph set; everything else, and everything under ASCII, as words. */
|
|
9
|
+
const ARROWS = {
|
|
10
|
+
up: "↑",
|
|
11
|
+
down: "↓",
|
|
12
|
+
left: "←",
|
|
13
|
+
right: "→"
|
|
14
|
+
};
|
|
15
|
+
const WORDS = {
|
|
16
|
+
up: "up",
|
|
17
|
+
down: "down",
|
|
18
|
+
left: "left",
|
|
19
|
+
right: "right",
|
|
20
|
+
enter: "enter",
|
|
21
|
+
space: "space",
|
|
22
|
+
tab: "tab",
|
|
23
|
+
"shift+tab": "shift+tab",
|
|
24
|
+
backspace: "backspace",
|
|
25
|
+
delete: "del",
|
|
26
|
+
escape: "esc",
|
|
27
|
+
"ctrl+c": "ctrl+c",
|
|
28
|
+
home: "home",
|
|
29
|
+
end: "end",
|
|
30
|
+
pageup: "pgup",
|
|
31
|
+
pagedown: "pgdn"
|
|
32
|
+
};
|
|
33
|
+
const labelOf = (key, glyphs) => {
|
|
34
|
+
if (typeof key !== "string") return key.char;
|
|
35
|
+
return (glyphs.kind === "unicode" ? ARROWS[key] : void 0) ?? WORDS[key];
|
|
36
|
+
};
|
|
37
|
+
/** A typed space is only ever reported as the named space key, so a `{ char: " " }` binding means `"space"`. */
|
|
38
|
+
const normalise = (key) => typeof key !== "string" && key.char === " " ? "space" : key;
|
|
39
|
+
/** Two keys shadow each other in help when they would match the same press, so a char compares in NFC as matching does. */
|
|
40
|
+
const identity = (key) => typeof key === "string" ? `named:${key}` : `char:${key.char.normalize("NFC")}`;
|
|
41
|
+
const bound = (binding, key) => typeof binding === "string" ? key._tag === "Named" && key.name === binding : key._tag === "Char" && key.char.normalize("NFC") === binding.char.normalize("NFC");
|
|
42
|
+
/**
|
|
43
|
+
* The keys a widget understands, as data: the one source both for dispatching input and for the help line, so the
|
|
44
|
+
* two cannot drift apart.
|
|
45
|
+
*
|
|
46
|
+
* @remarks
|
|
47
|
+
* Read it with {@link useKeys}, whose handler must step from current state, never render-closure state: several keys
|
|
48
|
+
* from one stdin read are dispatched before React re-renders.
|
|
49
|
+
*
|
|
50
|
+
* @public
|
|
51
|
+
*/
|
|
52
|
+
var KeyTable = class KeyTable {
|
|
53
|
+
/** The bindings, in priority order. */
|
|
54
|
+
bindings;
|
|
55
|
+
constructor(bindings) {
|
|
56
|
+
this.bindings = bindings;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A table from its bindings. When two bindings share a key, the first wins. A `{ char: " " }` key is stored as
|
|
60
|
+
* the named `"space"`, the only form in which Ink reports a space.
|
|
61
|
+
*
|
|
62
|
+
* @param bindings - the bindings, in priority order
|
|
63
|
+
*/
|
|
64
|
+
static make = (bindings) => new KeyTable(bindings.map((binding) => ({
|
|
65
|
+
...binding,
|
|
66
|
+
keys: binding.keys.map(normalise)
|
|
67
|
+
})));
|
|
68
|
+
/**
|
|
69
|
+
* The keys every screen has: Esc cancels with `"escape"` (help: cancel), and Ctrl-C cancels with `"interrupt"`,
|
|
70
|
+
* bound but hidden. `q` is never a root key: it belongs to a widget's own table, so a text input can type it.
|
|
71
|
+
*/
|
|
72
|
+
static root = new KeyTable([{
|
|
73
|
+
keys: ["escape"],
|
|
74
|
+
action: "escape",
|
|
75
|
+
help: "cancel"
|
|
76
|
+
}, {
|
|
77
|
+
keys: ["ctrl+c"],
|
|
78
|
+
action: "interrupt",
|
|
79
|
+
help: "interrupt",
|
|
80
|
+
hidden: true
|
|
81
|
+
}]);
|
|
82
|
+
/**
|
|
83
|
+
* The action of the first binding that holds `key`, or `None`.
|
|
84
|
+
*
|
|
85
|
+
* @param key - the key pressed
|
|
86
|
+
*/
|
|
87
|
+
match = (key) => {
|
|
88
|
+
for (const binding of this.bindings) if (binding.keys.some((candidate) => bound(candidate, key))) return Option.some(binding.action);
|
|
89
|
+
return Option.none();
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* The help rows of every binding not hidden that can still fire, in order, labelled for `glyphs`: `↑/↓` under
|
|
93
|
+
* Unicode, `up/down` under ASCII.
|
|
94
|
+
*
|
|
95
|
+
* @remarks
|
|
96
|
+
* A key an earlier binding already holds (hidden or not) can never fire a later one, so a later binding is
|
|
97
|
+
* labelled with its remaining keys only, and left out when none remain.
|
|
98
|
+
*
|
|
99
|
+
* @param glyphs - the glyph set the labels are drawn with
|
|
100
|
+
*/
|
|
101
|
+
help = (glyphs) => {
|
|
102
|
+
const taken = /* @__PURE__ */ new Set();
|
|
103
|
+
const rows = [];
|
|
104
|
+
for (const binding of this.bindings) {
|
|
105
|
+
const live = binding.keys.filter((key) => !taken.has(identity(key)));
|
|
106
|
+
for (const key of binding.keys) taken.add(identity(key));
|
|
107
|
+
if (binding.hidden === true || live.length === 0) continue;
|
|
108
|
+
rows.push({
|
|
109
|
+
label: live.map((key) => labelOf(key, glyphs)).join("/"),
|
|
110
|
+
help: binding.help
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return rows;
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
/** What a character of coalesced text is as a key: a line break is enter, and so on; another control is nothing. */
|
|
117
|
+
const keyOfCharacter = (character) => {
|
|
118
|
+
if (character === "\r" || character === "\n" || character === "\r\n") return UiKey.named("enter");
|
|
119
|
+
if (character === " ") return UiKey.named("tab");
|
|
120
|
+
if (character === " ") return UiKey.named("space");
|
|
121
|
+
if (character === "" || character === "\b") return UiKey.named("backspace");
|
|
122
|
+
return UiKey.fromInk(character, PLAIN);
|
|
123
|
+
};
|
|
124
|
+
/** Ink's key flags for plain typed text: none set. */
|
|
125
|
+
const PLAIN = {
|
|
126
|
+
upArrow: false,
|
|
127
|
+
downArrow: false,
|
|
128
|
+
leftArrow: false,
|
|
129
|
+
rightArrow: false,
|
|
130
|
+
pageDown: false,
|
|
131
|
+
pageUp: false,
|
|
132
|
+
home: false,
|
|
133
|
+
end: false,
|
|
134
|
+
return: false,
|
|
135
|
+
escape: false,
|
|
136
|
+
ctrl: false,
|
|
137
|
+
shift: false,
|
|
138
|
+
tab: false,
|
|
139
|
+
backspace: false,
|
|
140
|
+
delete: false,
|
|
141
|
+
meta: false,
|
|
142
|
+
super: false,
|
|
143
|
+
hyper: false,
|
|
144
|
+
capsLock: false,
|
|
145
|
+
numLock: false
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* The keys in one Ink input. Ink hands text read in one go to `useInput` as one string with no key flag (`"yy"`, or
|
|
149
|
+
* `"y\r"` with no `return`), so text of more than one code point is split into a key per code point; a named key,
|
|
150
|
+
* or a Ctrl or Meta combination, is the one key it is.
|
|
151
|
+
*/
|
|
152
|
+
const keysOf = (input, key) => {
|
|
153
|
+
const single = UiKey.fromInk(input, key);
|
|
154
|
+
if (single?._tag === "Named" || key.ctrl || key.meta) return single === void 0 ? [] : [single];
|
|
155
|
+
const characters = graphemes(input);
|
|
156
|
+
if (characters.length <= 1) return single === void 0 ? [] : [single];
|
|
157
|
+
return characters.flatMap((character) => {
|
|
158
|
+
const pressed = keyOfCharacter(character);
|
|
159
|
+
return pressed === void 0 ? [] : [pressed];
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
/**
|
|
163
|
+
* Read the keys of `table` and dispatch the action each one matches; keys the table does not bind are ignored.
|
|
164
|
+
*
|
|
165
|
+
* @remarks
|
|
166
|
+
* One Ink `useInput` per call, and nothing else reads input.
|
|
167
|
+
*
|
|
168
|
+
* Text read in one go (`"yy"`, `"y\r"`) reaches Ink's `useInput` as one string; it is split here into a key per
|
|
169
|
+
* grapheme (a decomposed letter or a ZWJ emoji is one key), a line break (CR, LF or CR LF) as one enter, a tab as tab, a
|
|
170
|
+
* space as space, so `{ char: "y" }` matches each `y`. A `{ char }` binding matches in NFC, whichever form was typed.
|
|
171
|
+
* A bracketed paste never reaches it: the screen takes pastes on Ink's paste channel, so pasted text cannot press a
|
|
172
|
+
* widget's keys (a pasted `q` does not cancel); `TextInput` reads pastes as text.
|
|
173
|
+
*
|
|
174
|
+
* Several keys from one stdin read (a fast typist, a held arrow, a terminal that batches) are each dispatched
|
|
175
|
+
* before React re-renders, so `dispatch` must never step from state captured in the render that created it: the
|
|
176
|
+
* second key would see the first key's starting point and repeat its move. Step with a functional update
|
|
177
|
+
* (`setState((current) => step(current, action))`), a `useReducer` dispatch, or a ref the handler itself advances.
|
|
178
|
+
*
|
|
179
|
+
* Inside a screen mounted by `CliUi.run`, a `dispatch` that throws ends the screen as a defect carrying the error, as a
|
|
180
|
+
* component that throws in render does; it never escapes as an uncaught exception.
|
|
181
|
+
*
|
|
182
|
+
* @param table - the keys to read
|
|
183
|
+
* @param dispatch - receives each matched action
|
|
184
|
+
* @param options - whether the keys are read
|
|
185
|
+
*
|
|
186
|
+
* @public
|
|
187
|
+
*/
|
|
188
|
+
const useKeys = (table, dispatch, options = {}) => {
|
|
189
|
+
const guard = useScreenGuard();
|
|
190
|
+
inkModules().ink.useInput(guard((input, key) => {
|
|
191
|
+
for (const pressed of keysOf(input, key)) {
|
|
192
|
+
const action = table.match(pressed);
|
|
193
|
+
if (Option.isSome(action)) dispatch(action.value);
|
|
194
|
+
}
|
|
195
|
+
}), { isActive: options.isActive ?? true });
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
//#endregion
|
|
199
|
+
export { KeyTable, useKeys };
|
|
@@ -0,0 +1,260 @@
|
|
|
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 { Viewport } from "./Viewport.js";
|
|
9
|
+
|
|
10
|
+
//#region src/ui/MultiSelect.ts
|
|
11
|
+
/** Every item with its section's index, numbered across sections in order. */
|
|
12
|
+
const flatten = (sections) => sections.flatMap((section, index) => section.items.map((item) => ({
|
|
13
|
+
section: index,
|
|
14
|
+
item
|
|
15
|
+
})));
|
|
16
|
+
/** Throws when two items, in any sections, share a key: keys identify rows. */
|
|
17
|
+
const assertUniqueKeys = (sections) => {
|
|
18
|
+
const seen = /* @__PURE__ */ new Set();
|
|
19
|
+
for (const { item } of flatten(sections)) {
|
|
20
|
+
if (seen.has(item.key)) throw new Error(`@effected/cli/ui: MultiSelect item keys must be unique across sections; "${item.key}" repeats`);
|
|
21
|
+
seen.add(item.key);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const init = (sections, options = {}) => {
|
|
25
|
+
assertUniqueKeys(sections);
|
|
26
|
+
const items = flatten(sections);
|
|
27
|
+
return {
|
|
28
|
+
sections,
|
|
29
|
+
chosen: new Set(items.flatMap((entry, index) => entry.item.selected === true ? [index] : [])),
|
|
30
|
+
viewport: Viewport.init(items.length, options.height ?? 10),
|
|
31
|
+
submitted: false
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
const step = (state, action) => {
|
|
35
|
+
const items = flatten(state.sections);
|
|
36
|
+
const cursor = state.viewport.cursor;
|
|
37
|
+
switch (action) {
|
|
38
|
+
case "cancel": return state;
|
|
39
|
+
case "submit": return {
|
|
40
|
+
...state,
|
|
41
|
+
submitted: true
|
|
42
|
+
};
|
|
43
|
+
case "toggle": {
|
|
44
|
+
if (items.length === 0) return state;
|
|
45
|
+
const chosen = new Set(state.chosen);
|
|
46
|
+
if (chosen.has(cursor)) chosen.delete(cursor);
|
|
47
|
+
else chosen.add(cursor);
|
|
48
|
+
return {
|
|
49
|
+
...state,
|
|
50
|
+
chosen
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
case "toggleSection": {
|
|
54
|
+
const section = items[cursor]?.section;
|
|
55
|
+
if (section === void 0) return state;
|
|
56
|
+
const members = items.flatMap((entry, index) => entry.section === section ? [index] : []);
|
|
57
|
+
const fill = members.some((index) => !state.chosen.has(index));
|
|
58
|
+
const chosen = new Set(state.chosen);
|
|
59
|
+
for (const index of members) if (fill) chosen.add(index);
|
|
60
|
+
else chosen.delete(index);
|
|
61
|
+
return {
|
|
62
|
+
...state,
|
|
63
|
+
chosen
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
default: return {
|
|
67
|
+
...state,
|
|
68
|
+
viewport: Viewport.step(state.viewport, action)
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const selected = (state) => flatten(state.sections).flatMap((entry, index) => state.chosen.has(index) ? [entry.item.value] : []);
|
|
73
|
+
/** ↑/↓ shown; the page, home and end moves bound but hidden, so the line names space, a, enter and esc at 80 columns. */
|
|
74
|
+
const KEYS = KeyTable.make([
|
|
75
|
+
{
|
|
76
|
+
keys: ["up"],
|
|
77
|
+
action: "up",
|
|
78
|
+
help: "move"
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
keys: ["down"],
|
|
82
|
+
action: "down",
|
|
83
|
+
help: "move"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
keys: ["pageup"],
|
|
87
|
+
action: "pageup",
|
|
88
|
+
help: "page",
|
|
89
|
+
hidden: true
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
keys: ["pagedown"],
|
|
93
|
+
action: "pagedown",
|
|
94
|
+
help: "page",
|
|
95
|
+
hidden: true
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
keys: ["home"],
|
|
99
|
+
action: "home",
|
|
100
|
+
help: "top",
|
|
101
|
+
hidden: true
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
keys: ["end"],
|
|
105
|
+
action: "end",
|
|
106
|
+
help: "bottom",
|
|
107
|
+
hidden: true
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
keys: ["space"],
|
|
111
|
+
action: "toggle",
|
|
112
|
+
help: "toggle"
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
keys: [{ char: "a" }],
|
|
116
|
+
action: "toggleSection",
|
|
117
|
+
help: "toggle section"
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
keys: ["enter"],
|
|
121
|
+
action: "submit",
|
|
122
|
+
help: "continue"
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
keys: [{ char: "q" }],
|
|
126
|
+
action: "cancel",
|
|
127
|
+
help: "cancel"
|
|
128
|
+
}
|
|
129
|
+
]);
|
|
130
|
+
/** Lines around the list: the message above, the detail and the help line below. */
|
|
131
|
+
const RESERVED = 3;
|
|
132
|
+
/**
|
|
133
|
+
* Several choices from sectioned lists: a pure reducer, its key table, a view and a ready-made screen.
|
|
134
|
+
*
|
|
135
|
+
* Item keys must be unique across all sections; `init` throws, and `screen` dies, on a repeat.
|
|
136
|
+
*
|
|
137
|
+
* @remarks
|
|
138
|
+
* The cursor moves over items only; section titles are headers drawn by the viewport, which keeps a scrolled-off
|
|
139
|
+
* header visible. Submitting with nothing selected resolves an empty list, which is a result, not a cancel.
|
|
140
|
+
*
|
|
141
|
+
* @example
|
|
142
|
+
* ```ts
|
|
143
|
+
* import { CliUi, MultiSelect } from "@effected/cli/ui"
|
|
144
|
+
* import { Effect } from "effect"
|
|
145
|
+
*
|
|
146
|
+
* const pickFeatures = Effect.gen(function* () {
|
|
147
|
+
* const features = yield* CliUi.run(
|
|
148
|
+
* MultiSelect.screen({
|
|
149
|
+
* message: "Which features?",
|
|
150
|
+
* sections: [
|
|
151
|
+
* {
|
|
152
|
+
* title: "Tooling",
|
|
153
|
+
* items: [
|
|
154
|
+
* { key: "lint", label: "Linting", value: "lint", selected: true },
|
|
155
|
+
* { key: "test", label: "Tests", value: "test" },
|
|
156
|
+
* ],
|
|
157
|
+
* },
|
|
158
|
+
* ],
|
|
159
|
+
* }),
|
|
160
|
+
* )
|
|
161
|
+
* return features
|
|
162
|
+
* })
|
|
163
|
+
* ```
|
|
164
|
+
*
|
|
165
|
+
* @public
|
|
166
|
+
*/
|
|
167
|
+
var MultiSelect = class MultiSelect {
|
|
168
|
+
constructor() {}
|
|
169
|
+
/**
|
|
170
|
+
* A multi-select over `sections`, each item starting as its own `selected` flag says, on the first item.
|
|
171
|
+
*
|
|
172
|
+
* @param sections - the sections
|
|
173
|
+
* @param options - the list height
|
|
174
|
+
*/
|
|
175
|
+
static init = init;
|
|
176
|
+
/**
|
|
177
|
+
* Apply an action: a viewport move over the items; `"toggle"` flips the highlighted item; `"toggleSection"`
|
|
178
|
+
* selects every item of the highlighted item's section while any is unselected, and clears them all otherwise;
|
|
179
|
+
* `"submit"` marks it submitted; `"cancel"` changes nothing here, because ending the screen is the view's job.
|
|
180
|
+
*
|
|
181
|
+
* @param state - where the multi-select is
|
|
182
|
+
* @param action - the action
|
|
183
|
+
*/
|
|
184
|
+
static step = step;
|
|
185
|
+
/**
|
|
186
|
+
* The selected values, in section order and then item order, however they were toggled.
|
|
187
|
+
*
|
|
188
|
+
* @param state - where the multi-select is
|
|
189
|
+
*/
|
|
190
|
+
static selected = selected;
|
|
191
|
+
/** The keys: ↑/↓ move (page, home and end too), space toggle, a toggle section, enter continue, q cancel. */
|
|
192
|
+
static keys = KEYS;
|
|
193
|
+
/**
|
|
194
|
+
* Draw the multi-select: the message, the sections (each item a check glyph, `◉`/`◯` or `[x]`/`[ ]` under ASCII,
|
|
195
|
+
* then its label cut to the width; the highlighted one in the accent token with the arrow glyph), the highlighted
|
|
196
|
+
* item's detail, and the key help. Enter calls `onSubmit` with the selected values; `q` cancels with `"escape"`.
|
|
197
|
+
*
|
|
198
|
+
* @remarks
|
|
199
|
+
* Single-shot, like `Select.View`: the sections are read once at mount.
|
|
200
|
+
*
|
|
201
|
+
* @param props - the message, the sections, and where the selection goes
|
|
202
|
+
*/
|
|
203
|
+
static View = (props) => {
|
|
204
|
+
const { ink, react } = inkModules();
|
|
205
|
+
const glyphs = useGlyphs();
|
|
206
|
+
const { columns } = useTerminalSize();
|
|
207
|
+
const cancel = useScreenCancel();
|
|
208
|
+
const [state, setState] = react.useState(() => init(props.sections, props.height === void 0 ? {} : { height: props.height }));
|
|
209
|
+
const { onSubmit } = props;
|
|
210
|
+
react.useEffect(() => {
|
|
211
|
+
if (state.submitted) onSubmit(selected(state));
|
|
212
|
+
}, [state.submitted]);
|
|
213
|
+
useKeys(KEYS, (action) => {
|
|
214
|
+
if (action === "cancel") cancel("escape");
|
|
215
|
+
else setState((current) => step(current, action));
|
|
216
|
+
});
|
|
217
|
+
const items = flatten(props.sections);
|
|
218
|
+
const numberOf = new Map(items.map((entry, index) => [entry.item.key, index]));
|
|
219
|
+
const rows = props.sections.flatMap((section) => [{
|
|
220
|
+
_tag: "Header",
|
|
221
|
+
label: section.title
|
|
222
|
+
}, ...section.items.map((item) => ({
|
|
223
|
+
_tag: "Item",
|
|
224
|
+
key: item.key
|
|
225
|
+
}))]);
|
|
226
|
+
const on = glyphs.kind === "unicode" ? "◉" : "[x]";
|
|
227
|
+
const off = glyphs.kind === "unicode" ? "◯" : "[ ]";
|
|
228
|
+
const blank = " ".repeat(Fmt.width(glyphs.arrow));
|
|
229
|
+
const ellipsis = { ellipsis: glyphs.ellipsis };
|
|
230
|
+
const renderRow = (row, highlighted) => {
|
|
231
|
+
if (row._tag === "Header") return react.createElement(Styled, { token: "emphasis" }, Fmt.truncate(lineText(row.label), columns, ellipsis));
|
|
232
|
+
const index = numberOf.get(row.key) ?? -1;
|
|
233
|
+
const entry = items[index];
|
|
234
|
+
const text = Fmt.truncate(`${highlighted ? glyphs.arrow : blank} ${state.chosen.has(index) ? on : off} ${lineText(entry?.item.label ?? "")}`, columns, ellipsis);
|
|
235
|
+
return highlighted ? react.createElement(Styled, { token: "accent" }, text) : react.createElement(ink.Text, null, text);
|
|
236
|
+
};
|
|
237
|
+
const detail = items[state.viewport.cursor]?.item.detail;
|
|
238
|
+
return react.createElement(ink.Box, { flexDirection: "column" }, react.createElement(Styled, { token: "emphasis" }, Fmt.truncate(lineText(props.message), columns, ellipsis)), react.createElement(Viewport.View, {
|
|
239
|
+
rows,
|
|
240
|
+
state: state.viewport,
|
|
241
|
+
renderRow,
|
|
242
|
+
reserved: RESERVED
|
|
243
|
+
}), detail === void 0 ? null : react.createElement(Styled, { token: "muted" }, Fmt.truncate(lineText(detail), columns, ellipsis)), react.createElement(KeyHelp, { tables: [KEYS] }));
|
|
244
|
+
};
|
|
245
|
+
/**
|
|
246
|
+
* A ready-made screen for `CliUi.run`: the multi-select, resolving with the selected values (`[]` when none are).
|
|
247
|
+
*
|
|
248
|
+
* @param options - the message, the sections and the list height
|
|
249
|
+
*/
|
|
250
|
+
static screen = (options) => (control) => {
|
|
251
|
+
assertUniqueKeys(options.sections);
|
|
252
|
+
return inkModules().react.createElement(MultiSelect.View, {
|
|
253
|
+
...options,
|
|
254
|
+
onSubmit: control.resolve
|
|
255
|
+
});
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
//#endregion
|
|
260
|
+
export { MultiSelect };
|