@effected/cli 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/Cancelled.js +44 -0
  2. package/CliAudience.js +178 -0
  3. package/CliColor.js +14 -20
  4. package/CliEnv.js +89 -0
  5. package/CliExit.js +1 -1
  6. package/CliFailure.js +253 -0
  7. package/CliInteractive.js +71 -0
  8. package/CliLinks.js +154 -0
  9. package/CliLog.js +294 -0
  10. package/CliLogger.js +34 -33
  11. package/CliMessage.js +83 -0
  12. package/CliPrompt.js +104 -0
  13. package/CliRuntime.js +104 -54
  14. package/CliTest.js +18 -2
  15. package/CliTheme.js +128 -0
  16. package/ConfigIssueRenderer.js +14 -33
  17. package/Doc.js +512 -0
  18. package/Fmt.js +133 -0
  19. package/GithubAnnotation.js +40 -0
  20. package/Glyphs.js +83 -0
  21. package/NotInteractive.js +42 -0
  22. package/README.md +129 -131
  23. package/Render.js +254 -0
  24. package/SchemaIssueRenderer.js +7 -10
  25. package/Status.js +163 -0
  26. package/TestTerminal.js +80 -0
  27. package/Token.js +69 -0
  28. package/index.d.ts +2923 -171
  29. package/index.js +19 -1
  30. package/internal/HelpRouting.js +1 -1
  31. package/internal/ansi.js +230 -0
  32. package/internal/autoFormat.js +34 -0
  33. package/internal/canPrompt.js +15 -0
  34. package/internal/counts.js +69 -0
  35. package/internal/diagnostics.js +32 -0
  36. package/internal/displayWidth.js +35 -0
  37. package/internal/failureTarget.js +156 -0
  38. package/internal/fallbackAnswer.js +18 -0
  39. package/internal/fileSink.js +62 -0
  40. package/internal/format.js +62 -7
  41. package/internal/layout.js +250 -0
  42. package/internal/linkScheme.js +30 -0
  43. package/internal/linkTarget.js +50 -0
  44. package/internal/logSafety.js +46 -0
  45. package/internal/renderAnsi.js +52 -0
  46. package/internal/renderDoc.js +319 -0
  47. package/internal/renderGithubLog.js +46 -0
  48. package/internal/renderMarkdown.js +367 -0
  49. package/internal/renderPlain.js +50 -0
  50. package/internal/scanAudience.js +106 -0
  51. package/internal/splitFrame.js +56 -0
  52. package/internal/wizardGate.js +18 -0
  53. package/package.json +35 -5
  54. package/testing.d.ts +90 -4
  55. package/testing.js +2 -1
  56. package/ui/CliUi.js +348 -0
  57. package/ui/CliUiLive.js +399 -0
  58. package/ui/Confirm.js +245 -0
  59. package/ui/DocView.js +74 -0
  60. package/ui/KeyHelp.js +62 -0
  61. package/ui/KeyTable.js +199 -0
  62. package/ui/MultiSelect.js +260 -0
  63. package/ui/Select.js +226 -0
  64. package/ui/Tabs.js +202 -0
  65. package/ui/TextInput.js +250 -0
  66. package/ui/Toggle.js +32 -0
  67. package/ui/UiKey.js +44 -0
  68. package/ui/UiProvider.js +60 -0
  69. package/ui/UiStreams.js +18 -0
  70. package/ui/UiTheme.js +119 -0
  71. package/ui/Viewport.js +204 -0
  72. package/ui/internal/ErrorBoundary.js +30 -0
  73. package/ui/internal/Holder.js +74 -0
  74. package/ui/internal/ScreenContext.js +52 -0
  75. package/ui/internal/UiProviders.js +21 -0
  76. package/ui/internal/ink.js +122 -0
  77. package/ui/internal/inkChalk.js +58 -0
  78. package/ui/internal/inkConsole.js +146 -0
  79. package/ui/internal/lineText.js +19 -0
  80. package/ui/internal/mountPermit.js +16 -0
  81. package/ui/internal/perfDrain.js +33 -0
  82. package/ui/internal/processStreams.js +19 -0
  83. package/ui/internal/renderOptions.js +13 -0
  84. package/ui/testing/CliUiTest.js +735 -0
  85. package/ui/testing/fakeStreams.js +76 -0
  86. package/ui/testing/terminalModel.js +59 -0
  87. package/ui-testing.d.ts +446 -0
  88. package/ui-testing.js +3 -0
  89. package/ui.d.ts +1648 -0
  90. 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 };