@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/Toggle.js ADDED
@@ -0,0 +1,32 @@
1
+ import { Fmt } from "../Fmt.js";
2
+ import { inkModules } from "./internal/ink.js";
3
+ import { Styled, useGlyphs, useTerminalSize } from "./UiTheme.js";
4
+ import { lineText } from "./internal/lineText.js";
5
+
6
+ //#region src/ui/Toggle.ts
7
+ /**
8
+ * An on/off row: a check glyph and a label.
9
+ *
10
+ * @public
11
+ */
12
+ var Toggle = class {
13
+ constructor() {}
14
+ /**
15
+ * Draw a toggle row: `◉` on or `◯` off (`[x]` and `[ ]` under ASCII glyphs), then the label, cut to the width with
16
+ * the glyph set's ellipsis. A highlighted row starts with the arrow glyph and is painted with the accent token.
17
+ *
18
+ * @param props - the label, the value and whether the row is highlighted
19
+ */
20
+ static View = (props) => {
21
+ const { ink, react } = inkModules();
22
+ const glyphs = useGlyphs();
23
+ const { columns } = useTerminalSize();
24
+ const check = props.value ? glyphs.kind === "unicode" ? "◉" : "[x]" : glyphs.kind === "unicode" ? "◯" : "[ ]";
25
+ const lead = props.highlighted ? glyphs.arrow : " ".repeat(Fmt.width(glyphs.arrow));
26
+ const text = Fmt.truncate(`${lead} ${check} ${lineText(props.label)}`, columns, { ellipsis: glyphs.ellipsis });
27
+ return props.highlighted ? react.createElement(Styled, { token: "accent" }, text) : react.createElement(ink.Text, null, text);
28
+ };
29
+ };
30
+
31
+ //#endregion
32
+ export { Toggle };
package/ui/UiKey.js ADDED
@@ -0,0 +1,44 @@
1
+ //#region src/ui/UiKey.ts
2
+ const named = (name) => ({
3
+ _tag: "Named",
4
+ name
5
+ });
6
+ const CONTROL = /[\u0000-\u001f\u007f]/;
7
+ /**
8
+ * Normalising Ink's input into {@link UiKey}s.
9
+ *
10
+ * @public
11
+ */
12
+ const UiKey = {
13
+ fromInk: (input, key) => {
14
+ if (key.upArrow) return named("up");
15
+ if (key.downArrow) return named("down");
16
+ if (key.leftArrow) return named("left");
17
+ if (key.rightArrow) return named("right");
18
+ if (key.pageUp) return named("pageup");
19
+ if (key.pageDown) return named("pagedown");
20
+ if (key.home) return named("home");
21
+ if (key.end) return named("end");
22
+ if (key.return) return named("enter");
23
+ if (key.escape) return named("escape");
24
+ if (key.ctrl && input === "c") return named("ctrl+c");
25
+ if (key.tab) return named(key.shift ? "shift+tab" : "tab");
26
+ if (key.backspace) return named("backspace");
27
+ if (key.delete) return named("delete");
28
+ if (key.ctrl || key.meta) return void 0;
29
+ if (input === " ") return named("space");
30
+ if (input === "" || CONTROL.test(input)) return void 0;
31
+ return {
32
+ _tag: "Char",
33
+ char: input
34
+ };
35
+ },
36
+ named,
37
+ char: (char) => ({
38
+ _tag: "Char",
39
+ char
40
+ })
41
+ };
42
+
43
+ //#endregion
44
+ export { UiKey };
@@ -0,0 +1,60 @@
1
+ import { inkModules } from "./internal/ink.js";
2
+ import { screenContext } from "./internal/ScreenContext.js";
3
+ import { uiProviders } from "./internal/UiProviders.js";
4
+
5
+ //#region src/ui/UiProvider.ts
6
+ /**
7
+ * Provide the kit's context to an Ink tree the kit did not mount, so `useTheme`, `useGlyphs`, `Styled` and
8
+ * `useTerminalSize` work in it.
9
+ *
10
+ * @remarks
11
+ * Take the value from `CliUi.context`, which also loads Ink and React: the provider and the kit's hooks render with
12
+ * the modules the kit loaded. A screen mounted by `CliUi.run` already has this context.
13
+ *
14
+ * With `size`, `useTerminalSize` reads it instead of the stdout Ink draws on, less one column and one row as ever.
15
+ * Give it to Ink's `renderToString`, whose terminal hooks see the process's own stdout rather than the width it lays
16
+ * out at: `renderToString(tree, { columns })` with `size: { columns, rows }` keeps the kit's widgets cut to that
17
+ * width. A tree given a `size` no longer follows the terminal's resizes.
18
+ *
19
+ * Standalone, there is no screen to end: a kit widget's own quit key, such as `Select`'s `q`, does nothing, and an
20
+ * input handler a kit widget registers is called as is, so what it throws escapes as Ink leaves it. Nested inside a `CliUi.run` screen, it overrides only the theme, the glyphs and the size: the screen's
21
+ * cancel and its defect route pass through, so `q` still cancels and a throwing handler is still the screen's defect.
22
+ * Ink's colour level is the host's: `Styled` passes the theme's props, none at colour `none`.
23
+ *
24
+ * @param props - the context, and the tree
25
+ *
26
+ * @public
27
+ */
28
+ const UiProvider = (props) => {
29
+ const { react } = inkModules();
30
+ const { theme, glyphs, size } = props.value;
31
+ const columns = size?.columns;
32
+ const rows = size?.rows;
33
+ const parent = react.useContext(screenContext());
34
+ const cancel = parent?.cancel;
35
+ const die = parent?.die;
36
+ const neutralize = props.value.neutralizeWorkflowCommands === true || parent?.neutralizeWorkflowCommands === true;
37
+ const value = react.useMemo(() => ({
38
+ theme,
39
+ glyphs,
40
+ ...columns === void 0 || rows === void 0 ? {} : { size: {
41
+ columns,
42
+ rows
43
+ } },
44
+ ...cancel === void 0 ? {} : { cancel },
45
+ ...die === void 0 ? {} : { die },
46
+ ...neutralize ? { neutralizeWorkflowCommands: true } : {}
47
+ }), [
48
+ theme,
49
+ glyphs,
50
+ columns,
51
+ rows,
52
+ cancel,
53
+ die,
54
+ neutralize
55
+ ]);
56
+ return uiProviders(value, props.children);
57
+ };
58
+
59
+ //#endregion
60
+ export { UiProvider };
@@ -0,0 +1,18 @@
1
+ import { processStreams } from "./internal/processStreams.js";
2
+ import { Context } from "effect";
3
+
4
+ //#region src/ui/UiStreams.ts
5
+ /**
6
+ * The streams a screen mounts on, the process's own standard streams by default.
7
+ *
8
+ * @remarks
9
+ * A `Context.Reference`, so it never appears in `R`: the default reads the process streams when first used, never
10
+ * at import, and a test provides in-memory streams with `Effect.provideService(UiStreams, streams)`. `./ui` binds
11
+ * Node's process streams.
12
+ *
13
+ * @public
14
+ */
15
+ var UiStreams = class extends Context.Reference("@effected/cli/ui/UiStreams", { defaultValue: () => processStreams() }) {};
16
+
17
+ //#endregion
18
+ export { UiStreams };
package/ui/UiTheme.js ADDED
@@ -0,0 +1,119 @@
1
+ import { inkModules } from "./internal/ink.js";
2
+ import { screenContext } from "./internal/ScreenContext.js";
3
+
4
+ //#region src/ui/UiTheme.ts
5
+ const OUTSIDE = "@effected/cli/ui: a theme hook was used outside a screen mounted by CliUi.run or a UiProvider";
6
+ const useScreen = () => {
7
+ const screen = inkModules().react.useContext(screenContext());
8
+ if (screen === void 0) throw new Error(OUTSIDE);
9
+ return screen;
10
+ };
11
+ /**
12
+ * The Ink `Text` props for `style` at `color`.
13
+ *
14
+ * @remarks
15
+ * At `"none"` it gives no styling props at all, not even bold or dim, so a frame is escape-free by construction;
16
+ * Ink's colour level held at 0 is the backstop. A flag set to `false` adds no prop.
17
+ *
18
+ * Omit `color` in an Ink tree the kit did not mount, which has no colour level of its own to pass: every prop is
19
+ * emitted, as at any level but `"none"`, and Ink's own chalk gates what reaches the terminal.
20
+ *
21
+ * @param style - the resolved style
22
+ * @param color - the stream's colour level; omitted, every prop is emitted for Ink's chalk to gate
23
+ *
24
+ * @public
25
+ */
26
+ const inkProps = (style, color) => color === "none" ? {} : {
27
+ ...style.fg === void 0 ? {} : { color: style.fg },
28
+ ...style.bold === true ? { bold: true } : {},
29
+ ...style.dim === true ? { dimColor: true } : {},
30
+ ...style.italic === true ? { italic: true } : {},
31
+ ...style.underline === true ? { underline: true } : {}
32
+ };
33
+ /**
34
+ * The theme of the stream the mounted screen draws on.
35
+ *
36
+ * @remarks
37
+ * A React hook: call it from a component rendered inside a `CliUi.run` screen or a `UiProvider`.
38
+ *
39
+ * @public
40
+ */
41
+ const useTheme = () => useScreen().theme;
42
+ /**
43
+ * The glyph set of the mounted screen, so a component draws Unicode or ASCII glyphs to match the rest of the output.
44
+ *
45
+ * @remarks
46
+ * A React hook: call it from a component rendered inside a `CliUi.run` screen or a `UiProvider`.
47
+ *
48
+ * @public
49
+ */
50
+ const useGlyphs = () => useScreen().glyphs;
51
+ /**
52
+ * Text painted with a theme token or style, through the mounted screen's theme.
53
+ *
54
+ * @remarks
55
+ * Its children are drawn as given. The kit's widgets sanitise every string they draw from data (escapes removed,
56
+ * line breaks folded) before handing it here; text a consumer passes to `Styled`, or to Ink's own `Text`, is the
57
+ * consumer's to sanitise, with `Fmt.sanitize`. Ink keeps the escape sequences it is handed, so text from data drawn
58
+ * unsanitised can paint colour at colour `none` or plant a hyperlink, and a line break in it adds a row the screen's
59
+ * height budget did not count.
60
+ *
61
+ * @param props - the token or style, and the text
62
+ *
63
+ * @public
64
+ */
65
+ const Styled = (props) => {
66
+ const theme = useTheme();
67
+ const { ink, react } = inkModules();
68
+ return react.createElement(ink.Text, inkProps(theme.style(props.token), theme.color), props.children);
69
+ };
70
+ /** A reported size, or `fallback` when it is unknown: absent, or not positive (a pty `script` opens reports 0x0). */
71
+ const known = (reported, fallback) => reported !== void 0 && reported > 0 ? reported : fallback;
72
+ /**
73
+ * The usable terminal size: the stdout Ink draws on, less one column and one row, re-read on every render and when
74
+ * the terminal resizes; or, under a `UiProvider` given a `size`, that size less one column and one row.
75
+ *
76
+ * @remarks
77
+ * A width or height the stream does not report, or reports as 0 (a pty that `script` opens says `0 0`), is unknown and
78
+ * reads as 80 columns by 24 rows, so a screen never lays itself out at width 0. This is not Ink's own fallback, which
79
+ * first asks the process's terminal (`terminal-size`: the tty, `COLUMNS`, `tput`) and only then uses 80x24; the kit
80
+ * reads no `process` here. On a 0x0 pty with `COLUMNS=50`, Ink lays out at 50 while these rows are cut at 79.
81
+ *
82
+ * The override exists for Ink's `renderToString`, whose `useStdout` is the process's own stdout whatever width it
83
+ * lays out at; without it, the kit's widgets would cut their rows to the wrong width there.
84
+ *
85
+ * Never feed `columns` into a `Box`'s `width`. On a resize Ink re-lays out the tree it already has and repaints
86
+ * before React re-renders with the new size, so a width taken from this hook is one paint stale. After a shrink,
87
+ * that stale, wider frame wraps in the narrower terminal and leaves a copy stranded above the live one. For a
88
+ * one-column margin use `marginRight: 1`, which Ink recomputes within its own resize. Text cut to `columns` lags the
89
+ * same paint, so give a long row Ink's `wrap: "truncate-end"` too: on a shrink Ink then clips it rather than letting
90
+ * the terminal wrap it.
91
+ *
92
+ * A React hook: call it from a component rendered inside an Ink tree; it needs no screen, but reads a `UiProvider`'s
93
+ * size when there is one.
94
+ *
95
+ * @public
96
+ */
97
+ const useTerminalSize = () => {
98
+ const { ink, react } = inkModules();
99
+ const { stdout } = ink.useStdout();
100
+ const override = react.useContext(screenContext())?.size;
101
+ const [, redraw] = react.useReducer((count) => count + 1, 0);
102
+ const followsStdout = override === void 0;
103
+ react.useEffect(() => {
104
+ if (!followsStdout) return void 0;
105
+ stdout.on("resize", redraw);
106
+ return () => {
107
+ stdout.off("resize", redraw);
108
+ };
109
+ }, [stdout, followsStdout]);
110
+ const columns = override?.columns ?? stdout.columns;
111
+ const rows = override?.rows ?? stdout.rows;
112
+ return {
113
+ columns: Math.max(1, known(columns, 80) - 1),
114
+ rows: Math.max(1, known(rows, 24) - 1)
115
+ };
116
+ };
117
+
118
+ //#endregion
119
+ export { Styled, inkProps, useGlyphs, useTerminalSize, useTheme };
package/ui/Viewport.js ADDED
@@ -0,0 +1,204 @@
1
+ import { inkModules } from "./internal/ink.js";
2
+ import { useTerminalSize } from "./UiTheme.js";
3
+ import { KeyTable } from "./KeyTable.js";
4
+
5
+ //#region 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/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
+ //#endregion
204
+ export { Viewport };
@@ -0,0 +1,30 @@
1
+ import { fromReact } from "./ink.js";
2
+
3
+ //#region 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
+ //#endregion
30
+ export { errorBoundary };
@@ -0,0 +1,74 @@
1
+ import { fromReact } from "./ink.js";
2
+
3
+ //#region 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
+ //#endregion
74
+ export { holder, holderSlot };
@@ -0,0 +1,52 @@
1
+ import { fromReact, inkModules } from "./ink.js";
2
+
3
+ //#region 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/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
+ //#endregion
52
+ export { screenContext, useScreenCancel, useScreenGuard };
@@ -0,0 +1,21 @@
1
+ import { inkModules } from "./ink.js";
2
+ import { screenContext } from "./ScreenContext.js";
3
+
4
+ //#region src/ui/internal/UiProviders.ts
5
+ /**
6
+ * Wrap `children` in the kit's providers: the theme, the glyph set, the optional size override, and a screen's cancel
7
+ * and defect route when it has them.
8
+ *
9
+ * @remarks
10
+ * The one place a kit tree's context is built, for `CliUi.run`'s screens, a live view and the public `UiProvider`,
11
+ * so every kit hook reads the same value under each.
12
+ *
13
+ * @internal
14
+ */
15
+ const uiProviders = (value, children) => inkModules().react.createElement(screenContext().Provider, {
16
+ value,
17
+ children
18
+ });
19
+
20
+ //#endregion
21
+ export { uiProviders };