@orkestrel/terminal 0.0.13 → 0.0.15

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,51 +1,44 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _orkestrel_console = require("@orkestrel/console");
2
3
  let _src_core = require("../core/index.cjs");
3
4
  let _orkestrel_form = require("@orkestrel/form");
4
- let _orkestrel_console = require("@orkestrel/console");
5
+ let _orkestrel_console_server = require("@orkestrel/console/server");
5
6
  let node_readline = require("node:readline");
6
7
  let node_process = require("node:process");
7
8
  //#region src/server/constants.ts
8
- /** The Escape byte (ESC, U+001B) — the lead byte of every CSI cursor-control sequence below. */
9
- var ESCAPE = String.fromCharCode(27);
10
- /** The Control Sequence Introducer (`ESC[`) — the prefix of every cursor / erase sequence. */
11
- var CSI = `[`;
12
9
  /**
13
- * The cursor-UP sequence TEMPLATE (`ESC[{count}A`) — {@link import('./helpers.js').moveUp}
14
- * interpolates the `{count}` placeholder with the number of lines to climb. Kept as a template so
15
- * 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.
16
13
  */
17
- var CSI_UP = `${CSI}{count}A`;
14
+ var CSI_UP = `${_orkestrel_console.CSI}{count}A`;
18
15
  /**
19
- * Hide the cursor (`ESC[?25l`) — written before the driver starts redrawing a prompt so the cursor
16
+ * Hides the cursor (`ESC[?25l`) — written before the driver starts redrawing a prompt so the cursor
20
17
  * does not flicker across the view during an in-place re-render; paired with {@link CURSOR_SHOW}.
21
18
  */
22
- var CURSOR_HIDE = `${CSI}?25l`;
23
- /** Show the cursor (`ESC[?25h`) — restores the cursor after a prompt resolves / cancels (the {@link CURSOR_HIDE} pair). */
24
- var CURSOR_SHOW = `${CSI}?25h`;
19
+ var CURSOR_HIDE = `${_orkestrel_console.CSI}?25l`;
20
+ /** Shows the cursor (`ESC[?25h`) — restores the cursor after a prompt resolves / cancels (the {@link CURSOR_HIDE} pair). */
21
+ var CURSOR_SHOW = `${_orkestrel_console.CSI}?25h`;
25
22
  /**
26
- * Erase from the cursor down to the end of the screen (`ESC[J`) — wipes the WHOLE previous (possibly
27
- * multi-line `select` / `checkbox`) view in one write before the new view is rendered, so a redraw
28
- * 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.
29
26
  */
30
- var CLEAR_DOWN = `${CSI}J`;
31
- /** A carriage return (`\r`, U+000D) — returns the cursor to column 0 so a redraw starts at the line's left edge. */
32
- var CARRIAGE_RETURN = String.fromCharCode(13);
33
- /** A line feed (`\n`, U+000A) — the line terminator the driver writes after the final committed prompt view. */
34
- var LINE_FEED = String.fromCharCode(10);
27
+ var CLEAR_DOWN = `${_orkestrel_console.CSI}J`;
35
28
  /**
36
- * The numbered-list prompt the non-TTY {@link import('./Terminal.js').Terminal} `select` fallback
29
+ * Holds the numbered-list prompt the non-TTY {@link import('./Terminal.js').Terminal} `select` fallback
37
30
  * appends — a piped (non-terminal) stream cannot navigate with arrow keys, so the choices are
38
31
  * printed numbered and the user types one number on a single readline line.
39
32
  */
40
33
  var FALLBACK_SELECT_HINT = "Enter a number";
41
- /** The comma-separated multi-select hint the non-TTY `checkbox` fallback shows (the user types one or more numbers). */
34
+ /** Holds the comma-separated multi-select hint the non-TTY `checkbox` fallback shows (the user types one or more numbers). */
42
35
  var FALLBACK_CHECKBOX_HINT = "Enter numbers separated by commas";
43
- /** The hint the non-TTY `editor` fallback shows — a piped stream has no ctrl-d, so end of input finishes the block. */
36
+ /** Holds the hint the non-TTY `editor` fallback shows — a piped stream has no ctrl-d, so end of input finishes the block. */
44
37
  var FALLBACK_EDITOR_HINT = "(end of input finishes)";
45
- /** The hint the non-TTY `confirm` fallback shows — a piped stream sends a whole line, so the answer is typed rather than pressed. */
38
+ /** Holds the hint the non-TTY `confirm` fallback shows — a piped stream sends a whole line, so the answer is typed rather than pressed. */
46
39
  var FALLBACK_CONFIRM_HINT = "(y/n)";
47
40
  /**
48
- * The format cue appended to a field's label for each control the walk reads as a line of text —
41
+ * Holds the format cue appended to a field's label for each control the walk reads as a line of text —
49
42
  * the terminal has no date picker, no color well, and no file chooser, so the accepted shape is
50
43
  * stated instead. A control with no entry needs none: `text` and `editor` accept any line,
51
44
  * `password` masks one, and `confirm`, `select`, and `checkbox` are answered by key rather than by
@@ -59,16 +52,22 @@ var CONTROL_HINTS = Object.freeze({
59
52
  color: "(#rrggbb)",
60
53
  file: "(path)"
61
54
  });
62
- /** The instruction a `file` field with `multiple` shows before its entries — one path per line, and a blank line ends the list. */
55
+ /** Holds the instruction a `file` field with `multiple` shows before its entries — one path per line, and a blank line ends the list. */
63
56
  var FILE_HINT = "One path per line, blank to finish";
64
- /** 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
+ */
65
61
  var SUGGESTION_LEAD = "Suggestions";
66
- /** 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
+ */
67
66
  var UNAVAILABLE_LEAD = "Unavailable";
68
- /** The mark on a locked field's line — the walk renders its value and moves on, because the form refuses an edit there. */
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. */
69
68
  var LOCKED_MARK = "(locked)";
70
69
  /**
71
- * What a field is told when the walk read an answer the control cannot hold — a word typed into a
70
+ * States what a field is told when the walk read an answer the control cannot hold — a word typed into a
72
71
  * `number`, an off-list value typed into an open `select` whose choice is refused. The value binds
73
72
  * as absence and this message is invalidated onto the field, so the walk re-asks it with the reason
74
73
  * on screen.
@@ -77,67 +76,55 @@ var REFUSAL_MESSAGE = "Enter a value this field accepts";
77
76
  //#endregion
78
77
  //#region src/server/helpers.ts
79
78
  /**
80
- * Whether `value` is a usable {@link InputStreamInterface} — a record with callable `on` / `off`
81
- * `'data'` subscription methods. A total type guard (AGENTS §14): it NEVER throws and returns `false`
82
- * for anything off-shape, so it narrows the one unavoidable input boundary (the real `process.stdin`,
83
- * 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.
84
83
  *
85
84
  * @remarks
86
85
  * Only `on` / `off` are required (the irreducible event seam); `setRawMode` / `resume` / `pause` /
87
86
  * `isTTY` are optional on {@link InputStreamInterface}, so their absence does not disqualify a stream
88
- * — 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
89
88
  * fallback rather than raw mode.
90
89
  *
91
90
  * @param value - Any value crossing the boundary (a process stream, an injected fake, `unknown`)
92
- * @returns `true` when `value` has callable `on` and `off`
91
+ * @returns True if `value` has callable `on` and `off`; false otherwise
93
92
  */
94
93
  function isInputStream(value) {
95
94
  return typeof value === "object" && value !== null && "on" in value && typeof value.on === "function" && "off" in value && typeof value.off === "function";
96
95
  }
97
96
  /**
98
- * Whether `value` is a usable {@link OutputStreamInterface} — a record with a callable `write`. A
99
- * total type guard (AGENTS §14): it NEVER throws and returns `false` for anything off-shape, so it
100
- * narrows the output boundary (the real `process.stdout`, or a recording fake a test injects) to the
101
- * one method the driver writes through — no `as`.
102
- *
103
- * @param value - Any value crossing the boundary (a process stream, an injected fake, `unknown`)
104
- * @returns `true` when `value` has a callable `write`
105
- */
106
- function isOutputStream(value) {
107
- return typeof value === "object" && value !== null && "write" in value && typeof value.write === "function";
108
- }
109
- /**
110
- * Whether `value` is a Node {@link NodeJS.ReadableStream} — a total structural guard (AGENTS §14)
111
- * checking for the callable `read` / `pipe` / `on` that `node:readline`'s `createInterface` requires
112
- * as its `input`. The non-TTY fallback narrows the resolved input stream through this before handing
113
- * it to readline (never an `as`), so a real piped `process.stdin` (or a `PassThrough` a test injects)
114
- * crosses into the readline boundary honestly. Never throws; returns `false` for a minimal fake that
115
- * isn't a full readable.
97
+ * Checks whether `value` is a Node {@link NodeJS.ReadableStream} — a total structural guard
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.
116
103
  *
117
104
  * @param value - The resolved input stream (or any value crossing the boundary)
118
- * @returns `true` when `value` has the readable methods readline needs
105
+ * @returns True if `value` has the readable methods readline needs; false otherwise
119
106
  */
120
107
  function isReadable(value) {
121
108
  return typeof value === "object" && value !== null && "read" in value && typeof value.read === "function" && "pipe" in value && typeof value.pipe === "function";
122
109
  }
123
110
  /**
124
- * Whether an input stream can be driven in RAW mode — it both reports `isTTY === true` AND exposes a
125
- * callable `setRawMode`. The {@link import('./Terminal.js').Terminal} probes this to choose its path:
126
- * `true` ⇒ the interactive raw-mode prompts (arrow-key navigation, live re-render); `false` ⇒ the
127
- * `node:readline` line-input fallback (a piped / non-terminal stream cannot enter raw mode). Total —
128
- * 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.
129
116
  *
130
117
  * @param input - The resolved {@link InputStreamInterface}
131
- * @returns `true` when the stream is a TTY with `setRawMode`
118
+ * @returns True if the stream is a TTY with `setRawMode`; false otherwise
132
119
  */
133
- function rawCapable(input) {
120
+ function supportsRawMode(input) {
134
121
  return input.isTTY === true && typeof input.setRawMode === "function";
135
122
  }
136
123
  /**
137
- * The number of terminal LINES a rendered prompt `view` occupies — one more than its newline count
138
- * (a view with no newline is a single line; N newlines span N+1 lines). The basis of the in-place
139
- * re-render: the driver records the line count of the view it just wrote so the next redraw knows how
140
- * 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.
141
128
  *
142
129
  * @param view - The rendered (possibly multi-line, possibly ANSI-styled) view string
143
130
  * @returns The number of lines the view spans (always at least 1)
@@ -148,45 +135,45 @@ function lineCount(view) {
148
135
  return lines;
149
136
  }
150
137
  /**
151
- * The cursor-UP control sequence that moves the cursor up `count` lines (`ESC[{count}A`) — or the
152
- * empty string when `count` is zero or negative (no movement needed, and `ESC[0A` is a wasted write).
153
- * The pure step the in-place re-render uses to climb back over the previous view before clearing it.
154
- * 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.
155
142
  *
156
143
  * @param count - How many lines to move the cursor up
157
144
  * @returns The `ESC[{count}A` sequence, or `''` when `count <= 0`
158
145
  */
159
- function moveUp(count) {
146
+ function renderCursorUp(count) {
160
147
  if (count <= 0) return "";
161
148
  return `${CSI_UP.replace("{count}", String(count))}`;
162
149
  }
163
150
  /**
164
- * The full reposition-and-clear prefix to write BEFORE re-rendering a prompt view in place — given
165
- * the line count of the PREVIOUS view, it moves the cursor up over those lines, returns it to column
166
- * 0, and erases everything from there to the end of the screen, so the next view is drawn on a clean
167
- * region (no orphaned rows from a taller previous view). Pure; the driver writes this immediately
168
- * 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.
169
156
  *
170
157
  * @remarks
171
- * For the FIRST render `previousLines` is `1` (the cursor sits on the line the prompt opened on) so
172
- * the prefix is just a carriage return + clear-down — the prompt draws from the current line. For a
173
- * 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
174
161
  * view) before clearing. Keeping the math here (not in the driver) makes the re-render unit-testable
175
162
  * without a real terminal.
176
163
  *
177
- * @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})
178
165
  * @returns The control-sequence prefix to write before the new view
179
166
  */
180
167
  function redrawPrefix(previousLines) {
181
- return `${moveUp(previousLines - 1)}\r${CLEAR_DOWN}`;
168
+ return `${renderCursorUp(previousLines - 1)}${_src_core.RETURN}${CLEAR_DOWN}`;
182
169
  }
183
170
  /**
184
- * Project any field the walk reads as a LINE OF TEXT into the {@link TextField} the text reducer
185
- * takes — `text` itself, and the six controls a terminal has no widget for: `number`, `date`,
186
- * `time`, `datetime`, `color`, and one `file` entry. The label carries that control's format cue
187
- * from {@link CONTROL_HINTS}, and a declared `default` becomes the line a bare return submits. The
188
- * projection carries no rule, because the AUTHORITATIVE form still evaluates the answer this line
189
- * binds; it exists only so one reducer can render seven controls.
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
176
+ * binds; it exists only so one reducer covers every one of them.
190
177
  *
191
178
  * @param field - The field being read
192
179
  * @returns The text field the reducer renders for it
@@ -210,7 +197,7 @@ function fieldToText(field) {
210
197
  };
211
198
  }
212
199
  /**
213
- * Project one held answer into the text a read-only line shows — a scalar as itself, a boolean as
200
+ * Projects one held answer into the text a read-only line shows — a scalar as itself, a boolean as
214
201
  * `yes` / `no` (the word the confirm reducer commits), and a list joined by commas. Absence renders
215
202
  * as nothing, because a locked field nobody has answered has nothing to show.
216
203
  *
@@ -225,35 +212,43 @@ function valueToText(value) {
225
212
  return value.join(", ");
226
213
  }
227
214
  /**
228
- * The choices a `select` or `checkbox` field actually OFFERS — the form refuses a disabled choice's
229
- * value at every door, including a fill, so the walk never puts one in front of the cursor. Pair
230
- * with {@link disabledChoices} to tell the reader what was withheld.
215
+ * Returns the choices a `select` or `checkbox` field actually offers — the form refuses a disabled
216
+ * choice's value at every door, including a fill, so the walk never puts one in front of the
217
+ * cursor. Pair with {@link filterDisabled} to tell the reader what was withheld.
231
218
  *
232
219
  * @param choices - The field's declared choices
233
220
  * @returns The choices the walk offers, in declared order
234
221
  */
235
- function enabledChoices(choices) {
222
+ function filterEnabled(choices) {
236
223
  return choices.filter((choice) => choice.disabled !== true);
237
224
  }
238
225
  /**
239
- * The choices a `select` or `checkbox` field SHOWS but refuses — the complement of
240
- * {@link enabledChoices}, rendered by {@link unavailableLine} above the list so a reader sees why a
241
- * declared choice is missing from it.
226
+ * Returns the choices a `select` or `checkbox` field shows but refuses — the complement of
227
+ * {@link filterEnabled}, rendered by {@link renderUnavailableLine} above the list so a reader sees
228
+ * why a declared choice is missing from it.
242
229
  *
243
230
  * @param choices - The field's declared choices
244
231
  * @returns The refused choices, in declared order
245
232
  */
246
- function disabledChoices(choices) {
233
+ function filterDisabled(choices) {
247
234
  return choices.filter((choice) => choice.disabled === true);
248
235
  }
249
- /** The section header the walk writes when it enters a new field group, painted by the `message` role. */
250
- function groupHeader(styler, theme, label) {
236
+ /**
237
+ * Renders the section header the walk writes when it enters a new field group, painted by the
238
+ * `message` role.
239
+ *
240
+ * @param styler - The console styler that renders each role
241
+ * @param theme - The resolved prompt theme
242
+ * @param label - The group's declared label, falling back to its own name
243
+ * @returns The rendered section header
244
+ */
245
+ function renderGroupHeader(styler, theme, label) {
251
246
  return styler.render(theme.roles.message, label);
252
247
  }
253
248
  /**
254
- * The read-only line a LOCKED field renders — its label, the {@link LOCKED_MARK}, and the answer the
255
- * form already holds. The walk writes this instead of a prompt, because the field is still
256
- * 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.
257
252
  *
258
253
  * @param styler - The console styler that renders each role
259
254
  * @param theme - The resolved prompt theme
@@ -261,59 +256,75 @@ function groupHeader(styler, theme, label) {
261
256
  * @param value - The held answer, from {@link valueToText}
262
257
  * @returns The rendered line, with no trailing space when there is nothing to show
263
258
  */
264
- function lockedLine(styler, theme, label, value) {
259
+ function renderLockedLine(styler, theme, label, value) {
265
260
  const head = `${styler.render(theme.roles.muted, theme.icons.dot)} ${styler.render(theme.roles.message, label)} ${styler.render(theme.roles.hint, LOCKED_MARK)}`;
266
261
  return value.length === 0 ? head : `${head} ${styler.render(theme.roles.content, value)}`;
267
262
  }
268
- /** The line listing an OPEN select's offered values above its text prompt — a suggestion list, because an open select admits an answer the list does not offer. */
269
- function suggestionLine(styler, theme, choices) {
263
+ /**
264
+ * Renders the line listing an open select's offered values above its text prompt — a suggestion
265
+ * list, because an open select admits an answer the list does not offer.
266
+ *
267
+ * @param styler - The console styler that renders each role
268
+ * @param theme - The resolved prompt theme
269
+ * @param choices - The choices the open select offers, from {@link filterEnabled}
270
+ * @returns The rendered suggestion line
271
+ */
272
+ function renderSuggestionLine(styler, theme, choices) {
270
273
  const values = choices.map((choice) => choice.value).join(", ");
271
274
  return styler.render(theme.roles.hint, `${SUGGESTION_LEAD}: ${values}`);
272
275
  }
273
- /** The line naming the choices a field shows but refuses, written above the list the walk drives. */
274
- function unavailableLine(styler, theme, choices) {
276
+ /**
277
+ * Renders the line naming the choices a field shows but refuses, written above the list the walk
278
+ * drives.
279
+ *
280
+ * @param styler - The console styler that renders each role
281
+ * @param theme - The resolved prompt theme
282
+ * @param choices - The refused choices, from {@link filterDisabled}
283
+ * @returns The rendered unavailable line
284
+ */
285
+ function renderUnavailableLine(styler, theme, choices) {
275
286
  const labels = choices.map((choice) => choice.label).join(", ");
276
287
  return styler.render(theme.roles.muted, `${UNAVAILABLE_LEAD}: ${labels}`);
277
288
  }
278
289
  /**
279
- * The numbered choice list the non-TTY fallback prints — a piped stream cannot navigate with arrow
280
- * keys, so each offered choice is printed with the number the reader types back. One line per
281
- * choice, with no trailing newline.
290
+ * Renders the numbered choice list the non-TTY fallback prints — a piped stream cannot navigate
291
+ * with arrow keys, so each offered choice is printed with the number the reader types back. One
292
+ * line per choice, with no trailing newline.
282
293
  *
283
294
  * @param styler - The console styler that renders each role
284
295
  * @param theme - The resolved prompt theme
285
- * @param choices - The choices the walk offers, from {@link enabledChoices}
296
+ * @param choices - The choices the walk offers, from {@link filterEnabled}
286
297
  * @returns The rendered list
287
298
  */
288
- function numberedList(styler, theme, choices) {
299
+ function renderNumberedList(styler, theme, choices) {
289
300
  return choices.map((choice, index) => ` ${styler.render(theme.roles.muted, `${String(index + 1)})`)} ${styler.render(theme.roles.content, choice.label)}`).join("\n");
290
301
  }
291
302
  //#endregion
292
303
  //#region src/server/Terminal.ts
293
304
  /**
294
- * The interactive terminal form DRIVER — the {@link TerminalInterface} implementation for a human at
295
- * this machine's keyboard, and the only impure part of the terminal stack. {@link ask} walks one
296
- * form's fields in schema order, feeds raw-mode stdin bytes through `parseKey` into the matching
297
- * pure reducer, renders each returned view in place, and binds every answer through the form's own
298
- * `fill`. It owns no form logic: the schema, the rules, the values, and the settlement all belong to
299
- * 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.
300
312
  *
301
313
  * @remarks
302
314
  * See {@link TerminalInterface} for the driving contract. The walk itself:
303
315
  *
304
- * - **Twelve controls, seven reducers.** `text`, `number`, `date`, `time`, `datetime`, `color`, and
316
+ * - **Controls and reducers.** `text`, `number`, `date`, `time`, `datetime`, `color`, and
305
317
  * each `file` entry are read as one line of text through {@link fieldToText}, which appends that
306
318
  * control's format cue to the label. `password`, `confirm`, `editor`, `select`, and `checkbox`
307
319
  * each drive their own reducer. An open `select` is a suggestion list plus a typed line, because
308
320
  * `open` means the answer need not come from the list.
309
321
  * - **The binding projects through `matchesAnswer`.** Every answer is filled as
310
322
  * `fill(name, matchesAnswer(value) ? value : undefined)` after `parseValue` has coerced it to the
311
- * 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
312
324
  * form's `required` rule refuses it. A typed answer the control cannot hold binds as absence and
313
325
  * invalidates the field, so the walk asks again with the reason on screen.
314
- * - **Visibility is honored.** A `hidden` field and a field currently in `form.disabled` are
315
- * skipped; a `locked` field renders read-only; entering a new group writes its label as a section
316
- * 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.
317
328
  * - **Refusal re-asks.** After the walk the form is submitted. A refusal re-walks only the erroring
318
329
  * fields the walk can edit and submits again. When every erroring field is one the walk cannot
319
330
  * edit — hidden, locked, or disabled — the form is abandoned instead, because asking again could
@@ -335,7 +346,7 @@ var Terminal = class {
335
346
  #ended = false;
336
347
  constructor(options) {
337
348
  this.#input = isInputStream(options?.input) ? options.input : node_process.stdin;
338
- this.#output = isOutputStream(options?.output) ? options.output : node_process.stdout;
349
+ this.#output = (0, _orkestrel_console_server.isStreamTarget)(options?.output) ? options.output : node_process.stdout;
339
350
  this.#styler = (0, _orkestrel_console.createStyler)();
340
351
  this.#theme = (0, _src_core.createPromptTheme)(options?.theme);
341
352
  }
@@ -348,7 +359,7 @@ var Terminal = class {
348
359
  const result = form.submit();
349
360
  if (result.success) return await form.answer;
350
361
  this.#report(form, result.error);
351
- const again = this.#editable(form, result.error);
362
+ const again = this.#collectEditable(form, result.error);
352
363
  if (again.length === 0 || this.#ended) {
353
364
  form.destroy();
354
365
  return await form.answer;
@@ -360,7 +371,7 @@ var Terminal = class {
360
371
  }
361
372
  }
362
373
  /**
363
- * Ask `fields` in the order given, skipping what the walk must not touch and rendering what it
374
+ * Asks `fields` in the order given, skipping what the walk must not touch and rendering what it
364
375
  * must not edit. Returns early the moment the form stops being editable, so an abandoned form
365
376
  * ends the walk between fields as well as under an active read.
366
377
  */
@@ -372,10 +383,10 @@ var Terminal = class {
372
383
  if (form.disabled.has(field.name)) continue;
373
384
  if (field.group !== group) {
374
385
  group = field.group;
375
- if (group !== void 0) this.#group(form, group);
386
+ if (group !== void 0) this.#writeGroup(form, group);
376
387
  }
377
388
  if (field.locked === true) {
378
- this.#locked(form, field);
389
+ this.#writeLocked(form, field);
379
390
  continue;
380
391
  }
381
392
  const raw = await this.#read(form, field);
@@ -383,31 +394,28 @@ var Terminal = class {
383
394
  this.#bind(form, field, raw);
384
395
  }
385
396
  }
386
- /** Write a group's label as a section header, resolving the schema's declared label and falling back to the group's own name. */
387
- #group(form, name) {
397
+ /** Writes a group's label as a section header, resolving the schema's declared label and falling back to the group's own name. */
398
+ #writeGroup(form, name) {
388
399
  const label = form.schema.groups?.find((group) => group.name === name)?.label ?? name;
389
- this.#output.write(`
390
- ${groupHeader(this.#styler, this.#theme, (0, _src_core.sanitizeDisplayText)(label))}
391
- `);
400
+ this.#output.write(`${_src_core.NEWLINE}${renderGroupHeader(this.#styler, this.#theme, (0, _src_core.sanitizeDisplayText)(label))}${_src_core.NEWLINE}`);
392
401
  }
393
- /** Render a locked field read-only — its label, its mark, and the answer the form already holds. */
394
- #locked(form, field) {
395
- const line = lockedLine(this.#styler, this.#theme, (0, _src_core.sanitizeDisplayText)(field.label ?? field.name), (0, _src_core.sanitizeDisplayText)(valueToText(form.values[field.name])));
396
- this.#output.write(`${line}
397
- `);
402
+ /** Renders a locked field read-only — its label, its mark, and the answer the form already holds. */
403
+ #writeLocked(form, field) {
404
+ const line = renderLockedLine(this.#styler, this.#theme, (0, _src_core.sanitizeDisplayText)(field.label ?? field.name), (0, _src_core.sanitizeDisplayText)(valueToText(form.values[field.name])));
405
+ this.#output.write(`${line}${_src_core.NEWLINE}`);
398
406
  }
399
- /** Read one field through the reducer its control names, resolving the raw answer the binding projects. */
407
+ /** Reads one field through the reducer its control names, resolving the raw answer the binding projects. */
400
408
  #read(form, field) {
401
- if (field.control === "password") return this.#password(form, field);
409
+ if (field.control === "password") return this.#askPassword(form, field);
402
410
  if (field.control === "confirm") return this.#confirm(form, field);
403
- if (field.control === "editor") return this.#editor(form, field);
411
+ if (field.control === "editor") return this.#askEditor(form, field);
404
412
  if (field.control === "select") return this.#select(form, field);
405
413
  if (field.control === "checkbox") return this.#checkbox(form, field);
406
414
  if (field.control === "file") return this.#file(form, field);
407
- return this.#text(form, field);
415
+ return this.#askText(form, field);
408
416
  }
409
417
  /**
410
- * Bind one raw answer to the form — the single binding, and the only place this driver writes a
418
+ * Binds one raw answer to the form — the single binding, and the only place this driver writes a
411
419
  * value. `parseValue` coerces the raw answer to the control's own shape and `matchesAnswer`
412
420
  * projects a blank one to absence, which is what keeps `required` refusing a bare return. A raw
413
421
  * answer the control cannot hold binds as absence and invalidates the field, so the field comes
@@ -419,8 +427,8 @@ ${groupHeader(this.#styler, this.#theme, (0, _src_core.sanitizeDisplayText)(labe
419
427
  form.fill(field.name, (0, _orkestrel_form.matchesAnswer)(value) ? value : void 0);
420
428
  if (value === void 0 && (0, _orkestrel_form.matchesAnswer)(raw)) form.invalidate(field.name, REFUSAL_MESSAGE);
421
429
  }
422
- /** The erroring fields the walk can ask again — every field the walk skips or renders read-only is excluded, because a second pass could not change its answer. */
423
- #editable(form, errors) {
430
+ /** Collects the erroring fields the walk can ask again — every field the walk skips or renders read-only is excluded, because a second pass could not change its answer. */
431
+ #collectEditable(form, errors) {
424
432
  const fields = [];
425
433
  for (const error of errors) {
426
434
  const field = form.field(error.field);
@@ -432,37 +440,36 @@ ${groupHeader(this.#styler, this.#theme, (0, _src_core.sanitizeDisplayText)(labe
432
440
  }
433
441
  return fields;
434
442
  }
435
- /** Write every supplied failure against its field's label. */
443
+ /** Writes every supplied failure against its field's label. */
436
444
  #report(form, errors) {
437
445
  for (const error of errors) {
438
446
  const label = (0, _src_core.sanitizeDisplayText)(form.field(error.field)?.label ?? error.field);
439
447
  const message = (0, _src_core.sanitizeDisplayText)(error.message);
440
- const line = (0, _src_core.errorLine)(this.#styler, this.#theme, `${label}: ${message}`);
441
- this.#output.write(`${line}
442
- `);
448
+ const line = (0, _src_core.renderErrorLine)(this.#styler, this.#theme, `${label}: ${message}`);
449
+ this.#output.write(`${line}${_src_core.NEWLINE}`);
443
450
  }
444
451
  }
445
- /** Read a field as one line of text — `text` itself and the six controls a terminal has no widget for. */
446
- #text(form, field) {
452
+ /** Reads a field as one line of text — `text` itself and the controls a terminal has no widget for. */
453
+ #askText(form, field) {
447
454
  const state = (0, _src_core.createInputState)(fieldToText(field), this.#styler, this.#theme);
448
- if (rawCapable(this.#input)) return this.#drive(form, state, _src_core.inputReduce);
455
+ if (supportsRawMode(this.#input)) return this.#drive(form, state, _src_core.reduceInput);
449
456
  return this.#line(form, state.message, state.default);
450
457
  }
451
- /** Read a secret — masked live in raw mode, and read without echo through readline on a stream that cannot enter it. */
452
- #password(form, field) {
458
+ /** Reads a secret — masked live in raw mode, and read without echo through readline on a stream that cannot enter it. */
459
+ #askPassword(form, field) {
453
460
  const state = (0, _src_core.createPasswordState)(field, this.#styler, this.#theme);
454
- if (rawCapable(this.#input)) return this.#drive(form, state, _src_core.passwordReduce);
455
- return this.#prompt(form, (0, _src_core.promptHeader)(this.#styler, this.#theme, state.message));
461
+ if (supportsRawMode(this.#input)) return this.#drive(form, state, _src_core.reducePassword);
462
+ return this.#prompt(form, (0, _src_core.renderPromptHeader)(this.#styler, this.#theme, state.message));
456
463
  }
457
464
  /**
458
- * Read a yes or no. The fallback accepts `y` / `yes` / `n` / `no` in any case and takes the
465
+ * Reads a yes or no. The fallback accepts `y` / `yes` / `n` / `no` in any case and takes the
459
466
  * field's default for a bare line; anything else is returned as typed, so the binding refuses it
460
467
  * and the walk asks again rather than silently reading it as no.
461
468
  */
462
469
  async #confirm(form, field) {
463
470
  const state = (0, _src_core.createConfirmState)(field, this.#styler, this.#theme);
464
- if (rawCapable(this.#input)) return this.#drive(form, state, _src_core.confirmReduce);
465
- const header = (0, _src_core.hintedHeader)(this.#styler, this.#theme, state.message, FALLBACK_CONFIRM_HINT);
471
+ if (supportsRawMode(this.#input)) return this.#drive(form, state, _src_core.reduceConfirm);
472
+ const header = (0, _src_core.renderHintedHeader)(this.#styler, this.#theme, state.message, FALLBACK_CONFIRM_HINT);
466
473
  const line = await this.#prompt(form, header);
467
474
  const answer = line.trim().toLowerCase();
468
475
  if (answer.length === 0) return state.default;
@@ -470,96 +477,91 @@ ${groupHeader(this.#styler, this.#theme, (0, _src_core.sanitizeDisplayText)(labe
470
477
  if (answer === "n" || answer === "no") return false;
471
478
  return line.trim();
472
479
  }
473
- /** Read text over many lines — ctrl-d finishes in raw mode, and end of input finishes on a piped stream. */
474
- #editor(form, field) {
480
+ /** Reads text over many lines — ctrl-d finishes in raw mode, and end of input finishes on a piped stream. */
481
+ #askEditor(form, field) {
475
482
  const state = (0, _src_core.createEditorState)(field, this.#styler, this.#theme);
476
- if (rawCapable(this.#input)) return this.#drive(form, state, _src_core.editorReduce);
483
+ if (supportsRawMode(this.#input)) return this.#drive(form, state, _src_core.reduceEditor);
477
484
  return this.#block(form, state.message, state.default);
478
485
  }
479
486
  /**
480
- * Read one choice. A disabled choice is named above the list and never offered, because the form
487
+ * Reads one choice. A disabled choice is named above the list and never offered, because the form
481
488
  * refuses its value at every door. An open select is a suggestion list plus a typed line, since
482
489
  * `open` admits an answer the list does not offer; a closed select with nothing left to offer
483
490
  * resolves absence and lets the form's own rules report it.
484
491
  */
485
492
  async #select(form, field) {
486
- const choices = enabledChoices(field.choices);
487
- this.#unavailable(field.choices);
493
+ const choices = filterEnabled(field.choices);
494
+ this.#writeUnavailable(field.choices);
488
495
  if (field.open === true) {
489
496
  if (choices.length > 0) {
490
497
  const display = choices.map((choice) => ({
491
498
  ...choice,
492
499
  value: (0, _src_core.sanitizeDisplayText)(choice.value)
493
500
  }));
494
- this.#output.write(`${suggestionLine(this.#styler, this.#theme, display)}
495
- `);
501
+ this.#output.write(`${renderSuggestionLine(this.#styler, this.#theme, display)}${_src_core.NEWLINE}`);
496
502
  }
497
- return this.#text(form, field);
503
+ return this.#askText(form, field);
498
504
  }
499
505
  if (choices.length === 0) return void 0;
500
506
  const state = (0, _src_core.createSelectState)({
501
507
  ...field,
502
508
  choices
503
509
  }, this.#styler, this.#theme);
504
- if (rawCapable(this.#input)) return this.#drive(form, state, _src_core.selectReduce);
505
- this.#list(state.message, choices);
506
- const line = await this.#prompt(form, this.#hint(`${FALLBACK_SELECT_HINT}:`));
510
+ if (supportsRawMode(this.#input)) return this.#drive(form, state, _src_core.reduceSelect);
511
+ this.#writeList(state.message, choices);
512
+ const line = await this.#prompt(form, this.#formatHint(`${FALLBACK_SELECT_HINT}:`));
507
513
  return choices[Number.parseInt(line.trim(), 10) - 1]?.value;
508
514
  }
509
515
  /**
510
- * Read any number of choices. Disabled choices are named above the list and never offered, so a
516
+ * Reads any number of choices. Disabled choices are named above the list and never offered, so a
511
517
  * box the form would refuse can never be ticked. An empty answer is an answered "none of them",
512
518
  * which is what `matchesAnswer` says an empty list means.
513
519
  */
514
520
  async #checkbox(form, field) {
515
- const choices = enabledChoices(field.choices);
516
- this.#unavailable(field.choices);
521
+ const choices = filterEnabled(field.choices);
522
+ this.#writeUnavailable(field.choices);
517
523
  const state = (0, _src_core.createCheckboxState)({
518
524
  ...field,
519
525
  choices
520
526
  }, this.#styler, this.#theme);
521
- if (rawCapable(this.#input)) return this.#drive(form, state, _src_core.checkboxReduce);
522
- this.#list(state.message, choices);
523
- return (await this.#prompt(form, this.#hint(`${FALLBACK_CHECKBOX_HINT}:`))).split(",").map((part) => choices[Number.parseInt(part.trim(), 10) - 1]?.value).filter((value) => value !== void 0);
527
+ if (supportsRawMode(this.#input)) return this.#drive(form, state, _src_core.reduceCheckbox);
528
+ this.#writeList(state.message, choices);
529
+ return (await this.#prompt(form, this.#formatHint(`${FALLBACK_CHECKBOX_HINT}:`))).split(",").map((part) => choices[Number.parseInt(part.trim(), 10) - 1]?.value).filter((value) => value !== void 0);
524
530
  }
525
531
  /**
526
- * Read file paths — names only, because bytes never enter a form. A `multiple` field collects one
532
+ * Reads file paths — names only, because bytes never enter a form. A `multiple` field collects one
527
533
  * path per line until a blank one, and a single field takes one. No path at all is absence rather
528
534
  * than an empty list, because the reader answered a blank line.
529
535
  */
530
536
  async #file(form, field) {
531
- if (field.multiple === true) this.#output.write(`${this.#hint(FILE_HINT)}
532
- `);
537
+ if (field.multiple === true) this.#output.write(`${this.#formatHint(FILE_HINT)}${_src_core.NEWLINE}`);
533
538
  const paths = [];
534
539
  for (;;) {
535
- const entry = await this.#text(form, field);
540
+ const entry = await this.#askText(form, field);
536
541
  if (entry.length === 0) break;
537
542
  paths.push(entry);
538
543
  if (field.multiple !== true || this.#ended) break;
539
544
  }
540
545
  return paths.length > 0 ? paths : void 0;
541
546
  }
542
- /** Name the choices a field shows but refuses, above the list the walk drives. */
543
- #unavailable(choices) {
544
- const refused = disabledChoices(choices);
547
+ /** Names the choices a field shows but refuses, above the list the walk drives. */
548
+ #writeUnavailable(choices) {
549
+ const refused = filterDisabled(choices);
545
550
  if (refused.length === 0) return;
546
- this.#output.write(`${unavailableLine(this.#styler, this.#theme, refused)}
547
- `);
548
- }
549
- /** Write a field's header above its numbered choice list — the non-TTY presentation of a choice field. */
550
- #list(message, choices) {
551
- const header = (0, _src_core.promptHeader)(this.#styler, this.#theme, message);
552
- const list = numberedList(this.#styler, this.#theme, choices);
553
- this.#output.write(`${header}
554
- ${list}
555
- `);
556
- }
557
- /** Paint one line of supplementary instruction through the `hint` role. */
558
- #hint(text) {
551
+ this.#output.write(`${renderUnavailableLine(this.#styler, this.#theme, refused)}${_src_core.NEWLINE}`);
552
+ }
553
+ /** Writes a field's header above its numbered choice list — the non-TTY presentation of a choice field. */
554
+ #writeList(message, choices) {
555
+ const header = (0, _src_core.renderPromptHeader)(this.#styler, this.#theme, message);
556
+ const list = renderNumberedList(this.#styler, this.#theme, choices);
557
+ this.#output.write(`${header}${_src_core.NEWLINE}${list}${_src_core.NEWLINE}`);
558
+ }
559
+ /** Paints one line of supplementary instruction through the `hint` role. */
560
+ #formatHint(text) {
559
561
  return this.#styler.render(this.#theme.roles.hint, text);
560
562
  }
561
563
  /**
562
- * The irreducible Node raw-mode primitive — the ONLY place raw mode is touched. Switches the input
564
+ * Isolates the irreducible Node raw-mode primitive — the ONLY place raw mode is touched. Switches the input
563
565
  * into raw mode (each keypress delivered immediately, no echo), resumes its flow, and subscribes
564
566
  * `handler` to `'data'`. The paired {@link #leaveRaw} operation unsubscribes the exact handler,
565
567
  * leaves raw mode, and pauses the stream on submit, cancel, abandon, or throw.
@@ -579,7 +581,7 @@ ${list}
579
581
  this.#input.pause?.();
580
582
  }
581
583
  /**
582
- * Drive ONE field over raw-mode stdin — the generic engine every interactive control shares.
584
+ * Drives ONE field over raw-mode stdin — the generic engine every interactive control shares.
583
585
  * Renders the reducer's initial view, enters raw mode once, and on each keypress runs `parseKey` →
584
586
  * `reduce` → an in-place re-render; on `submit` it cleans up and resolves the reducer's value, on
585
587
  * `cancel` (ctrl-c) it cleans up and rejects a {@link TerminalError} coded `CANCEL`. Abandoning
@@ -595,8 +597,7 @@ ${list}
595
597
  firstView = reduce(initial, (0, _src_core.parseKey)("")).view;
596
598
  this.#output.write(firstView);
597
599
  } catch (error) {
598
- this.#output.write(`${CURSOR_SHOW}
599
- `);
600
+ this.#output.write(`${CURSOR_SHOW}${_src_core.NEWLINE}`);
600
601
  reject(error);
601
602
  return;
602
603
  }
@@ -620,45 +621,41 @@ ${list}
620
621
  }
621
622
  this.#render(step.view, lines);
622
623
  this.#leaveRaw(token);
623
- this.#output.write(`${CURSOR_SHOW}
624
- `);
624
+ this.#output.write(`${CURSOR_SHOW}${_src_core.NEWLINE}`);
625
625
  if (step.status === "cancel") reject(new _src_core.TerminalError("CANCEL", "Prompt cancelled"));
626
626
  else if (step.status === "submit" && step.value !== void 0) resolve(step.value);
627
627
  else reject(new _src_core.TerminalError("DRIVER", "submit produced no value"));
628
628
  } catch (error) {
629
629
  this.#leaveRaw(token);
630
- this.#output.write(`${CURSOR_SHOW}
631
- `);
630
+ this.#output.write(`${CURSOR_SHOW}${_src_core.NEWLINE}`);
632
631
  reject(error);
633
632
  }
634
633
  };
635
634
  }
636
- /** End an active read that the form outlived — release raw mode exactly as a submit does, then fail the read with the form's own error. */
635
+ /** Ends an active read that the form outlived — releases raw mode exactly as a submit does, then fails the read with the form's own error. */
637
636
  #interrupt(token, reject, error) {
638
637
  if (!this.#handlers.has(token)) return;
639
638
  this.#leaveRaw(token);
640
- this.#output.write(`${CURSOR_SHOW}
641
- `);
639
+ this.#output.write(`${CURSOR_SHOW}${_src_core.NEWLINE}`);
642
640
  reject(error);
643
641
  }
644
- /** Redraw a field view in place — climb over the previous view's `previousLines`, clear, and write the new view (the pure cursor-math is {@link redrawPrefix}). */
642
+ /** Redraws a field view in place — climbs over the previous view's `previousLines`, clears, and writes the new view (the pure cursor-math is {@link redrawPrefix}). */
645
643
  #render(view, previousLines) {
646
644
  this.#output.write(`${redrawPrefix(previousLines)}${view}`);
647
645
  }
648
- /** Read one line for a text-shaped field, taking the field's default when the line is bare. */
646
+ /** Reads one line for a text-shaped field, taking the field's default when the line is bare. */
649
647
  async #line(form, message, seed) {
650
- const answer = await this.#prompt(form, (0, _src_core.promptHeader)(this.#styler, this.#theme, message));
648
+ const answer = await this.#prompt(form, (0, _src_core.renderPromptHeader)(this.#styler, this.#theme, message));
651
649
  return answer.length > 0 ? answer : seed;
652
650
  }
653
651
  /**
654
- * Read a whole block for an `editor` field — a piped stream has no ctrl-d keypress, so end of
652
+ * Reads a whole block for an `editor` field — a piped stream has no ctrl-d keypress, so end of
655
653
  * input is the terminator. It drains every remaining line, which is why nothing after an `editor`
656
654
  * field can be asked on the same stream.
657
655
  */
658
656
  async #block(form, message, seed) {
659
- const header = (0, _src_core.hintedHeader)(this.#styler, this.#theme, message, FALLBACK_EDITOR_HINT);
660
- this.#output.write(`${header}
661
- `);
657
+ const header = (0, _src_core.renderHintedHeader)(this.#styler, this.#theme, message, FALLBACK_EDITOR_HINT);
658
+ this.#output.write(`${header}${_src_core.NEWLINE}`);
662
659
  const collected = [];
663
660
  for (;;) {
664
661
  const line = await this.#next(form);
@@ -668,13 +665,13 @@ ${list}
668
665
  const text = collected.join("\n");
669
666
  return text.length > 0 ? text : seed;
670
667
  }
671
- /** Write one field's header and read back the line the reader answers it with, or `''` once the stream has ended. */
668
+ /** Writes one field's header and reads back the line the reader answers it with, or `''` once the stream has ended. */
672
669
  async #prompt(form, header) {
673
670
  this.#output.write(`${header} `);
674
671
  return await this.#next(form) ?? "";
675
672
  }
676
673
  /**
677
- * Take the next line the input carries — from the buffer when the reader has already read ahead,
674
+ * Takes the next line the input carries — from the buffer when the reader has already read ahead,
678
675
  * and otherwise by waiting for one. It resolves ABSENCE once the stream has ended, so a walk over
679
676
  * an exhausted stream settles instead of waiting forever, and it rejects when the form is
680
677
  * abandoned, so an unanswerable read ends with the form rather than outliving it.
@@ -683,7 +680,7 @@ ${list}
683
680
  const buffered = this.#queue.shift();
684
681
  if (buffered !== void 0) return Promise.resolve(buffered);
685
682
  if (this.#ended) return Promise.resolve(void 0);
686
- this.#reader();
683
+ this.#startReader();
687
684
  return new Promise((resolve, reject) => {
688
685
  this.#taker = resolve;
689
686
  form.answer.catch((error) => {
@@ -694,19 +691,19 @@ ${list}
694
691
  });
695
692
  }
696
693
  /**
697
- * Open the walk's ONE readline interface, or keep the open one. A whole form is many questions
694
+ * Opens the walk's ONE readline interface, or keeps the open one. A whole form is many questions
698
695
  * over one stream, and an interface reads ahead: a fresh interface per question would swallow
699
696
  * every line it read past the one it was asked for, so the walk holds a single reader and buffers
700
697
  * what arrives early. It is closed when the walk ends, so the process is free to exit.
701
698
  */
702
- #reader() {
699
+ #startReader() {
703
700
  if (this.#interface !== void 0) return;
704
- const rl = (0, node_readline.createInterface)(this.#readline());
701
+ const rl = (0, node_readline.createInterface)(this.#openReadline());
705
702
  rl.on("line", (line) => this.#accept(line));
706
703
  rl.on("close", () => this.#finish());
707
704
  this.#interface = rl;
708
705
  }
709
- /** Hand a line to whoever is waiting for it, or buffer it for the next question. */
706
+ /** Hands a line to whoever is waiting for it, or buffers it for the next question. */
710
707
  #accept(line) {
711
708
  const taker = this.#taker;
712
709
  if (taker === void 0) {
@@ -716,7 +713,7 @@ ${list}
716
713
  this.#taker = void 0;
717
714
  taker(line);
718
715
  }
719
- /** Record that no further line can arrive and release whoever was waiting for one. */
716
+ /** Records that no further line can arrive and releases whoever was waiting for one. */
720
717
  #finish() {
721
718
  this.#ended = true;
722
719
  const taker = this.#taker;
@@ -724,7 +721,7 @@ ${list}
724
721
  this.#taker = void 0;
725
722
  taker(void 0);
726
723
  }
727
- /** Close the walk's reader and release a waiting question — the walk is over, so the stream must stop holding the process open. */
724
+ /** Closes the walk's reader and releases a waiting question — the walk is over, so the stream must stop holding the process open. */
728
725
  #close() {
729
726
  const taker = this.#taker;
730
727
  this.#taker = void 0;
@@ -736,14 +733,14 @@ ${list}
736
733
  rl.close();
737
734
  }
738
735
  /**
739
- * Narrow the resolved input to the `node:readline` `createInterface` boundary, never an assertion.
736
+ * Narrows the resolved input to the `node:readline` `createInterface` boundary, never an assertion.
740
737
  * The fallback only runs on a real piped `process.stdin` (or a `PassThrough` a test injects), both
741
738
  * genuine readables; a minimal non-readable fake reaching here means the walk was driven with a
742
739
  * stream it cannot use, which fails loudly rather than silently. `terminal: false` leaves readline
743
740
  * as a line decoder and nothing else: the walk writes every prompt itself, through the same output
744
741
  * every other line goes to, so nothing is written twice and a secret is never echoed.
745
742
  */
746
- #readline() {
743
+ #openReadline() {
747
744
  const input = this.#input;
748
745
  if (!isReadable(input)) throw new _src_core.TerminalError("DRIVER", "Terminal fallback requires a readable input stream");
749
746
  return {
@@ -755,12 +752,12 @@ ${list}
755
752
  //#endregion
756
753
  //#region src/server/factories.ts
757
754
  /**
758
- * Create the interactive terminal form driver — the local-keyboard arm of the terminal trio, beside
759
- * the core headless `createPrompt` broker and the SSE `createPromptClient` bridge. Where the broker
760
- * PARKS a live form until somebody elsewhere answers it, a `Terminal` answers one HERE: it walks the
761
- * form's fields in schema order, drives each control's pure reducer over raw-mode stdin, binds every
762
- * answer through the form's own `fill`, and submits. It is the only impure part of the terminal
763
- * 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.
764
761
  *
765
762
  * @param options - See {@link TerminalOptions}
766
763
  * @returns A {@link TerminalInterface} whose `ask` drives one whole form over the resolved streams
@@ -768,7 +765,7 @@ ${list}
768
765
  * @remarks
769
766
  * - **The form is the unit.** `ask` takes the caller's live form and returns its settled values. The
770
767
  * returned promise is that form's own `answer`, so a caller holding the form can await either one.
771
- * - **Every control renders.** Seven controls are read as a line of text with their format cue,
768
+ * - **Every control renders.** The line-read controls are read as a line of text with their format cue,
772
769
  * `password` masks, `confirm` takes a key, `editor` takes a block, `select` and `checkbox` drive a
773
770
  * list, and an open `select` accepts a value its list does not offer.
774
771
  * - **The form decides.** A blank answer binds as absence, the form evaluates, and a refusal
@@ -780,62 +777,75 @@ ${list}
780
777
  * - **Non-TTY fallback.** A piped stream cannot enter raw mode, so the same walk runs over
781
778
  * `node:readline` line input.
782
779
  *
783
- * @example
780
+ * @example Ask one form at this keyboard
784
781
  * ```ts
785
782
  * import { createForm } from '@orkestrel/form'
783
+ * import { isTerminalError } from '@orkestrel/terminal'
786
784
  * import { createTerminal } from '@orkestrel/terminal/server'
787
785
  *
788
- * const terminal = createTerminal()
789
- * const values = await terminal.ask(
790
- * createForm({
791
- * label: 'Sign up',
792
- * fields: [
793
- * { control: 'text', name: 'email', label: 'Email', rule: { required: true, email: true } },
794
- * { control: 'confirm', name: 'terms', label: 'I accept the terms' },
795
- * ],
796
- * }),
797
- * )
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
+ * }
798
813
  * ```
799
814
  */
800
815
  function createTerminal(options) {
801
816
  return new Terminal(options);
802
817
  }
803
818
  //#endregion
804
- exports.CARRIAGE_RETURN = CARRIAGE_RETURN;
805
819
  exports.CLEAR_DOWN = CLEAR_DOWN;
806
820
  exports.CONTROL_HINTS = CONTROL_HINTS;
807
- exports.CSI = CSI;
808
821
  exports.CSI_UP = CSI_UP;
809
822
  exports.CURSOR_HIDE = CURSOR_HIDE;
810
823
  exports.CURSOR_SHOW = CURSOR_SHOW;
811
- exports.ESCAPE = ESCAPE;
812
824
  exports.FALLBACK_CHECKBOX_HINT = FALLBACK_CHECKBOX_HINT;
813
825
  exports.FALLBACK_CONFIRM_HINT = FALLBACK_CONFIRM_HINT;
814
826
  exports.FALLBACK_EDITOR_HINT = FALLBACK_EDITOR_HINT;
815
827
  exports.FALLBACK_SELECT_HINT = FALLBACK_SELECT_HINT;
816
828
  exports.FILE_HINT = FILE_HINT;
817
- exports.LINE_FEED = LINE_FEED;
818
829
  exports.LOCKED_MARK = LOCKED_MARK;
819
830
  exports.REFUSAL_MESSAGE = REFUSAL_MESSAGE;
820
831
  exports.SUGGESTION_LEAD = SUGGESTION_LEAD;
821
832
  exports.Terminal = Terminal;
822
833
  exports.UNAVAILABLE_LEAD = UNAVAILABLE_LEAD;
823
834
  exports.createTerminal = createTerminal;
824
- exports.disabledChoices = disabledChoices;
825
- exports.enabledChoices = enabledChoices;
826
835
  exports.fieldToText = fieldToText;
827
- exports.groupHeader = groupHeader;
836
+ exports.filterDisabled = filterDisabled;
837
+ exports.filterEnabled = filterEnabled;
828
838
  exports.isInputStream = isInputStream;
829
- exports.isOutputStream = isOutputStream;
830
839
  exports.isReadable = isReadable;
831
840
  exports.lineCount = lineCount;
832
- exports.lockedLine = lockedLine;
833
- exports.moveUp = moveUp;
834
- exports.numberedList = numberedList;
835
- exports.rawCapable = rawCapable;
836
841
  exports.redrawPrefix = redrawPrefix;
837
- exports.suggestionLine = suggestionLine;
838
- exports.unavailableLine = unavailableLine;
842
+ exports.renderCursorUp = renderCursorUp;
843
+ exports.renderGroupHeader = renderGroupHeader;
844
+ exports.renderLockedLine = renderLockedLine;
845
+ exports.renderNumberedList = renderNumberedList;
846
+ exports.renderSuggestionLine = renderSuggestionLine;
847
+ exports.renderUnavailableLine = renderUnavailableLine;
848
+ exports.supportsRawMode = supportsRawMode;
839
849
  exports.valueToText = valueToText;
840
850
 
841
851
  //# sourceMappingURL=index.cjs.map