@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.
Files changed (92) hide show
  1. package/Cancelled.js +44 -0
  2. package/CliAudience.js +178 -0
  3. package/CliColor.js +13 -19
  4. package/CliEnv.js +89 -0
  5. package/CliExit.js +1 -1
  6. package/CliFailure.js +302 -0
  7. package/CliInteractive.js +71 -0
  8. package/CliLinks.js +154 -0
  9. package/CliLog.js +346 -0
  10. package/CliLogger.js +34 -33
  11. package/CliMessage.js +80 -0
  12. package/CliPrompt.js +104 -0
  13. package/CliRuntime.js +110 -54
  14. package/CliTest.js +16 -0
  15. package/CliTheme.js +141 -0
  16. package/ConfigIssueRenderer.js +14 -33
  17. package/Doc.js +536 -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 +145 -131
  23. package/Render.js +255 -0
  24. package/SchemaIssueRenderer.js +7 -10
  25. package/Status.js +166 -0
  26. package/TestTerminal.js +80 -0
  27. package/Token.js +69 -0
  28. package/index.d.ts +3089 -169
  29. package/index.js +19 -1
  30. package/internal/ansi.js +230 -0
  31. package/internal/autoFormat.js +34 -0
  32. package/internal/canPrompt.js +15 -0
  33. package/internal/counts.js +84 -0
  34. package/internal/diagnostics.js +32 -0
  35. package/internal/displayWidth.js +35 -0
  36. package/internal/failureTarget.js +195 -0
  37. package/internal/fallbackAnswer.js +18 -0
  38. package/internal/fileSink.js +62 -0
  39. package/internal/format.js +62 -7
  40. package/internal/layout.js +250 -0
  41. package/internal/linkScheme.js +30 -0
  42. package/internal/linkTarget.js +50 -0
  43. package/internal/logSafety.js +46 -0
  44. package/internal/renderAnsi.js +52 -0
  45. package/internal/renderDoc.js +320 -0
  46. package/internal/renderGithubLog.js +46 -0
  47. package/internal/renderMarkdown.js +368 -0
  48. package/internal/renderPlain.js +50 -0
  49. package/internal/scanAudience.js +106 -0
  50. package/internal/splitFrame.js +56 -0
  51. package/internal/wizardGate.js +18 -0
  52. package/package.json +40 -5
  53. package/testing.d.ts +88 -2
  54. package/testing.js +2 -1
  55. package/ui/CliUi.js +432 -0
  56. package/ui/CliUiLive.js +446 -0
  57. package/ui/Confirm.js +245 -0
  58. package/ui/DocView.js +74 -0
  59. package/ui/KeyHelp.js +62 -0
  60. package/ui/KeyTable.js +199 -0
  61. package/ui/MultiSelect.js +260 -0
  62. package/ui/Select.js +230 -0
  63. package/ui/Tabs.js +202 -0
  64. package/ui/TextInput.js +290 -0
  65. package/ui/Toggle.js +32 -0
  66. package/ui/UiKey.js +44 -0
  67. package/ui/UiProvider.js +60 -0
  68. package/ui/UiStreams.js +18 -0
  69. package/ui/UiTheme.js +119 -0
  70. package/ui/Viewport.js +204 -0
  71. package/ui/internal/ErrorBoundary.js +30 -0
  72. package/ui/internal/Holder.js +74 -0
  73. package/ui/internal/ScreenContext.js +52 -0
  74. package/ui/internal/UiProviders.js +21 -0
  75. package/ui/internal/ink.js +122 -0
  76. package/ui/internal/inkChalk.js +58 -0
  77. package/ui/internal/inkConsole.js +146 -0
  78. package/ui/internal/lazyView.js +74 -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 +760 -0
  85. package/ui/testing/fakeStreams.js +79 -0
  86. package/ui/testing/terminalModel.js +59 -0
  87. package/ui-testing-serializer.d.ts +14 -0
  88. package/ui-testing-serializer.js +33 -0
  89. package/ui-testing.d.ts +527 -0
  90. package/ui-testing.js +3 -0
  91. package/ui.d.ts +1790 -0
  92. package/ui.js +17 -0
package/ui/Select.js ADDED
@@ -0,0 +1,230 @@
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, useTheme } 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
+ import { Option } from "effect";
10
+
11
+ //#region src/ui/Select.ts
12
+ const enabled = (choices, index) => index >= 0 && index < choices.length && choices[index]?.disabled !== true;
13
+ /** The nearest enabled index from `from` in `direction`, or `undefined`. */
14
+ const nearest = (choices, from, direction) => {
15
+ for (let index = from; index >= 0 && index < choices.length; index += direction) if (enabled(choices, index)) return index;
16
+ };
17
+ /** Move the viewport's cursor to `index`, one step at a time so its window follows. */
18
+ const moveTo = (viewport, index) => {
19
+ let state = viewport;
20
+ while (state.cursor < index) state = Viewport.step(state, "down");
21
+ while (state.cursor > index) state = Viewport.step(state, "up");
22
+ return state;
23
+ };
24
+ const NO_ENABLED_CHOICE = "@effected/cli/ui: Select needs at least one enabled choice";
25
+ const init = (choices, options = {}) => {
26
+ if (!choices.some((choice) => choice.disabled !== true)) throw new Error(NO_ENABLED_CHOICE);
27
+ const height = options.height ?? 10;
28
+ const start = options.initial ?? 0;
29
+ const first = nearest(choices, start, 1) ?? nearest(choices, start, -1) ?? 0;
30
+ return {
31
+ choices,
32
+ viewport: moveTo(Viewport.init(choices.length, height), first),
33
+ submitted: false
34
+ };
35
+ };
36
+ const step = (state, action) => {
37
+ const { choices, viewport } = state;
38
+ if (action === "cancel") return state;
39
+ if (action === "submit") return enabled(choices, viewport.cursor) ? {
40
+ ...state,
41
+ submitted: true
42
+ } : state;
43
+ const last = choices.length - 1;
44
+ const paged = Viewport.step(viewport, action).cursor;
45
+ const target = action === "up" ? nearest(choices, viewport.cursor - 1, -1) : action === "down" ? nearest(choices, viewport.cursor + 1, 1) : action === "home" ? nearest(choices, 0, 1) : action === "end" ? nearest(choices, last, -1) : action === "pageup" ? nearest(choices, paged, -1) ?? nearest(choices, paged, 1) : nearest(choices, paged, 1) ?? nearest(choices, paged, -1);
46
+ return target === void 0 ? state : {
47
+ ...state,
48
+ viewport: moveTo(viewport, target)
49
+ };
50
+ };
51
+ /** ↑/↓ shown; the page, home and end moves bound but hidden, so the help line fits 80 columns. */
52
+ const KEYS = KeyTable.make([
53
+ {
54
+ keys: ["up"],
55
+ action: "up",
56
+ help: "move"
57
+ },
58
+ {
59
+ keys: ["down"],
60
+ action: "down",
61
+ help: "move"
62
+ },
63
+ {
64
+ keys: ["pageup"],
65
+ action: "pageup",
66
+ help: "page",
67
+ hidden: true
68
+ },
69
+ {
70
+ keys: ["pagedown"],
71
+ action: "pagedown",
72
+ help: "page",
73
+ hidden: true
74
+ },
75
+ {
76
+ keys: ["home"],
77
+ action: "home",
78
+ help: "top",
79
+ hidden: true
80
+ },
81
+ {
82
+ keys: ["end"],
83
+ action: "end",
84
+ help: "bottom",
85
+ hidden: true
86
+ },
87
+ {
88
+ keys: ["enter"],
89
+ action: "submit",
90
+ help: "choose"
91
+ },
92
+ {
93
+ keys: [{ char: "q" }],
94
+ action: "cancel",
95
+ help: "cancel"
96
+ }
97
+ ]);
98
+ /** The text mark a disabled row carries at colour `none`, where the muted token paints nothing. */
99
+ const DISABLED = " (disabled)";
100
+ /** Lines around the list: the message above, the detail and the help line below. */
101
+ const RESERVED = 3;
102
+ /**
103
+ * A single choice from a list: a pure reducer, its key table, a view, and a ready-made screen.
104
+ *
105
+ * @remarks
106
+ * A select with no enabled choice is a programming error: `init` throws, and `screen` dies, saying so.
107
+ *
108
+ * @example
109
+ * ```ts
110
+ * import { CliUi, Select } from "@effected/cli/ui"
111
+ * import { Effect } from "effect"
112
+ *
113
+ * const pickTarget = Effect.gen(function* () {
114
+ * const target = yield* CliUi.run(
115
+ * Select.screen({
116
+ * message: "Deploy to which environment?",
117
+ * choices: [
118
+ * { label: "staging", value: "staging" },
119
+ * { label: "production", value: "production", detail: "Needs approval" },
120
+ * ],
121
+ * }),
122
+ * )
123
+ * return target
124
+ * })
125
+ * ```
126
+ *
127
+ * @public
128
+ */
129
+ var Select = class Select {
130
+ constructor() {}
131
+ /**
132
+ * A select over `choices`, on the first enabled choice at or after `initial` (the nearest enabled one before it
133
+ * when none follows).
134
+ *
135
+ * @param choices - the choices
136
+ * @param options - the starting choice and the list height
137
+ */
138
+ static init = init;
139
+ /**
140
+ * Apply an action: a move lands on the nearest enabled choice, never on a disabled one and never past either
141
+ * end; `"submit"` marks the highlighted choice chosen (a disabled one never is); `"cancel"` changes nothing here,
142
+ * because ending the screen is the view's job.
143
+ *
144
+ * @param state - where the select is
145
+ * @param action - the action
146
+ */
147
+ static step = step;
148
+ /**
149
+ * The submitted value, or `None` before a submit.
150
+ *
151
+ * @param state - where the select is
152
+ */
153
+ static chosen = (state) => {
154
+ const choice = state.choices[state.viewport.cursor];
155
+ return state.submitted && choice !== void 0 ? Option.some(choice.value) : Option.none();
156
+ };
157
+ /** The keys: the viewport's moves, enter choose, q cancel. */
158
+ static keys = KEYS;
159
+ /**
160
+ * Draw the select: the message, the list (the highlighted row in the accent token with the arrow glyph, disabled
161
+ * rows muted, and at colour `none` ending in ` (disabled)` instead, every row cut to the width with the glyph set's
162
+ * ellipsis), the highlighted choice's detail, and the
163
+ * key help.
164
+ *
165
+ * @remarks
166
+ * A choice's `detail` is drawn only while that choice is highlighted, as one muted line under the list, so the others'
167
+ * details are not on screen until the cursor reaches them. Enter calls `onSubmit` with the value; `q` cancels the screen with `"escape"`.
168
+ *
169
+ * Single-shot: the choices and the starting choice are read once, when the view mounts, and later changes to them
170
+ * are ignored; after a submit it stays as it is. Render a new view (a new screen) to ask again.
171
+ *
172
+ * @param props - the message, the choices, and where the chosen value goes
173
+ */
174
+ static View = (props) => {
175
+ const { ink, react } = inkModules();
176
+ const glyphs = useGlyphs();
177
+ const theme = useTheme();
178
+ const { columns } = useTerminalSize();
179
+ const cancel = useScreenCancel();
180
+ const [state, setState] = react.useState(() => init(props.choices, {
181
+ ...props.initial === void 0 ? {} : { initial: props.initial },
182
+ ...props.height === void 0 ? {} : { height: props.height }
183
+ }));
184
+ const submitted = state.submitted;
185
+ const { onSubmit } = props;
186
+ react.useEffect(() => {
187
+ const choice = state.choices[state.viewport.cursor];
188
+ if (submitted && choice !== void 0) onSubmit(choice.value);
189
+ }, [submitted]);
190
+ useKeys(KEYS, (action) => {
191
+ if (action === "cancel") cancel("escape");
192
+ else setState((current) => step(current, action));
193
+ });
194
+ const rows = props.choices.map((_, index) => ({
195
+ _tag: "Item",
196
+ key: String(index)
197
+ }));
198
+ const blank = " ".repeat(Fmt.width(glyphs.arrow));
199
+ const renderRow = (row, highlighted) => {
200
+ const choice = row._tag === "Item" ? props.choices[Number(row.key)] : void 0;
201
+ const line = `${highlighted ? glyphs.arrow : blank} ${lineText(choice?.label ?? "")}`;
202
+ const text = choice?.disabled === true && theme.color === "none" ? `${Fmt.truncate(line, Math.max(0, columns - Fmt.width(DISABLED)), { ellipsis: glyphs.ellipsis })}${DISABLED}` : Fmt.truncate(line, columns, { ellipsis: glyphs.ellipsis });
203
+ if (highlighted) return react.createElement(Styled, { token: "accent" }, text);
204
+ if (choice?.disabled === true) return react.createElement(Styled, { token: "muted" }, text);
205
+ return react.createElement(ink.Text, null, text);
206
+ };
207
+ const detail = props.choices[state.viewport.cursor]?.detail;
208
+ return react.createElement(ink.Box, { flexDirection: "column" }, react.createElement(Styled, { token: "emphasis" }, Fmt.truncate(lineText(props.message), columns, { ellipsis: glyphs.ellipsis })), react.createElement(Viewport.View, {
209
+ rows,
210
+ state: state.viewport,
211
+ renderRow,
212
+ reserved: RESERVED
213
+ }), detail === void 0 ? null : react.createElement(Styled, { token: "muted" }, Fmt.truncate(lineText(detail), columns, { ellipsis: glyphs.ellipsis })), react.createElement(KeyHelp, { tables: [KEYS] }));
214
+ };
215
+ /**
216
+ * A ready-made screen for `CliUi.run`: the select, resolving with the chosen value.
217
+ *
218
+ * @param options - the message, the choices, the starting choice and the list height
219
+ */
220
+ static screen = (options) => (control) => {
221
+ if (!options.choices.some((choice) => choice.disabled !== true)) throw new Error(NO_ENABLED_CHOICE);
222
+ return inkModules().react.createElement(Select.View, {
223
+ ...options,
224
+ onSubmit: control.resolve
225
+ });
226
+ };
227
+ };
228
+
229
+ //#endregion
230
+ export { Select };
package/ui/Tabs.js ADDED
@@ -0,0 +1,202 @@
1
+ import { Fmt } from "../Fmt.js";
2
+ import { inkModules } from "./internal/ink.js";
3
+ import { Styled, useGlyphs, useTerminalSize, useTheme } from "./UiTheme.js";
4
+ import { KeyTable, useKeys } from "./KeyTable.js";
5
+ import { lineText } from "./internal/lineText.js";
6
+
7
+ //#region src/ui/Tabs.ts
8
+ const step = (index, count, action) => {
9
+ if (count <= 0) return index;
10
+ if (action === "prev") return (index - 1 + count) % count;
11
+ if (action === "next") return (index + 1) % count;
12
+ return action.jump >= 0 && action.jump < count ? action.jump : index;
13
+ };
14
+ /** Plain digits jump: 1 is the first tab, 9 the ninth, 0 the tenth. Bound, but left out of the help line. */
15
+ const DIGITS = [
16
+ "1",
17
+ "2",
18
+ "3",
19
+ "4",
20
+ "5",
21
+ "6",
22
+ "7",
23
+ "8",
24
+ "9",
25
+ "0"
26
+ ].map((digit, index) => ({
27
+ keys: [{ char: digit }],
28
+ action: { jump: index },
29
+ help: "jump",
30
+ hidden: true
31
+ }));
32
+ const table = (prev, next) => KeyTable.make([
33
+ {
34
+ keys: [prev],
35
+ action: "prev",
36
+ help: "switch"
37
+ },
38
+ {
39
+ keys: [next],
40
+ action: "next",
41
+ help: "switch"
42
+ },
43
+ {
44
+ keys: ["tab"],
45
+ action: "next",
46
+ help: "switch"
47
+ },
48
+ {
49
+ keys: ["shift+tab"],
50
+ action: "prev",
51
+ help: "switch"
52
+ },
53
+ ...DIGITS
54
+ ]);
55
+ const ROW_KEYS = table("left", "right");
56
+ const COLUMN_KEYS = table("up", "down");
57
+ /** The tabs from `from` to `to` that fit `width` around `active`, widening right then left in turn. */
58
+ const fitAround = (widths, active, gap, width) => {
59
+ let from = active;
60
+ let to = active;
61
+ let used = widths[active] ?? 0;
62
+ let grew = true;
63
+ while (grew) {
64
+ grew = false;
65
+ const right = widths[to + 1];
66
+ if (right !== void 0 && used + gap + right <= width) {
67
+ to++;
68
+ used += gap + right;
69
+ grew = true;
70
+ }
71
+ const left = widths[from - 1];
72
+ if (left !== void 0 && used + gap + left <= width) {
73
+ from--;
74
+ used += gap + left;
75
+ grew = true;
76
+ }
77
+ }
78
+ return {
79
+ from,
80
+ to
81
+ };
82
+ };
83
+ /**
84
+ * A row (or column) of tabs, for a consumer's own screen.
85
+ *
86
+ * @remarks
87
+ * Not a screen: render `Tabs.View` inside a consumer's own screen. Its keys come from one `useKeys`, so there is a
88
+ * single input reader. Tab and Shift-Tab cycle whenever the tabs are focused, and plain digits jump, so a screen that
89
+ * hosts Tabs beside another widget reading Tab, arrows or digits (a `TextInput`, say) must decide who has the keys:
90
+ * pass `isFocused: false` to the Tabs while the other widget is being typed into. When unfocused, Tabs reads no key
91
+ * and is drawn muted.
92
+ *
93
+ * @public
94
+ */
95
+ var Tabs = class {
96
+ constructor() {}
97
+ /**
98
+ * The tab an action lands on: `"prev"` and `"next"` wrap at both ends; `{ jump }` moves to that index and does
99
+ * nothing when it is out of range.
100
+ *
101
+ * @param index - the active tab
102
+ * @param count - how many tabs there are
103
+ * @param action - the action
104
+ */
105
+ static step = step;
106
+ /** The keys of a row: `←`/`→` and Tab/Shift-Tab switch, plain digits 1–9 jump, 0 is the tenth tab. */
107
+ static keys = ROW_KEYS;
108
+ /** The keys of a column: `↑`/`↓` in place of `←`/`→`. */
109
+ static columnKeys = COLUMN_KEYS;
110
+ /**
111
+ * Draw the tabs: the active one in the accent token, bold and underlined; the others plain; every tab muted while
112
+ * unfocused. At colour `"none"`, where all of that vanishes, the active tab is bracketed, `[Alpha]`, and the others
113
+ * padded a space each side. A row wider than the terminal shows the tabs that fit around the active one, with the
114
+ * glyph set's ellipsis at a cut edge, so it never wraps.
115
+ *
116
+ * @remarks
117
+ * Controlled when `value` is given: a key calls `onChange` and the active tab moves only when `value` does.
118
+ * Uncontrolled otherwise, starting at `defaultValue` or the first tab. Either way `onChange` fires once on mount
119
+ * with the starting tab.
120
+ *
121
+ * @param props - the tabs and how they behave
122
+ */
123
+ static View = (props) => {
124
+ const { ink, react } = inkModules();
125
+ const glyphs = useGlyphs();
126
+ const theme = useTheme();
127
+ const { columns } = useTerminalSize();
128
+ const indexOf = (name) => name === void 0 ? -1 : props.tabs.findIndex((tab) => tab.name === name);
129
+ const [own, setOwn] = react.useState(() => Math.max(0, indexOf(props.defaultValue)));
130
+ const controlled = props.value !== void 0;
131
+ const index = controlled ? Math.max(0, indexOf(props.value)) : own;
132
+ const focused = props.isFocused ?? true;
133
+ const column = props.direction === "column";
134
+ const { onChange } = props;
135
+ const at = react.useRef(index);
136
+ at.current = index;
137
+ const drawn = react.useRef(index);
138
+ drawn.current = index;
139
+ const announced = react.useRef(false);
140
+ react.useEffect(() => {
141
+ if (announced.current) return;
142
+ announced.current = true;
143
+ const first = props.tabs[index];
144
+ if (first !== void 0) onChange?.(first.name, index);
145
+ }, []);
146
+ useKeys(column ? COLUMN_KEYS : ROW_KEYS, (action) => {
147
+ const from = at.current;
148
+ const next = step(from, props.tabs.length, action);
149
+ const tab = props.tabs[next];
150
+ if (next === from || tab === void 0) return;
151
+ at.current = next;
152
+ if (controlled) queueMicrotask(() => {
153
+ at.current = drawn.current;
154
+ });
155
+ else setOwn(next);
156
+ onChange?.(tab.name, next);
157
+ }, { isActive: focused });
158
+ const labelOf = (position) => `${props.showIndex === true ? `${position + 1}. ` : ""}${lineText(props.tabs[position]?.label ?? "")}`;
159
+ const plain = theme.color === "none";
160
+ const marks = plain ? 2 : 0;
161
+ const marked = (position, text) => plain ? position === index ? `[${text}]` : ` ${text} ` : text;
162
+ const draw = (position, text) => {
163
+ if (!focused) return react.createElement(Styled, {
164
+ key: position,
165
+ token: position === index ? {
166
+ ...theme.style("muted"),
167
+ underline: true
168
+ } : "muted"
169
+ }, text);
170
+ return position === index ? react.createElement(Styled, {
171
+ key: position,
172
+ token: {
173
+ ...theme.style("accent"),
174
+ bold: true,
175
+ underline: true
176
+ }
177
+ }, text) : react.createElement(ink.Text, { key: position }, text);
178
+ };
179
+ if (column) return react.createElement(ink.Box, { flexDirection: "column" }, ...props.tabs.map((_, position) => react.createElement(ink.Box, { key: position }, draw(position, marked(position, Fmt.truncate(labelOf(position), Math.max(1, columns - marks), { ellipsis: glyphs.ellipsis }))))));
180
+ const separator = lineText(props.separator ?? (glyphs.kind === "unicode" ? " │ " : " | "));
181
+ const labels = props.tabs.map((_, position) => marked(position, labelOf(position)));
182
+ const mark = Fmt.width(glyphs.ellipsis);
183
+ const { from, to } = labels.reduce((sum, label) => sum + Fmt.width(label), 0) + Fmt.width(separator) * (labels.length - 1) <= columns ? {
184
+ from: 0,
185
+ to: labels.length - 1
186
+ } : fitAround(labels.map((label) => Fmt.width(label)), index, Fmt.width(separator), Math.max(1, columns - 2 * mark));
187
+ const children = [];
188
+ if (from > 0) children.push(glyphs.ellipsis);
189
+ for (let position = from; position <= to; position++) {
190
+ if (position > from) children.push(separator);
191
+ const room = Math.max(1, columns - (from > 0 ? mark : 0) - (to < labels.length - 1 ? mark : 0));
192
+ const label = labels[position] ?? "";
193
+ const cut = () => marked(position, Fmt.truncate(labelOf(position), Math.max(1, room - marks), { ellipsis: glyphs.ellipsis }));
194
+ children.push(draw(position, from === to && Fmt.width(label) > room ? cut() : label));
195
+ }
196
+ if (to < labels.length - 1) children.push(glyphs.ellipsis);
197
+ return react.createElement(ink.Text, { wrap: "truncate-end" }, ...children);
198
+ };
199
+ };
200
+
201
+ //#endregion
202
+ export { Tabs };