@orkestrel/terminal 0.0.14 → 0.0.16

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.
@@ -1,14 +1,15 @@
1
1
  import { CSI, createStyler } from "@orkestrel/console";
2
2
  import { NEWLINE, RETURN, TerminalError, createCheckboxState, createConfirmState, createEditorState, createInputState, createPasswordState, createPromptTheme, createSelectState, parseKey, reduceCheckbox, reduceConfirm, reduceEditor, reduceInput, reducePassword, reduceSelect, renderErrorLine, renderHintedHeader, renderPromptHeader, sanitizeDisplayText } from "../core/index.js";
3
+ import { isBoolean, isFunction, isNumber, isObject, isString } from "@orkestrel/contract";
3
4
  import { matchesAnswer, parseValue } from "@orkestrel/form";
4
5
  import { isStreamTarget } from "@orkestrel/console/server";
5
6
  import { createInterface } from "node:readline";
6
7
  import { stdin, stdout } from "node:process";
7
8
  //#region src/server/constants.ts
8
9
  /**
9
- * Holds the cursor-UP sequence TEMPLATE (`ESC[{count}A`) — {@link import('./helpers.js').renderCursorUp}
10
- * interpolates the `{count}` placeholder with the number of lines to climb. Kept as a template so
11
- * the count stays out of the constant.
10
+ * Holds the cursor-up sequence template (`ESC[{count}A`) —
11
+ * {@link import('./helpers.js').renderCursorUp} interpolates the `{count}` placeholder with the
12
+ * number of lines to climb. Kept as a template so the count stays out of the constant.
12
13
  */
13
14
  var CSI_UP = `${CSI}{count}A`;
14
15
  /**
@@ -19,9 +20,9 @@ var CURSOR_HIDE = `${CSI}?25l`;
19
20
  /** Shows the cursor (`ESC[?25h`) — restores the cursor after a prompt resolves / cancels (the {@link CURSOR_HIDE} pair). */
20
21
  var CURSOR_SHOW = `${CSI}?25h`;
21
22
  /**
22
- * Erases from the cursor down to the end of the screen (`ESC[J`) — wipes the WHOLE previous (possibly
23
- * multi-line `select` / `checkbox`) view in one write before the new view is rendered, so a redraw
24
- * never leaves orphaned rows below.
23
+ * Erases from the cursor down to the end of the screen (`ESC[J`) — wipes the whole previous view,
24
+ * which a `select` or `checkbox` can spread over several lines, in one write before the new view is
25
+ * rendered, so a redraw never leaves orphaned rows behind.
25
26
  */
26
27
  var CLEAR_DOWN = `${CSI}J`;
27
28
  /**
@@ -53,9 +54,15 @@ var CONTROL_HINTS = Object.freeze({
53
54
  });
54
55
  /** Holds the instruction a `file` field with `multiple` shows before its entries — one path per line, and a blank line ends the list. */
55
56
  var FILE_HINT = "One path per line, blank to finish";
56
- /** Holds the lead on the line listing an open `select`'s offered values, which a typed answer may ignore. */
57
+ /**
58
+ * Holds the lead on the line listing an open `select`'s offered values, which a typed answer can
59
+ * ignore.
60
+ */
57
61
  var SUGGESTION_LEAD = "Suggestions";
58
- /** Holds the lead on the line listing the choices a `select` or `checkbox` shows but refuses, so a reader sees why one is missing from the list below. */
62
+ /**
63
+ * Holds the lead on the line listing the choices a `select` or `checkbox` shows but refuses, so a
64
+ * reader sees why one is missing from the list it heads.
65
+ */
59
66
  var UNAVAILABLE_LEAD = "Unavailable";
60
67
  /** Holds the mark on a locked field's line — the walk renders its value and moves on, because the form refuses an edit there. */
61
68
  var LOCKED_MARK = "(locked)";
@@ -69,55 +76,55 @@ var REFUSAL_MESSAGE = "Enter a value this field accepts";
69
76
  //#endregion
70
77
  //#region src/server/helpers.ts
71
78
  /**
72
- * Checks whether `value` is a usable {@link InputStreamInterface} — a record with callable `on` / `off`
73
- * `'data'` subscription methods. A total type guard: it NEVER throws and returns `false`
74
- * for anything off-shape, so it narrows the one unavoidable input boundary (the real `process.stdin`,
75
- * or a fake TTY a test injects) to the exact slice the driver reads — no `as`.
79
+ * Checks whether `value` is a usable {@link InputStreamInterface} — a record with callable `on` /
80
+ * `off` `'data'` subscription methods. A total type guard: it never throws and returns `false` for
81
+ * anything off-shape, so it narrows the one unavoidable input boundary (the real `process.stdin`,
82
+ * or a fake TTY a test injects) to the exact slice the driver reads, never an assertion.
76
83
  *
77
84
  * @remarks
78
85
  * Only `on` / `off` are required (the irreducible event seam); `setRawMode` / `resume` / `pause` /
79
86
  * `isTTY` are optional on {@link InputStreamInterface}, so their absence does not disqualify a stream
80
- * — a piped, non-TTY stream is still a valid input, just one the driver reads through the readline
87
+ * — a piped, non-TTY stream is still a valid input, one the driver reads through the readline
81
88
  * fallback rather than raw mode.
82
89
  *
83
90
  * @param value - Any value crossing the boundary (a process stream, an injected fake, `unknown`)
84
91
  * @returns True if `value` has callable `on` and `off`; false otherwise
85
92
  */
86
93
  function isInputStream(value) {
87
- return typeof value === "object" && value !== null && "on" in value && typeof value.on === "function" && "off" in value && typeof value.off === "function";
94
+ return isObject(value) && "on" in value && isFunction(value.on) && "off" in value && isFunction(value.off);
88
95
  }
89
96
  /**
90
97
  * Checks whether `value` is a Node {@link NodeJS.ReadableStream} — a total structural guard
91
- * checking for the callable `read` / `pipe` / `on` that `node:readline`'s `createInterface` requires
92
- * as its `input`. The non-TTY fallback narrows the resolved input stream through this before handing
93
- * it to readline (never an `as`), so a real piped `process.stdin` (or a `PassThrough` a test injects)
94
- * crosses into the readline boundary honestly. Never throws; returns `false` for a minimal fake that
95
- * isn't a full readable.
98
+ * checking for the callable `read` / `pipe` / `on` that `node:readline`'s `createInterface`
99
+ * requires as its `input`. The non-TTY fallback narrows the resolved input stream through this
100
+ * before handing it to readline, never through an assertion, so a real piped `process.stdin` (or a
101
+ * `PassThrough` a test injects) crosses into the readline boundary honestly. Never throws; returns
102
+ * `false` for a minimal fake that isn't a full readable.
96
103
  *
97
104
  * @param value - The resolved input stream (or any value crossing the boundary)
98
105
  * @returns True if `value` has the readable methods readline needs; false otherwise
99
106
  */
100
107
  function isReadable(value) {
101
- return typeof value === "object" && value !== null && "read" in value && typeof value.read === "function" && "pipe" in value && typeof value.pipe === "function";
108
+ return isObject(value) && "read" in value && isFunction(value.read) && "pipe" in value && isFunction(value.pipe);
102
109
  }
103
110
  /**
104
- * Checks whether an input stream can be driven in RAW mode — it both reports `isTTY === true` AND
105
- * exposes a callable `setRawMode`. The {@link import('./Terminal.js').Terminal} probes this to choose its path:
106
- * `true` ⇒ the interactive raw-mode prompts (arrow-key navigation, live re-render); `false` ⇒ the
107
- * `node:readline` line-input fallback (a piped / non-terminal stream cannot enter raw mode). Total —
108
- * never throws.
111
+ * Checks whether an input stream can be driven in raw mode — it reports `isTTY === true` and
112
+ * exposes a callable `setRawMode`. The {@link import('./Terminal.js').Terminal} probes this to
113
+ * choose its path: true selects the interactive raw-mode fields, with arrow-key navigation and a
114
+ * live re-render; false selects the `node:readline` line-input fallback, because a piped or
115
+ * non-terminal stream cannot enter raw mode. Total — never throws.
109
116
  *
110
117
  * @param input - The resolved {@link InputStreamInterface}
111
118
  * @returns True if the stream is a TTY with `setRawMode`; false otherwise
112
119
  */
113
120
  function supportsRawMode(input) {
114
- return input.isTTY === true && typeof input.setRawMode === "function";
121
+ return input.isTTY === true && isFunction(input.setRawMode);
115
122
  }
116
123
  /**
117
- * Counts the terminal LINES a rendered prompt `view` occupies — one more than its newline count
118
- * (a view with no newline is a single line; N newlines span N+1 lines). The basis of the in-place
119
- * re-render: the driver records the line count of the view it just wrote so the next redraw knows how
120
- * far up to move the cursor before overwriting. Total; an empty string is one (empty) line.
124
+ * Counts the terminal lines a rendered prompt `view` occupies — one more than its newline count, so
125
+ * a view with no newline is a single line and a view with N newlines spans N+1 lines. The basis of
126
+ * the in-place re-render: the driver records the line count of the view it wrote so the next redraw
127
+ * knows how far up to move the cursor before overwriting. Total; an empty string is one empty line.
121
128
  *
122
129
  * @param view - The rendered (possibly multi-line, possibly ANSI-styled) view string
123
130
  * @returns The number of lines the view spans (always at least 1)
@@ -128,10 +135,10 @@ function lineCount(view) {
128
135
  return lines;
129
136
  }
130
137
  /**
131
- * Returns the cursor-UP control sequence that moves the cursor up `count` lines (`ESC[{count}A`) — or the
132
- * empty string when `count` is zero or negative (no movement needed, and `ESC[0A` is a wasted write).
133
- * The pure step the in-place re-render uses to climb back over the previous view before clearing it.
134
- * Total.
138
+ * Returns the cursor-up control sequence that moves the cursor up `count` lines (`ESC[{count}A`),
139
+ * or the empty string when `count` is zero or negative, because no movement is needed and `ESC[0A`
140
+ * is a wasted write. The pure step the in-place re-render uses to climb back over the previous view
141
+ * before clearing it. Total.
135
142
  *
136
143
  * @param count - How many lines to move the cursor up
137
144
  * @returns The `ESC[{count}A` sequence, or `''` when `count <= 0`
@@ -141,31 +148,31 @@ function renderCursorUp(count) {
141
148
  return `${CSI_UP.replace("{count}", String(count))}`;
142
149
  }
143
150
  /**
144
- * Returns the full reposition-and-clear prefix to write BEFORE re-rendering a prompt view in place — given
145
- * the line count of the PREVIOUS view, it moves the cursor up over those lines, returns it to column
146
- * 0, and erases everything from there to the end of the screen, so the next view is drawn on a clean
147
- * region (no orphaned rows from a taller previous view). Pure; the driver writes this immediately
148
- * followed by the new view.
151
+ * Returns the full reposition-and-clear prefix to write before re-rendering a prompt view in place
152
+ * — given the line count of the previous view, it moves the cursor up over those lines, returns it
153
+ * to column 0, and erases everything from there to the end of the screen, so the next view is drawn
154
+ * on a clean region and a taller previous view leaves no orphaned rows. Pure; the driver writes
155
+ * this immediately followed by the new view.
149
156
  *
150
157
  * @remarks
151
- * For the FIRST render `previousLines` is `1` (the cursor sits on the line the prompt opened on) so
152
- * the prefix is just a carriage return + clear-down — the prompt draws from the current line. For a
153
- * subsequent render it climbs `previousLines - 1` lines (the cursor is on the LAST line of the prior
158
+ * For the first render `previousLines` is `1` (the cursor sits on the line the prompt opened on) so
159
+ * the prefix is a carriage return + clear-down — the prompt draws from the current line. For a
160
+ * subsequent render it climbs `previousLines - 1` lines (the cursor is on the last line of the prior
154
161
  * view) before clearing. Keeping the math here (not in the driver) makes the re-render unit-testable
155
162
  * without a real terminal.
156
163
  *
157
- * @param previousLines - The line count of the view currently on screen (from {@link lineCount})
164
+ * @param previousLines - The line count of the view on screen (from {@link lineCount})
158
165
  * @returns The control-sequence prefix to write before the new view
159
166
  */
160
167
  function redrawPrefix(previousLines) {
161
168
  return `${renderCursorUp(previousLines - 1)}${RETURN}${CLEAR_DOWN}`;
162
169
  }
163
170
  /**
164
- * Projects any field the walk reads as a LINE OF TEXT into the {@link TextField} the text reducer
165
- * takes — `text` itself, and the controls a terminal has no widget for: `number`, `date`,
166
- * `time`, `datetime`, `color`, and one `file` entry. The label carries that control's format cue
167
- * from {@link CONTROL_HINTS}, and a declared `default` becomes the line a bare return submits. The
168
- * projection carries no rule, because the AUTHORITATIVE form still evaluates the answer this line
171
+ * Projects any field the walk reads as a line of text into the {@link TextField} the text reducer
172
+ * takes — `text` itself, and the controls a terminal has no widget for: `number`, `date`, `time`,
173
+ * `datetime`, `color`, and one `file` entry. The label carries that control's format cue from
174
+ * {@link CONTROL_HINTS}, and a declared `default` becomes the line a bare return submits. The
175
+ * projection carries no rule, because the authoritative form still evaluates the answer this line
169
176
  * binds; it exists only so one reducer covers every one of them.
170
177
  *
171
178
  * @param field - The field being read
@@ -181,12 +188,12 @@ function fieldToText(field) {
181
188
  const label = field.label ?? field.name;
182
189
  const hint = CONTROL_HINTS[field.control];
183
190
  const seed = "default" in field ? field.default : void 0;
184
- const text = typeof seed === "number" ? String(seed) : seed;
191
+ const text = isNumber(seed) ? String(seed) : seed;
185
192
  return {
186
193
  control: "text",
187
194
  name: field.name,
188
195
  label: hint === void 0 ? label : `${label} ${hint}`,
189
- ...typeof text === "string" ? { default: text } : {}
196
+ ...isString(text) ? { default: text } : {}
190
197
  };
191
198
  }
192
199
  /**
@@ -199,13 +206,13 @@ function fieldToText(field) {
199
206
  */
200
207
  function valueToText(value) {
201
208
  if (value === void 0) return "";
202
- if (typeof value === "string") return value;
203
- if (typeof value === "number") return String(value);
204
- if (typeof value === "boolean") return value ? "yes" : "no";
209
+ if (isString(value)) return value;
210
+ if (isNumber(value)) return String(value);
211
+ if (isBoolean(value)) return value ? "yes" : "no";
205
212
  return value.join(", ");
206
213
  }
207
214
  /**
208
- * Returns the choices a `select` or `checkbox` field actually OFFERS — the form refuses a disabled
215
+ * Returns the choices a `select` or `checkbox` field actually offers — the form refuses a disabled
209
216
  * choice's value at every door, including a fill, so the walk never puts one in front of the
210
217
  * cursor. Pair with {@link filterDisabled} to tell the reader what was withheld.
211
218
  *
@@ -216,7 +223,7 @@ function filterEnabled(choices) {
216
223
  return choices.filter((choice) => choice.disabled !== true);
217
224
  }
218
225
  /**
219
- * Returns the choices a `select` or `checkbox` field SHOWS but refuses — the complement of
226
+ * Returns the choices a `select` or `checkbox` field shows but refuses — the complement of
220
227
  * {@link filterEnabled}, rendered by {@link renderUnavailableLine} above the list so a reader sees
221
228
  * why a declared choice is missing from it.
222
229
  *
@@ -239,9 +246,9 @@ function renderGroupHeader(styler, theme, label) {
239
246
  return styler.render(theme.roles.message, label);
240
247
  }
241
248
  /**
242
- * Renders the read-only line a LOCKED field shows — its label, the {@link LOCKED_MARK}, and the
243
- * answer the form already holds. The walk writes this instead of a prompt, because the field is still
244
- * validated and still submitted but must not be edited here.
249
+ * Renders the read-only line a locked field shows — its label, the {@link LOCKED_MARK}, and the
250
+ * answer the form already holds. The walk writes this instead of a prompt, because the field is
251
+ * still validated and still submitted but must not be edited here.
245
252
  *
246
253
  * @param styler - The console styler that renders each role
247
254
  * @param theme - The resolved prompt theme
@@ -254,7 +261,7 @@ function renderLockedLine(styler, theme, label, value) {
254
261
  return value.length === 0 ? head : `${head} ${styler.render(theme.roles.content, value)}`;
255
262
  }
256
263
  /**
257
- * Renders the line listing an OPEN select's offered values above its text prompt — a suggestion
264
+ * Renders the line listing an open select's offered values above its text prompt — a suggestion
258
265
  * list, because an open select admits an answer the list does not offer.
259
266
  *
260
267
  * @param styler - The console styler that renders each role
@@ -295,12 +302,13 @@ function renderNumberedList(styler, theme, choices) {
295
302
  //#endregion
296
303
  //#region src/server/Terminal.ts
297
304
  /**
298
- * Implements {@link TerminalInterface} for a human at this machine's keyboard — the interactive form
299
- * DRIVER, and the only impure part of the terminal stack. {@link ask} walks one
300
- * form's fields in schema order, feeds raw-mode stdin bytes through `parseKey` into the matching
301
- * pure reducer, renders each returned view in place, and binds every answer through the form's own
302
- * `fill`. It owns no form logic: the schema, the rules, the values, and the settlement all belong to
303
- * the form it is given, and this class owns only raw mode, the cursor, and the re-render.
305
+ * Implements {@link TerminalInterface} for a human at this machine's keyboard — the interactive
306
+ * form driver, and the only impure part of the terminal stack. {@link ask} walks one form's fields
307
+ * in schema order, feeds raw-mode stdin bytes through `parseKey` into the matching pure reducer,
308
+ * renders each returned view in place, binds every answer through the form's own `fill`, and
309
+ * re-asks what the form refused. It owns no form logic: the schema, the rules, the values, and the
310
+ * settlement all belong to the form it is given, and this class owns only raw mode, the cursor, and
311
+ * the re-render.
304
312
  *
305
313
  * @remarks
306
314
  * See {@link TerminalInterface} for the driving contract. The walk itself:
@@ -312,12 +320,11 @@ function renderNumberedList(styler, theme, choices) {
312
320
  * `open` means the answer need not come from the list.
313
321
  * - **The binding projects through `matchesAnswer`.** Every answer is filled as
314
322
  * `fill(name, matchesAnswer(value) ? value : undefined)` after `parseValue` has coerced it to the
315
- * control's own shape, so a bare return on a field with no default binds as ABSENCE and the
323
+ * control's own shape, so a bare return on a field with no default binds as absence and the
316
324
  * form's `required` rule refuses it. A typed answer the control cannot hold binds as absence and
317
325
  * invalidates the field, so the walk asks again with the reason on screen.
318
- * - **Visibility is honored.** A `hidden` field and a field currently in `form.disabled` are
319
- * skipped; a `locked` field renders read-only; entering a new group writes its label as a section
320
- * header.
326
+ * - **Visibility is honored.** A `hidden` field and a field in `form.disabled` are skipped; a
327
+ * `locked` field renders read-only; entering a new group writes its label as a section header.
321
328
  * - **Refusal re-asks.** After the walk the form is submitted. A refusal re-walks only the erroring
322
329
  * fields the walk can edit and submits again. When every erroring field is one the walk cannot
323
330
  * edit — hidden, locked, or disabled — the form is abandoned instead, because asking again could
@@ -745,12 +752,12 @@ var Terminal = class {
745
752
  //#endregion
746
753
  //#region src/server/factories.ts
747
754
  /**
748
- * Creates the interactive terminal form driver — the local-keyboard arm of the terminal trio, beside
749
- * the core headless `createPrompt` broker and the SSE `createPromptClient` bridge. Where the broker
750
- * PARKS a live form until somebody elsewhere answers it, a `Terminal` answers one HERE: it walks the
751
- * form's fields in schema order, drives each control's pure reducer over raw-mode stdin, binds every
752
- * answer through the form's own `fill`, and submits. It is the only impure part of the terminal
753
- * stack.
755
+ * Creates the interactive terminal form driver — the local-keyboard arm of the terminal trio,
756
+ * beside the core headless `createPrompt` broker and the SSE `createPromptClient` bridge. Where the
757
+ * broker parks a live form until somebody elsewhere answers it, a `Terminal` answers one here: it
758
+ * walks the form's fields in schema order, drives each control's pure reducer over raw-mode stdin,
759
+ * binds every answer through the form's own `fill`, and submits. It is the only impure part of the
760
+ * terminal stack.
754
761
  *
755
762
  * @param options - See {@link TerminalOptions}
756
763
  * @returns A {@link TerminalInterface} whose `ask` drives one whole form over the resolved streams
@@ -770,21 +777,39 @@ var Terminal = class {
770
777
  * - **Non-TTY fallback.** A piped stream cannot enter raw mode, so the same walk runs over
771
778
  * `node:readline` line input.
772
779
  *
773
- * @example
780
+ * @example Ask one form at this keyboard
774
781
  * ```ts
775
782
  * import { createForm } from '@orkestrel/form'
783
+ * import { isTerminalError } from '@orkestrel/terminal'
776
784
  * import { createTerminal } from '@orkestrel/terminal/server'
777
785
  *
778
- * const terminal = createTerminal()
779
- * const values = await terminal.ask(
780
- * createForm({
781
- * label: 'Sign up',
782
- * fields: [
783
- * { control: 'text', name: 'email', label: 'Email', rule: { required: true, email: true } },
784
- * { control: 'confirm', name: 'terms', label: 'I accept the terms' },
785
- * ],
786
- * }),
787
- * )
786
+ * const terminal = createTerminal() // process.stdin / process.stdout by default
787
+ * const form = createForm({
788
+ * label: 'Sign up',
789
+ * fields: [
790
+ * { control: 'text', name: 'name', label: 'Your name', rule: { required: true, minimum: 2 } },
791
+ * { control: 'text', name: 'email', label: 'Email', rule: { required: true, email: true } },
792
+ * { control: 'password', name: 'token', label: 'Token' },
793
+ * { control: 'confirm', name: 'terms', label: 'Accept the terms', rule: { required: true } },
794
+ * {
795
+ * control: 'select',
796
+ * name: 'role',
797
+ * label: 'Role',
798
+ * choices: [
799
+ * { value: 'admin', label: 'Admin' },
800
+ * { value: 'viewer', label: 'Viewer', help: 'read-only' },
801
+ * ],
802
+ * },
803
+ * ],
804
+ * })
805
+ *
806
+ * try {
807
+ * const values = await terminal.ask(form)
808
+ * deploy(values)
809
+ * } catch (error) {
810
+ * // Ctrl-c: the walk ended, and the form is still `editing` for whoever owns it.
811
+ * if (isTerminalError(error) && error.code === 'CANCEL') form.destroy()
812
+ * }
788
813
  * ```
789
814
  */
790
815
  function createTerminal(options) {