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