@effected/cli-kit 0.0.0-stage → 0.26.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 (103) hide show
  1. package/Cancelled.js +43 -0
  2. package/CliAudience.js +177 -0
  3. package/CliColor.js +59 -0
  4. package/CliEnv.js +101 -0
  5. package/CliExit.js +64 -0
  6. package/CliFailure.js +287 -0
  7. package/CliFormat.js +124 -0
  8. package/CliInteractive.js +94 -0
  9. package/CliLinks.js +153 -0
  10. package/CliLog.js +367 -0
  11. package/CliLogger.js +91 -0
  12. package/CliMessage.js +79 -0
  13. package/CliPrompt.js +110 -0
  14. package/CliRuntime.js +254 -0
  15. package/CliTest.js +206 -0
  16. package/CliTheme.js +142 -0
  17. package/ConfigIssueRenderer.js +63 -0
  18. package/Doc.js +548 -0
  19. package/Fmt.js +335 -0
  20. package/GithubAnnotation.js +39 -0
  21. package/Glyphs.js +82 -0
  22. package/LICENSE +21 -0
  23. package/NotInteractive.js +41 -0
  24. package/README.md +215 -2
  25. package/Render.js +280 -0
  26. package/SchemaIssueRenderer.js +47 -0
  27. package/Status.js +165 -0
  28. package/TestTerminal.js +101 -0
  29. package/Token.js +68 -0
  30. package/index.d.ts +3951 -0
  31. package/index.js +27 -0
  32. package/internal/ExitRequested.js +21 -0
  33. package/internal/HelpRouting.js +35 -0
  34. package/internal/ansi.js +229 -0
  35. package/internal/autoFormat.js +33 -0
  36. package/internal/canPrompt.js +14 -0
  37. package/internal/counts.js +83 -0
  38. package/internal/diagnostics.js +31 -0
  39. package/internal/displayPath.js +46 -0
  40. package/internal/displayWidth.js +42 -0
  41. package/internal/failureTarget.js +195 -0
  42. package/internal/fallbackAnswer.js +17 -0
  43. package/internal/fileSink.js +61 -0
  44. package/internal/format.js +175 -0
  45. package/internal/inspect.js +40 -0
  46. package/internal/isExitCode.js +11 -0
  47. package/internal/layout.js +249 -0
  48. package/internal/linkScheme.js +29 -0
  49. package/internal/linkTarget.js +49 -0
  50. package/internal/logSafety.js +45 -0
  51. package/internal/renderAnsi.js +51 -0
  52. package/internal/renderDoc.js +322 -0
  53. package/internal/renderGithubLog.js +59 -0
  54. package/internal/renderMarkdown.js +367 -0
  55. package/internal/renderPlain.js +49 -0
  56. package/internal/scanAudience.js +105 -0
  57. package/internal/splitFrame.js +55 -0
  58. package/internal/terminalCapabilities.js +23 -0
  59. package/internal/wizardGate.js +17 -0
  60. package/package.json +90 -5
  61. package/testing.d.ts +246 -0
  62. package/testing.js +4 -0
  63. package/tsdoc-metadata.json +11 -0
  64. package/ui/CliUi.js +507 -0
  65. package/ui/CliUiDetached.js +36 -0
  66. package/ui/CliUiLive.js +436 -0
  67. package/ui/CliUiRouteStream.js +72 -0
  68. package/ui/Confirm.js +244 -0
  69. package/ui/DocView.js +76 -0
  70. package/ui/KeyHelp.js +61 -0
  71. package/ui/KeyTable.js +198 -0
  72. package/ui/MultiSelect.js +259 -0
  73. package/ui/Select.js +229 -0
  74. package/ui/Tabs.js +201 -0
  75. package/ui/TextInput.js +289 -0
  76. package/ui/Toggle.js +31 -0
  77. package/ui/UiKey.js +48 -0
  78. package/ui/UiProvider.js +62 -0
  79. package/ui/UiStreams.js +17 -0
  80. package/ui/UiTheme.js +147 -0
  81. package/ui/Viewport.js +203 -0
  82. package/ui/internal/ErrorBoundary.js +29 -0
  83. package/ui/internal/Holder.js +73 -0
  84. package/ui/internal/ScreenContext.js +51 -0
  85. package/ui/internal/UiProviders.js +38 -0
  86. package/ui/internal/ink.js +121 -0
  87. package/ui/internal/inkChalk.js +47 -0
  88. package/ui/internal/inkConsole.js +150 -0
  89. package/ui/internal/lazyView.js +73 -0
  90. package/ui/internal/lineRouter.js +70 -0
  91. package/ui/internal/lineText.js +18 -0
  92. package/ui/internal/mountPermit.js +15 -0
  93. package/ui/internal/processStreams.js +18 -0
  94. package/ui/internal/renderOptions.js +12 -0
  95. package/ui/testing/CliUiTest.js +792 -0
  96. package/ui/testing/fakeStreams.js +104 -0
  97. package/ui/testing/terminalModel.js +67 -0
  98. package/ui-testing-serializer.d.ts +12 -0
  99. package/ui-testing-serializer.js +32 -0
  100. package/ui-testing.d.ts +558 -0
  101. package/ui-testing.js +3 -0
  102. package/ui.d.ts +1990 -0
  103. package/ui.js +17 -0
package/ui/Confirm.js ADDED
@@ -0,0 +1,244 @@
1
+ import { Fmt } from "../Fmt.js";
2
+ import { inkModules } from "./internal/ink.js";
3
+ import { lineText } from "./internal/lineText.js";
4
+ import { useScreenCancel } from "./internal/ScreenContext.js";
5
+ import { KeyHelp } from "./KeyHelp.js";
6
+ import { KeyTable, useKeys } from "./KeyTable.js";
7
+ import { Toggle } from "./Toggle.js";
8
+ import { Styled, useGlyphs, useTerminalSize } from "./UiTheme.js";
9
+ import { Viewport } from "./Viewport.js";
10
+
11
+ // src/ui/Confirm.ts
12
+ const assertUniqueKeys = (toggles) => {
13
+ const seen = /* @__PURE__ */ new Set();
14
+ for (const toggle of toggles) {
15
+ if (seen.has(toggle.key)) throw new Error(`@effected/cli-kit/ui: Confirm toggle keys must be unique; "${toggle.key}" repeats`);
16
+ seen.add(toggle.key);
17
+ }
18
+ };
19
+ const init = (options = {}) => {
20
+ const toggles = options.toggles ?? [];
21
+ assertUniqueKeys(toggles);
22
+ return {
23
+ confirmed: options.initial ?? false,
24
+ row: 0,
25
+ toggles,
26
+ submitted: false
27
+ };
28
+ };
29
+ const step = (state, action) => {
30
+ switch (action) {
31
+ case "up": return {
32
+ ...state,
33
+ row: Math.max(0, state.row - 1)
34
+ };
35
+ case "down": return {
36
+ ...state,
37
+ row: Math.min(state.toggles.length, state.row + 1)
38
+ };
39
+ case "toggle": return state.row === 0 ? state : {
40
+ ...state,
41
+ toggles: state.toggles.map((toggle, index) => index === state.row - 1 ? {
42
+ ...toggle,
43
+ value: !toggle.value
44
+ } : toggle)
45
+ };
46
+ case "yes": return {
47
+ ...state,
48
+ confirmed: true
49
+ };
50
+ case "no": return {
51
+ ...state,
52
+ confirmed: false
53
+ };
54
+ case "flip": return {
55
+ ...state,
56
+ confirmed: !state.confirmed
57
+ };
58
+ case "submit": return {
59
+ ...state,
60
+ submitted: true
61
+ };
62
+ case "cancel": return state;
63
+ }
64
+ };
65
+ const result = (state) => ({
66
+ confirmed: state.confirmed,
67
+ toggles: Object.fromEntries(state.toggles.map((toggle) => [toggle.key, toggle.value]))
68
+ });
69
+ /** The bindings; with no toggles the row and toggle keys stay bound (they do nothing) but leave the help line. */
70
+ const bindings = (toggles) => KeyTable.make([
71
+ {
72
+ keys: [{ char: "y" }],
73
+ action: "yes",
74
+ help: "yes"
75
+ },
76
+ {
77
+ keys: [{ char: "n" }],
78
+ action: "no",
79
+ help: "no"
80
+ },
81
+ {
82
+ keys: ["left"],
83
+ action: "flip",
84
+ help: "flip"
85
+ },
86
+ {
87
+ keys: ["right"],
88
+ action: "flip",
89
+ help: "flip"
90
+ },
91
+ {
92
+ keys: ["up"],
93
+ action: "up",
94
+ help: "row",
95
+ hidden: !toggles
96
+ },
97
+ {
98
+ keys: ["down"],
99
+ action: "down",
100
+ help: "row",
101
+ hidden: !toggles
102
+ },
103
+ {
104
+ keys: ["space"],
105
+ action: "toggle",
106
+ help: "toggle",
107
+ hidden: !toggles
108
+ },
109
+ {
110
+ keys: ["enter"],
111
+ action: "submit",
112
+ help: "submit"
113
+ },
114
+ {
115
+ keys: [{ char: "q" }],
116
+ action: "cancel",
117
+ help: "cancel"
118
+ }
119
+ ]);
120
+ const KEYS = bindings(true);
121
+ const ANSWER_KEYS = bindings(false);
122
+ /** The lines around the toggle rows: the question, the answer row and the help line. */
123
+ const RESERVED = 3;
124
+ /**
125
+ * A yes/no question, optionally with extra on/off rows beneath it: a pure reducer, its key table, a view and a
126
+ * ready-made screen.
127
+ *
128
+ * @remarks
129
+ * The rows are the yes/no row first, then each toggle. `y` and `n` set the answer and `←`/`→` flip it, from any
130
+ * row; `↑`/`↓` move between rows; space flips the highlighted toggle and does nothing on the yes/no row; enter
131
+ * submits; `q` cancels with `"escape"`. Toggle keys must be unique; `init` throws, and `screen` dies, on a repeat.
132
+ * With no toggles, the help line leaves out the row and toggle keys. Toggles that do not fit the terminal scroll
133
+ * in a window under the answer row, so the question, the answer and the help line stay on screen.
134
+ * The answer starts as no unless `initial` says otherwise, and `←`/`→` flip it whichever row is highlighted.
135
+ *
136
+ * A toggle passed conditionally is absent from the result when it was left out, so read it back with a fallback:
137
+ *
138
+ * ```ts
139
+ * import { CliUi, Confirm } from "@effected/cli-kit/ui"
140
+ * import { Effect } from "effect"
141
+ *
142
+ * const publish = (drafts: number) =>
143
+ * Effect.gen(function* () {
144
+ * const { confirmed, toggles } = yield* CliUi.run(
145
+ * Confirm.screen({
146
+ * message: "Publish the release?",
147
+ * toggles: drafts > 0 ? [{ key: "promote", label: `promote ${drafts} drafts to stable`, value: true }] : [],
148
+ * }),
149
+ * )
150
+ * const promote = toggles.promote ?? false
151
+ * return { confirmed, promote }
152
+ * })
153
+ * ```
154
+ *
155
+ * @public
156
+ */
157
+ var Confirm = class Confirm {
158
+ constructor() {}
159
+ /**
160
+ * A confirm with its answer (no by default) and toggles, on the yes/no row.
161
+ *
162
+ * @param options - the starting answer and the toggles
163
+ */
164
+ static init = init;
165
+ /**
166
+ * Apply an action (see the class remarks for what each key does).
167
+ *
168
+ * @param state - where the confirm is
169
+ * @param action - the action
170
+ */
171
+ static step = step;
172
+ /**
173
+ * The answer and every toggle's value by key.
174
+ *
175
+ * @param state - where the confirm is
176
+ */
177
+ static result = result;
178
+ /** The keys: y yes, n no, ←/→ flip, ↑/↓ row, space toggle, enter submit, q cancel (the view hides row and toggle from its help when there are no toggles). */
179
+ static keys = KEYS;
180
+ /**
181
+ * Draw the confirm: the question, the answer row (`[Yes] No` or ` Yes [No]`, the chosen answer in brackets so it
182
+ * shows without colour, and in the accent token), a {@link Toggle} row per toggle, and the key help.
183
+ *
184
+ * @remarks
185
+ * Single-shot, like `Select.View`: the options are read once at mount.
186
+ *
187
+ * @param props - the question, the starting answer, the toggles and where the result goes
188
+ */
189
+ static View = (props) => {
190
+ const { ink, react } = inkModules();
191
+ const glyphs = useGlyphs();
192
+ const { columns } = useTerminalSize();
193
+ const cancel = useScreenCancel();
194
+ const [state, setState] = react.useState(() => init({
195
+ ...props.initial === void 0 ? {} : { initial: props.initial },
196
+ ...props.toggles === void 0 ? {} : { toggles: props.toggles }
197
+ }));
198
+ const { onSubmit } = props;
199
+ react.useEffect(() => {
200
+ if (state.submitted) onSubmit(result(state));
201
+ }, [state.submitted]);
202
+ const keys = state.toggles.length === 0 ? ANSWER_KEYS : KEYS;
203
+ useKeys(keys, (action) => {
204
+ if (action === "cancel") cancel("escape");
205
+ else setState((current) => step(current, action));
206
+ });
207
+ const toggleRows = state.toggles.map((toggle) => ({
208
+ _tag: "Item",
209
+ key: toggle.key
210
+ }));
211
+ const numberOf = new Map(state.toggles.map((toggle, index) => [toggle.key, index]));
212
+ const ellipsis = { ellipsis: glyphs.ellipsis };
213
+ const lead = state.row === 0 ? glyphs.arrow : " ".repeat(Fmt.width(glyphs.arrow));
214
+ const answer = (label, chosen) => chosen ? react.createElement(Styled, { token: "accent" }, `[${label}]`) : react.createElement(ink.Text, null, ` ${label} `);
215
+ return react.createElement(ink.Box, { flexDirection: "column" }, react.createElement(Styled, { token: "emphasis" }, Fmt.truncate(lineText(props.message), columns, ellipsis)), react.createElement(ink.Text, null, `${lead} `, answer("Yes", state.confirmed), " ", answer("No", !state.confirmed)), state.toggles.length === 0 ? null : react.createElement(Viewport.View, {
216
+ rows: toggleRows,
217
+ state: Viewport.init(state.toggles.length, state.toggles.length, Math.max(0, state.row - 1)),
218
+ reserved: RESERVED,
219
+ renderRow: (row) => {
220
+ const index = row._tag === "Item" ? numberOf.get(row.key) ?? 0 : 0;
221
+ const toggle = state.toggles[index];
222
+ return react.createElement(Toggle.View, {
223
+ label: toggle?.label ?? "",
224
+ value: toggle?.value ?? false,
225
+ highlighted: state.row === index + 1
226
+ });
227
+ }
228
+ }), react.createElement(KeyHelp, { tables: [keys] }));
229
+ };
230
+ /**
231
+ * A ready-made screen for `CliUi.run`: the confirm, resolving with the answer and the toggles.
232
+ *
233
+ * @param options - the question, the starting answer and the toggles
234
+ */
235
+ static screen = (options) => (control) => {
236
+ assertUniqueKeys(options.toggles ?? []);
237
+ return inkModules().react.createElement(Confirm.View, {
238
+ ...options,
239
+ onSubmit: control.resolve
240
+ });
241
+ };
242
+ };
243
+
244
+ export { Confirm };
package/ui/DocView.js ADDED
@@ -0,0 +1,76 @@
1
+ import { identity } from "../internal/displayPath.js";
2
+ import { Render } from "../Render.js";
3
+ import { fromReact, inkModules } from "./internal/ink.js";
4
+ import { screenContext } from "./internal/ScreenContext.js";
5
+ import { useTerminalSize } from "./UiTheme.js";
6
+
7
+ // src/ui/DocView.ts
8
+ const OUTSIDE = "@effected/cli-kit/ui: DocView was drawn with no ctx outside a screen, a live view or a UiProvider, so it has no theme";
9
+ /** The context a `DocView` builds from its tree's theme: the theme's own paint, so token overrides hold. */
10
+ const contextOf = (theme, width, neutralize, displayPath) => ({
11
+ ...neutralize ? { neutralizeWorkflowCommands: true } : {},
12
+ width,
13
+ audience: "human",
14
+ color: theme.color,
15
+ paint: theme.paint,
16
+ glyphs: theme.glyphs,
17
+ link: (_target, label) => label,
18
+ displayPath
19
+ });
20
+ /** The document's lines as the kit's own renderer lays them out: plain at colour `none`, painted otherwise. */
21
+ const linesOf = (doc, ctx) => {
22
+ const document = Array.isArray(doc) ? doc : [doc];
23
+ const text = ctx.color === "none" ? Render.plain(document, ctx) : Render.ansi(document, ctx);
24
+ return text === "" ? [] : text.split("\n");
25
+ };
26
+ /** The view, memoised on its props and built on the loaded React, like every kit component. */
27
+ const docView = fromReact((react) => {
28
+ const View = (props) => {
29
+ const { ink } = inkModules();
30
+ const screen = react.useContext(screenContext());
31
+ const { columns } = useTerminalSize();
32
+ const given = props.ctx;
33
+ const theme = screen?.theme;
34
+ const neutralize = screen?.neutralizeWorkflowCommands === true;
35
+ const displayPath = screen?.displayPath ?? identity;
36
+ if (given === void 0 && theme === void 0) throw new Error(OUTSIDE);
37
+ const ctx = react.useMemo(() => given ?? contextOf(theme, columns, neutralize, displayPath), [
38
+ given,
39
+ theme,
40
+ columns,
41
+ neutralize,
42
+ displayPath
43
+ ]);
44
+ const lines = react.useMemo(() => linesOf(props.doc, ctx), [props.doc, ctx]);
45
+ return react.createElement(ink.Box, {
46
+ flexDirection: "column",
47
+ flexShrink: 0
48
+ }, ...lines.map((line, index) => react.createElement(ink.Text, {
49
+ key: index,
50
+ wrap: "truncate-end"
51
+ }, line === "" ? " " : line)));
52
+ };
53
+ View.displayName = "CliUiDocView";
54
+ return react.memo(View);
55
+ });
56
+ /**
57
+ * 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
58
+ * report show a document the same way.
59
+ *
60
+ * @remarks
61
+ * The document is rendered with `Render.ansi` (`Render.plain` at colour `none`) at the width, and each line becomes
62
+ * one Ink `Text` row cut with `wrap: "truncate-end"`, so Ink never re-wraps what the renderer laid out. Everything the
63
+ * static renderers do holds: a collapsible is drawn open, an annotation is skipped, text from data is sanitised.
64
+ *
65
+ * Without a `ctx` the view takes its theme from the tree (a screen, a live view, or a `UiProvider`) and its width from
66
+ * `useTerminalSize`, so it follows a resize; for an agent the theme is colourless, so the view is escape-free. Links
67
+ * are off. The layout is memoised on the document's identity and the context: re-render with the same document, as a
68
+ * live view's tick does, and the renderer does not run again; build a new document only when it changes.
69
+ *
70
+ * @param props - the document, and optionally the render context
71
+ *
72
+ * @public
73
+ */
74
+ const DocView = (props) => inkModules().react.createElement(docView(), props);
75
+
76
+ export { DocView };
package/ui/KeyHelp.js ADDED
@@ -0,0 +1,61 @@
1
+ import { Fmt } from "../Fmt.js";
2
+ import { inkModules } from "./internal/ink.js";
3
+ import { lineText } from "./internal/lineText.js";
4
+ import { KeyTable } from "./KeyTable.js";
5
+ import { Styled, useGlyphs, useTerminalSize } from "./UiTheme.js";
6
+
7
+ // 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
+ export { KeyHelp };
package/ui/KeyTable.js ADDED
@@ -0,0 +1,198 @@
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
+ // 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
+ export { KeyTable, useKeys };