@orkestrel/terminal 0.0.8 → 0.0.10
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.
- package/README.md +51 -33
- package/dist/src/core/index.cjs +611 -943
- package/dist/src/core/index.d.cts +522 -963
- package/dist/src/core/index.d.ts +522 -963
- package/dist/src/core/index.js +610 -916
- package/dist/src/server/index.cjs +556 -203
- package/dist/src/server/index.d.cts +232 -129
- package/dist/src/server/index.d.ts +232 -129
- package/dist/src/server/index.js +541 -204
- package/package.json +10 -8
- package/dist/src/core/index.cjs.map +0 -1
- package/dist/src/core/index.js.map +0 -1
- package/dist/src/server/index.cjs.map +0 -1
- package/dist/src/server/index.js.map +0 -1
package/dist/src/server/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { TerminalError, checkboxReduce, confirmReduce, createCheckboxState, createConfirmState, createEditorState, createInputState, createPasswordState, createSelectState, editorReduce, hintedHeader, inputReduce, parseKey, passwordReduce, promptHeader,
|
|
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";
|
|
2
|
+
import { matchesAnswer, parseValue } from "@orkestrel/form";
|
|
3
|
+
import { createStyler } from "@orkestrel/console";
|
|
2
4
|
import { createInterface } from "node:readline";
|
|
3
5
|
import { stdin, stdout } from "node:process";
|
|
4
6
|
//#region src/server/constants.ts
|
|
@@ -8,8 +10,8 @@ var ESCAPE = String.fromCharCode(27);
|
|
|
8
10
|
var CSI = `[`;
|
|
9
11
|
/**
|
|
10
12
|
* The cursor-UP sequence TEMPLATE (`ESC[{count}A`) — {@link import('./helpers.js').moveUp}
|
|
11
|
-
* interpolates `{count}` with the number of lines to climb
|
|
12
|
-
*
|
|
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.
|
|
13
15
|
*/
|
|
14
16
|
var CSI_UP = `${CSI}{count}A`;
|
|
15
17
|
/**
|
|
@@ -30,13 +32,47 @@ var CARRIAGE_RETURN = String.fromCharCode(13);
|
|
|
30
32
|
/** A line feed (`\n`, U+000A) — the line terminator the driver writes after the final committed prompt view. */
|
|
31
33
|
var LINE_FEED = String.fromCharCode(10);
|
|
32
34
|
/**
|
|
33
|
-
* The numbered-list prompt the non-TTY {@link import('./Terminal.js').Terminal} `select`
|
|
34
|
-
*
|
|
35
|
-
* printed numbered and the user types
|
|
35
|
+
* The numbered-list prompt the non-TTY {@link import('./Terminal.js').Terminal} `select` fallback
|
|
36
|
+
* appends — a piped (non-terminal) stream cannot navigate with arrow keys, so the choices are
|
|
37
|
+
* printed numbered and the user types one number on a single readline line.
|
|
36
38
|
*/
|
|
37
39
|
var FALLBACK_SELECT_HINT = "Enter a number";
|
|
38
40
|
/** The comma-separated multi-select hint the non-TTY `checkbox` fallback shows (the user types one or more numbers). */
|
|
39
41
|
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. */
|
|
43
|
+
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. */
|
|
45
|
+
var FALLBACK_CONFIRM_HINT = "(y/n)";
|
|
46
|
+
/**
|
|
47
|
+
* The format cue appended to a field's label for each control the walk reads as a line of text —
|
|
48
|
+
* the terminal has no date picker, no color well, and no file chooser, so the accepted shape is
|
|
49
|
+
* stated instead. A control with no entry needs none: `text` and `editor` accept any line,
|
|
50
|
+
* `password` masks one, and `confirm`, `select`, and `checkbox` are answered by key rather than by
|
|
51
|
+
* format. The form's own rules still decide whether the typed value is acceptable.
|
|
52
|
+
*/
|
|
53
|
+
var CONTROL_HINTS = Object.freeze({
|
|
54
|
+
number: "(number)",
|
|
55
|
+
date: "(YYYY-MM-DD)",
|
|
56
|
+
time: "(HH:MM)",
|
|
57
|
+
datetime: "(YYYY-MM-DDTHH:MM)",
|
|
58
|
+
color: "(#rrggbb)",
|
|
59
|
+
file: "(path)"
|
|
60
|
+
});
|
|
61
|
+
/** The instruction a `file` field with `multiple` shows before its entries — one path per line, and a blank line ends the list. */
|
|
62
|
+
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. */
|
|
64
|
+
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. */
|
|
66
|
+
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. */
|
|
68
|
+
var LOCKED_MARK = "(locked)";
|
|
69
|
+
/**
|
|
70
|
+
* What a field is told when the walk read an answer the control cannot hold — a word typed into a
|
|
71
|
+
* `number`, an off-list value typed into an open `select` whose choice is refused. The value binds
|
|
72
|
+
* as absence and this message is invalidated onto the field, so the walk re-asks it with the reason
|
|
73
|
+
* on screen.
|
|
74
|
+
*/
|
|
75
|
+
var REFUSAL_MESSAGE = "Enter a value this field accepts";
|
|
40
76
|
//#endregion
|
|
41
77
|
//#region src/server/helpers.ts
|
|
42
78
|
/**
|
|
@@ -84,18 +120,6 @@ function isReadable(value) {
|
|
|
84
120
|
return typeof value === "object" && value !== null && "read" in value && typeof value.read === "function" && "pipe" in value && typeof value.pipe === "function";
|
|
85
121
|
}
|
|
86
122
|
/**
|
|
87
|
-
* Whether `value` is a Node {@link NodeJS.WritableStream} — a total structural guard (AGENTS §14)
|
|
88
|
-
* checking for the callable `write` / `end` that `node:readline`'s `createInterface` accepts as its
|
|
89
|
-
* `output`. Paired with {@link isReadable} so the non-TTY fallback narrows BOTH streams to the
|
|
90
|
-
* readline boundary without an `as`. Never throws.
|
|
91
|
-
*
|
|
92
|
-
* @param value - The resolved output stream (or any value crossing the boundary)
|
|
93
|
-
* @returns `true` when `value` has the writable methods readline needs
|
|
94
|
-
*/
|
|
95
|
-
function isWritable(value) {
|
|
96
|
-
return typeof value === "object" && value !== null && "write" in value && typeof value.write === "function" && "end" in value && typeof value.end === "function";
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
123
|
* Whether an input stream can be driven in RAW mode — it both reports `isTTY === true` AND exposes a
|
|
100
124
|
* callable `setRawMode`. The {@link import('./Terminal.js').Terminal} probes this to choose its path:
|
|
101
125
|
* `true` ⇒ the interactive raw-mode prompts (arrow-key navigation, live re-render); `false` ⇒ the
|
|
@@ -155,81 +179,389 @@ function moveUp(count) {
|
|
|
155
179
|
function redrawPrefix(previousLines) {
|
|
156
180
|
return `${moveUp(previousLines - 1)}\r${CLEAR_DOWN}`;
|
|
157
181
|
}
|
|
182
|
+
/**
|
|
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.
|
|
189
|
+
*
|
|
190
|
+
* @param field - The field being read
|
|
191
|
+
* @returns The text field the reducer renders for it
|
|
192
|
+
*
|
|
193
|
+
* @example
|
|
194
|
+
* ```ts
|
|
195
|
+
* fieldToText({ control: 'date', name: 'born', label: 'Birthday' })
|
|
196
|
+
* // { control: 'text', name: 'born', label: 'Birthday (YYYY-MM-DD)' }
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
199
|
+
function fieldToText(field) {
|
|
200
|
+
const label = field.label ?? field.name;
|
|
201
|
+
const hint = CONTROL_HINTS[field.control];
|
|
202
|
+
const seed = "default" in field ? field.default : void 0;
|
|
203
|
+
const text = typeof seed === "number" ? String(seed) : seed;
|
|
204
|
+
return {
|
|
205
|
+
control: "text",
|
|
206
|
+
name: field.name,
|
|
207
|
+
label: hint === void 0 ? label : `${label} ${hint}`,
|
|
208
|
+
...typeof text === "string" ? { default: text } : {}
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Project one held answer into the text a read-only line shows — a scalar as itself, a boolean as
|
|
213
|
+
* `yes` / `no` (the word the confirm reducer commits), and a list joined by commas. Absence renders
|
|
214
|
+
* as nothing, because a locked field nobody has answered has nothing to show.
|
|
215
|
+
*
|
|
216
|
+
* @param value - The answer the form holds for a field, or absence
|
|
217
|
+
* @returns The text to render for it
|
|
218
|
+
*/
|
|
219
|
+
function valueToText(value) {
|
|
220
|
+
if (value === void 0) return "";
|
|
221
|
+
if (typeof value === "string") return value;
|
|
222
|
+
if (typeof value === "number") return String(value);
|
|
223
|
+
if (typeof value === "boolean") return value ? "yes" : "no";
|
|
224
|
+
return value.join(", ");
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
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.
|
|
230
|
+
*
|
|
231
|
+
* @param choices - The field's declared choices
|
|
232
|
+
* @returns The choices the walk offers, in declared order
|
|
233
|
+
*/
|
|
234
|
+
function enabledChoices(choices) {
|
|
235
|
+
return choices.filter((choice) => choice.disabled !== true);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
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.
|
|
241
|
+
*
|
|
242
|
+
* @param choices - The field's declared choices
|
|
243
|
+
* @returns The refused choices, in declared order
|
|
244
|
+
*/
|
|
245
|
+
function disabledChoices(choices) {
|
|
246
|
+
return choices.filter((choice) => choice.disabled === true);
|
|
247
|
+
}
|
|
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) {
|
|
250
|
+
return styler.render(theme.roles.message, label);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
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.
|
|
256
|
+
*
|
|
257
|
+
* @param styler - The console styler that renders each role
|
|
258
|
+
* @param theme - The resolved prompt theme
|
|
259
|
+
* @param label - The field's label
|
|
260
|
+
* @param value - The held answer, from {@link valueToText}
|
|
261
|
+
* @returns The rendered line, with no trailing space when there is nothing to show
|
|
262
|
+
*/
|
|
263
|
+
function lockedLine(styler, theme, label, value) {
|
|
264
|
+
const head = `${styler.render(theme.roles.muted, theme.icons.dot)} ${styler.render(theme.roles.message, label)} ${styler.render(theme.roles.hint, LOCKED_MARK)}`;
|
|
265
|
+
return value.length === 0 ? head : `${head} ${styler.render(theme.roles.content, value)}`;
|
|
266
|
+
}
|
|
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) {
|
|
269
|
+
const values = choices.map((choice) => choice.value).join(", ");
|
|
270
|
+
return styler.render(theme.roles.hint, `${SUGGESTION_LEAD}: ${values}`);
|
|
271
|
+
}
|
|
272
|
+
/** The line naming the choices a field shows but refuses, written above the list the walk drives. */
|
|
273
|
+
function unavailableLine(styler, theme, choices) {
|
|
274
|
+
const labels = choices.map((choice) => choice.label).join(", ");
|
|
275
|
+
return styler.render(theme.roles.muted, `${UNAVAILABLE_LEAD}: ${labels}`);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
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.
|
|
281
|
+
*
|
|
282
|
+
* @param styler - The console styler that renders each role
|
|
283
|
+
* @param theme - The resolved prompt theme
|
|
284
|
+
* @param choices - The choices the walk offers, from {@link enabledChoices}
|
|
285
|
+
* @returns The rendered list
|
|
286
|
+
*/
|
|
287
|
+
function numberedList(styler, theme, choices) {
|
|
288
|
+
return choices.map((choice, index) => ` ${styler.render(theme.roles.muted, `${String(index + 1)})`)} ${styler.render(theme.roles.content, choice.label)}`).join("\n");
|
|
289
|
+
}
|
|
158
290
|
//#endregion
|
|
159
291
|
//#region src/server/Terminal.ts
|
|
160
292
|
/**
|
|
161
|
-
* The interactive terminal
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
* core; this class owns ONLY the cursor + raw-mode + in-place re-render mechanics.
|
|
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.
|
|
168
299
|
*
|
|
169
300
|
* @remarks
|
|
170
|
-
* See {@link TerminalInterface} for the
|
|
171
|
-
*
|
|
301
|
+
* See {@link TerminalInterface} for the driving contract. The walk itself:
|
|
302
|
+
*
|
|
303
|
+
* - **Twelve controls, seven reducers.** `text`, `number`, `date`, `time`, `datetime`, `color`, and
|
|
304
|
+
* each `file` entry are read as one line of text through {@link fieldToText}, which appends that
|
|
305
|
+
* control's format cue to the label. `password`, `confirm`, `editor`, `select`, and `checkbox`
|
|
306
|
+
* each drive their own reducer. An open `select` is a suggestion list plus a typed line, because
|
|
307
|
+
* `open` means the answer need not come from the list.
|
|
308
|
+
* - **The binding projects through `matchesAnswer`.** Every answer is filled as
|
|
309
|
+
* `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
|
|
311
|
+
* form's `required` rule refuses it. A typed answer the control cannot hold binds as absence and
|
|
312
|
+
* 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.
|
|
316
|
+
* - **Refusal re-asks.** After the walk the form is submitted. A refusal re-walks only the erroring
|
|
317
|
+
* fields the walk can edit and submits again. When every erroring field is one the walk cannot
|
|
318
|
+
* edit — hidden, locked, or disabled — the form is abandoned instead, because asking again could
|
|
319
|
+
* not change the answer.
|
|
320
|
+
* - **Raw-mode leak-free.** Raw mode is entered once per field and always cleaned up: on submit, on
|
|
321
|
+
* cancel, on a throw, and when the form is abandoned under an active read.
|
|
322
|
+
* - **Non-TTY fallback.** When `input` is not a TTY, the same walk runs over `node:readline` line
|
|
323
|
+
* input: `select` and `checkbox` print a numbered list, and `editor` reads to end of input.
|
|
172
324
|
*/
|
|
173
325
|
var Terminal = class {
|
|
174
326
|
#input;
|
|
175
327
|
#output;
|
|
328
|
+
#styler;
|
|
329
|
+
#theme;
|
|
176
330
|
#handlers = /* @__PURE__ */ new Map();
|
|
331
|
+
#queue = [];
|
|
332
|
+
#interface;
|
|
333
|
+
#taker;
|
|
334
|
+
#ended = false;
|
|
177
335
|
constructor(options) {
|
|
178
336
|
this.#input = isInputStream(options?.input) ? options.input : stdin;
|
|
179
337
|
this.#output = isOutputStream(options?.output) ? options.output : stdout;
|
|
338
|
+
this.#styler = createStyler();
|
|
339
|
+
this.#theme = createPromptTheme(options?.theme);
|
|
340
|
+
}
|
|
341
|
+
async ask(form) {
|
|
342
|
+
try {
|
|
343
|
+
this.#report(form, form.errors);
|
|
344
|
+
await this.#walk(form, form.schema.fields);
|
|
345
|
+
for (;;) {
|
|
346
|
+
if (form.status !== "editing") return await form.answer;
|
|
347
|
+
const result = form.submit();
|
|
348
|
+
if (result.success) return await form.answer;
|
|
349
|
+
this.#report(form, result.error);
|
|
350
|
+
const again = this.#editable(form, result.error);
|
|
351
|
+
if (again.length === 0 || this.#ended) {
|
|
352
|
+
form.destroy();
|
|
353
|
+
return await form.answer;
|
|
354
|
+
}
|
|
355
|
+
await this.#walk(form, again);
|
|
356
|
+
}
|
|
357
|
+
} finally {
|
|
358
|
+
this.#close();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Ask `fields` in the order given, skipping what the walk must not touch and rendering what it
|
|
363
|
+
* must not edit. Returns early the moment the form stops being editable, so an abandoned form
|
|
364
|
+
* ends the walk between fields as well as under an active read.
|
|
365
|
+
*/
|
|
366
|
+
async #walk(form, fields) {
|
|
367
|
+
let group;
|
|
368
|
+
for (const field of fields) {
|
|
369
|
+
if (form.status !== "editing") return;
|
|
370
|
+
if (field.hidden === true) continue;
|
|
371
|
+
if (form.disabled.has(field.name)) continue;
|
|
372
|
+
if (field.group !== group) {
|
|
373
|
+
group = field.group;
|
|
374
|
+
if (group !== void 0) this.#group(form, group);
|
|
375
|
+
}
|
|
376
|
+
if (field.locked === true) {
|
|
377
|
+
this.#locked(form, field);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
const raw = await this.#read(form, field);
|
|
381
|
+
if (form.status !== "editing") return;
|
|
382
|
+
this.#bind(form, field, raw);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
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) {
|
|
387
|
+
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
|
+
`);
|
|
391
|
+
}
|
|
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
|
+
`);
|
|
397
|
+
}
|
|
398
|
+
/** Read one field through the reducer its control names, resolving the raw answer the binding projects. */
|
|
399
|
+
#read(form, field) {
|
|
400
|
+
if (field.control === "password") return this.#password(form, field);
|
|
401
|
+
if (field.control === "confirm") return this.#confirm(form, field);
|
|
402
|
+
if (field.control === "editor") return this.#editor(form, field);
|
|
403
|
+
if (field.control === "select") return this.#select(form, field);
|
|
404
|
+
if (field.control === "checkbox") return this.#checkbox(form, field);
|
|
405
|
+
if (field.control === "file") return this.#file(form, field);
|
|
406
|
+
return this.#text(form, field);
|
|
180
407
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
if (
|
|
200
|
-
if (
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
return this.#
|
|
408
|
+
/**
|
|
409
|
+
* Bind one raw answer to the form — the single binding, and the only place this driver writes a
|
|
410
|
+
* value. `parseValue` coerces the raw answer to the control's own shape and `matchesAnswer`
|
|
411
|
+
* projects a blank one to absence, which is what keeps `required` refusing a bare return. A raw
|
|
412
|
+
* answer the control cannot hold binds as absence and invalidates the field, so the field comes
|
|
413
|
+
* back on the next pass carrying the reason rather than vanishing silently.
|
|
414
|
+
*/
|
|
415
|
+
#bind(form, field, raw) {
|
|
416
|
+
form.touch(field.name);
|
|
417
|
+
const value = raw === void 0 ? void 0 : parseValue(field, raw);
|
|
418
|
+
form.fill(field.name, matchesAnswer(value) ? value : void 0);
|
|
419
|
+
if (value === void 0 && matchesAnswer(raw)) form.invalidate(field.name, REFUSAL_MESSAGE);
|
|
420
|
+
}
|
|
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) {
|
|
423
|
+
const fields = [];
|
|
424
|
+
for (const error of errors) {
|
|
425
|
+
const field = form.field(error.field);
|
|
426
|
+
if (field === void 0) continue;
|
|
427
|
+
if (field.hidden === true || field.locked === true) continue;
|
|
428
|
+
if (form.disabled.has(field.name)) continue;
|
|
429
|
+
if (fields.some((held) => held.name === field.name)) continue;
|
|
430
|
+
fields.push(field);
|
|
431
|
+
}
|
|
432
|
+
return fields;
|
|
433
|
+
}
|
|
434
|
+
/** Write every supplied failure against its field's label. */
|
|
435
|
+
#report(form, errors) {
|
|
436
|
+
for (const error of errors) {
|
|
437
|
+
const label = sanitizeDisplayText(form.field(error.field)?.label ?? error.field);
|
|
438
|
+
const message = sanitizeDisplayText(error.message);
|
|
439
|
+
const line = errorLine(this.#styler, this.#theme, `${label}: ${message}`);
|
|
440
|
+
this.#output.write(`${line}
|
|
441
|
+
`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
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) {
|
|
446
|
+
const state = createInputState(fieldToText(field), this.#styler, this.#theme);
|
|
447
|
+
if (rawCapable(this.#input)) return this.#drive(form, state, inputReduce);
|
|
448
|
+
return this.#line(form, state.message, state.default);
|
|
449
|
+
}
|
|
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) {
|
|
452
|
+
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));
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Read a yes or no. The fallback accepts `y` / `yes` / `n` / `no` in any case and takes the
|
|
458
|
+
* field's default for a bare line; anything else is returned as typed, so the binding refuses it
|
|
459
|
+
* and the walk asks again rather than silently reading it as no.
|
|
460
|
+
*/
|
|
461
|
+
async #confirm(form, field) {
|
|
462
|
+
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);
|
|
465
|
+
const line = await this.#prompt(form, header);
|
|
466
|
+
const answer = line.trim().toLowerCase();
|
|
467
|
+
if (answer.length === 0) return state.default;
|
|
468
|
+
if (answer === "y" || answer === "yes") return true;
|
|
469
|
+
if (answer === "n" || answer === "no") return false;
|
|
470
|
+
return line.trim();
|
|
471
|
+
}
|
|
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) {
|
|
474
|
+
const state = createEditorState(field, this.#styler, this.#theme);
|
|
475
|
+
if (rawCapable(this.#input)) return this.#drive(form, state, editorReduce);
|
|
476
|
+
return this.#block(form, state.message, state.default);
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Read one choice. A disabled choice is named above the list and never offered, because the form
|
|
480
|
+
* refuses its value at every door. An open select is a suggestion list plus a typed line, since
|
|
481
|
+
* `open` admits an answer the list does not offer; a closed select with nothing left to offer
|
|
482
|
+
* resolves absence and lets the form's own rules report it.
|
|
483
|
+
*/
|
|
484
|
+
async #select(form, field) {
|
|
485
|
+
const choices = enabledChoices(field.choices);
|
|
486
|
+
this.#unavailable(field.choices);
|
|
487
|
+
if (field.open === true) {
|
|
488
|
+
if (choices.length > 0) {
|
|
489
|
+
const display = choices.map((choice) => ({
|
|
490
|
+
...choice,
|
|
491
|
+
value: sanitizeDisplayText(choice.value)
|
|
492
|
+
}));
|
|
493
|
+
this.#output.write(`${suggestionLine(this.#styler, this.#theme, display)}
|
|
494
|
+
`);
|
|
495
|
+
}
|
|
496
|
+
return this.#text(form, field);
|
|
497
|
+
}
|
|
498
|
+
if (choices.length === 0) return void 0;
|
|
499
|
+
const state = createSelectState({
|
|
500
|
+
...field,
|
|
501
|
+
choices
|
|
502
|
+
}, 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}:`));
|
|
506
|
+
return choices[Number.parseInt(line.trim(), 10) - 1]?.value;
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Read any number of choices. Disabled choices are named above the list and never offered, so a
|
|
510
|
+
* box the form would refuse can never be ticked. An empty answer is an answered "none of them",
|
|
511
|
+
* which is what `matchesAnswer` says an empty list means.
|
|
512
|
+
*/
|
|
513
|
+
async #checkbox(form, field) {
|
|
514
|
+
const choices = enabledChoices(field.choices);
|
|
515
|
+
this.#unavailable(field.choices);
|
|
516
|
+
const state = createCheckboxState({
|
|
517
|
+
...field,
|
|
518
|
+
choices
|
|
519
|
+
}, 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);
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Read file paths — names only, because bytes never enter a form. A `multiple` field collects one
|
|
526
|
+
* path per line until a blank one, and a single field takes one. No path at all is absence rather
|
|
527
|
+
* than an empty list, because the reader answered a blank line.
|
|
528
|
+
*/
|
|
529
|
+
async #file(form, field) {
|
|
530
|
+
if (field.multiple === true) this.#output.write(`${this.#hint(FILE_HINT)}
|
|
531
|
+
`);
|
|
532
|
+
const paths = [];
|
|
533
|
+
for (;;) {
|
|
534
|
+
const entry = await this.#text(form, field);
|
|
535
|
+
if (entry.length === 0) break;
|
|
536
|
+
paths.push(entry);
|
|
537
|
+
if (field.multiple !== true || this.#ended) break;
|
|
538
|
+
}
|
|
539
|
+
return paths.length > 0 ? paths : void 0;
|
|
540
|
+
}
|
|
541
|
+
/** Name the choices a field shows but refuses, above the list the walk drives. */
|
|
542
|
+
#unavailable(choices) {
|
|
543
|
+
const refused = disabledChoices(choices);
|
|
544
|
+
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) {
|
|
558
|
+
return this.#styler.render(this.#theme.roles.hint, text);
|
|
227
559
|
}
|
|
228
560
|
/**
|
|
229
561
|
* The irreducible Node raw-mode primitive — the ONLY place raw mode is touched. Switches the input
|
|
230
562
|
* into raw mode (each keypress delivered immediately, no echo), resumes its flow, and subscribes
|
|
231
563
|
* `handler` to `'data'`. The paired {@link #leaveRaw} operation unsubscribes the exact handler,
|
|
232
|
-
* leaves raw mode, and pauses the stream on submit, cancel, or throw.
|
|
564
|
+
* leaves raw mode, and pauses the stream on submit, cancel, abandon, or throw.
|
|
233
565
|
*/
|
|
234
566
|
#enterRaw(token, handler) {
|
|
235
567
|
this.#handlers.set(token, handler);
|
|
@@ -246,21 +578,20 @@ var Terminal = class {
|
|
|
246
578
|
this.#input.pause?.();
|
|
247
579
|
}
|
|
248
580
|
/**
|
|
249
|
-
* Drive ONE
|
|
250
|
-
* Renders the reducer's initial
|
|
251
|
-
*
|
|
252
|
-
* `cancel` (ctrl-c) it cleans up
|
|
253
|
-
*
|
|
254
|
-
* raw mode and no `'data'` listener ever
|
|
255
|
-
* on exit.
|
|
581
|
+
* Drive ONE field over raw-mode stdin — the generic engine every interactive control shares.
|
|
582
|
+
* Renders the reducer's initial view, enters raw mode once, and on each keypress runs `parseKey` →
|
|
583
|
+
* `reduce` → an in-place re-render; on `submit` it cleans up and resolves the reducer's value, on
|
|
584
|
+
* `cancel` (ctrl-c) it cleans up and rejects a {@link TerminalError} coded `CANCEL`. Abandoning
|
|
585
|
+
* the form ends the read too, through the rejection of its own `answer`. Raw mode is entered
|
|
586
|
+
* exactly once and cleaned up on every exit path, so no raw mode and no `'data'` listener ever
|
|
587
|
+
* leaks. The cursor is hidden for the duration and restored on exit.
|
|
256
588
|
*/
|
|
257
|
-
#drive(initial, reduce) {
|
|
589
|
+
#drive(form, initial, reduce) {
|
|
258
590
|
return new Promise((resolve, reject) => {
|
|
259
|
-
const state = initial;
|
|
260
591
|
this.#output.write(CURSOR_HIDE);
|
|
261
592
|
let firstView;
|
|
262
593
|
try {
|
|
263
|
-
firstView = reduce(
|
|
594
|
+
firstView = reduce(initial, parseKey("")).view;
|
|
264
595
|
this.#output.write(firstView);
|
|
265
596
|
} catch (error) {
|
|
266
597
|
this.#output.write(`${CURSOR_SHOW}
|
|
@@ -269,8 +600,9 @@ var Terminal = class {
|
|
|
269
600
|
return;
|
|
270
601
|
}
|
|
271
602
|
const token = {};
|
|
272
|
-
const handler = this.#createHandler(token,
|
|
603
|
+
const handler = this.#createHandler(token, initial, lineCount(firstView), reduce, resolve, reject);
|
|
273
604
|
this.#enterRaw(token, handler);
|
|
605
|
+
form.answer.catch((error) => this.#interrupt(token, reject, error));
|
|
274
606
|
});
|
|
275
607
|
}
|
|
276
608
|
#createHandler(token, initial, initialLines, reduce, resolve, reject) {
|
|
@@ -300,169 +632,174 @@ var Terminal = class {
|
|
|
300
632
|
}
|
|
301
633
|
};
|
|
302
634
|
}
|
|
303
|
-
/**
|
|
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. */
|
|
636
|
+
#interrupt(token, reject, error) {
|
|
637
|
+
if (!this.#handlers.has(token)) return;
|
|
638
|
+
this.#leaveRaw(token);
|
|
639
|
+
this.#output.write(`${CURSOR_SHOW}
|
|
640
|
+
`);
|
|
641
|
+
reject(error);
|
|
642
|
+
}
|
|
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}). */
|
|
304
644
|
#render(view, previousLines) {
|
|
305
645
|
this.#output.write(`${redrawPrefix(previousLines)}${view}`);
|
|
306
646
|
}
|
|
647
|
+
/** Read one line for a text-shaped field, taking the field's default when the line is bare. */
|
|
648
|
+
async #line(form, message, seed) {
|
|
649
|
+
const answer = await this.#prompt(form, promptHeader(this.#styler, this.#theme, message));
|
|
650
|
+
return answer.length > 0 ? answer : seed;
|
|
651
|
+
}
|
|
307
652
|
/**
|
|
308
|
-
* Read a
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
* mode, so there is no masking / live edit — just a validated line read.
|
|
312
|
-
*
|
|
313
|
-
* @remarks
|
|
314
|
-
* The re-prompt loop is bounded by end-of-input: once the stream reaches EOF (a piped stream that
|
|
315
|
-
* ran out, or one with no trailing newline) it can never deliver another line, so re-prompting
|
|
316
|
-
* would SPIN. On EOF the loop accepts the final line through `take` if it passes, else returns the
|
|
317
|
-
* caller's `eof` fallback (the default / empty value) — the prompt always settles, never spins.
|
|
653
|
+
* Read a whole block for an `editor` field — a piped stream has no ctrl-d keypress, so end of
|
|
654
|
+
* input is the terminator. It drains every remaining line, which is why nothing after an `editor`
|
|
655
|
+
* field can be asked on the same stream.
|
|
318
656
|
*/
|
|
319
|
-
async #
|
|
657
|
+
async #block(form, message, seed) {
|
|
658
|
+
const header = hintedHeader(this.#styler, this.#theme, message, FALLBACK_EDITOR_HINT);
|
|
659
|
+
this.#output.write(`${header}
|
|
660
|
+
`);
|
|
661
|
+
const collected = [];
|
|
320
662
|
for (;;) {
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
if (ended) return eof(answer);
|
|
663
|
+
const line = await this.#next(form);
|
|
664
|
+
if (line === void 0) break;
|
|
665
|
+
collected.push(line);
|
|
325
666
|
}
|
|
667
|
+
const text = collected.join("\n");
|
|
668
|
+
return text.length > 0 ? text : seed;
|
|
669
|
+
}
|
|
670
|
+
/** Write one field's header and read back the line the reader answers it with, or `''` once the stream has ended. */
|
|
671
|
+
async #prompt(form, header) {
|
|
672
|
+
this.#output.write(`${header} `);
|
|
673
|
+
return await this.#next(form) ?? "";
|
|
326
674
|
}
|
|
327
675
|
/**
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
676
|
+
* Take the next line the input carries — from the buffer when the reader has already read ahead,
|
|
677
|
+
* and otherwise by waiting for one. It resolves ABSENCE once the stream has ended, so a walk over
|
|
678
|
+
* an exhausted stream settles instead of waiting forever, and it rejects when the form is
|
|
679
|
+
* abandoned, so an unanswerable read ends with the form rather than outliving it.
|
|
332
680
|
*/
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
681
|
+
#next(form) {
|
|
682
|
+
const buffered = this.#queue.shift();
|
|
683
|
+
if (buffered !== void 0) return Promise.resolve(buffered);
|
|
684
|
+
if (this.#ended) return Promise.resolve(void 0);
|
|
685
|
+
this.#reader();
|
|
686
|
+
return new Promise((resolve, reject) => {
|
|
687
|
+
this.#taker = resolve;
|
|
688
|
+
form.answer.catch((error) => {
|
|
689
|
+
if (this.#taker !== resolve) return;
|
|
690
|
+
this.#taker = void 0;
|
|
691
|
+
reject(error);
|
|
692
|
+
});
|
|
337
693
|
});
|
|
338
|
-
this.#output.write(list);
|
|
339
|
-
for (;;) {
|
|
340
|
-
const { answer, ended } = await this.#question(`${styler.render(theme.roles.hint, `${hint}:`)} `);
|
|
341
|
-
const accepted = take(answer);
|
|
342
|
-
if (accepted !== void 0) return accepted.value;
|
|
343
|
-
if (ended) return eof;
|
|
344
|
-
}
|
|
345
694
|
}
|
|
346
695
|
/**
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
* @remarks
|
|
352
|
-
* Single-pass by necessity: `#lines` drains the whole stream to EOF in one read, so there is no
|
|
353
|
-
* second block to re-prompt for (re-reading an exhausted stream returns nothing forever). Validation
|
|
354
|
-
* is still applied; on a validation FAILURE the best-effort block is returned rather than spinning
|
|
355
|
-
* on the consumed stream — the EOF analogue of the raw-mode editor's re-edit, which a piped stream
|
|
356
|
-
* cannot offer.
|
|
696
|
+
* Open the walk's ONE readline interface, or keep the open one. A whole form is many questions
|
|
697
|
+
* over one stream, and an interface reads ahead: a fresh interface per question would swallow
|
|
698
|
+
* every line it read past the one it was asked for, so the walk holds a single reader and buffers
|
|
699
|
+
* what arrives early. It is closed when the walk ends, so the process is free to exit.
|
|
357
700
|
*/
|
|
358
|
-
|
|
359
|
-
this.#
|
|
360
|
-
const
|
|
361
|
-
|
|
701
|
+
#reader() {
|
|
702
|
+
if (this.#interface !== void 0) return;
|
|
703
|
+
const rl = createInterface(this.#readline());
|
|
704
|
+
rl.on("line", (line) => this.#accept(line));
|
|
705
|
+
rl.on("close", () => this.#finish());
|
|
706
|
+
this.#interface = rl;
|
|
362
707
|
}
|
|
363
|
-
/**
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
* delivered line is ignored, so a normal newline-terminated line reports `ended: false`).
|
|
373
|
-
*/
|
|
374
|
-
#question(prompt, masked = false) {
|
|
375
|
-
const rl = createInterface(this.#readline(masked));
|
|
376
|
-
return new Promise((resolve) => {
|
|
377
|
-
let last = "";
|
|
378
|
-
const settle = this.#createQuestionSettler(rl, resolve);
|
|
379
|
-
rl.on("line", (line) => {
|
|
380
|
-
last = line;
|
|
381
|
-
});
|
|
382
|
-
rl.question(prompt, (answer) => settle(answer, false));
|
|
383
|
-
rl.on("close", () => settle(last, true));
|
|
384
|
-
});
|
|
708
|
+
/** Hand a line to whoever is waiting for it, or buffer it for the next question. */
|
|
709
|
+
#accept(line) {
|
|
710
|
+
const taker = this.#taker;
|
|
711
|
+
if (taker === void 0) {
|
|
712
|
+
this.#queue.push(line);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
this.#taker = void 0;
|
|
716
|
+
taker(line);
|
|
385
717
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
answer,
|
|
394
|
-
ended
|
|
395
|
-
});
|
|
396
|
-
};
|
|
718
|
+
/** Record that no further line can arrive and release whoever was waiting for one. */
|
|
719
|
+
#finish() {
|
|
720
|
+
this.#ended = true;
|
|
721
|
+
const taker = this.#taker;
|
|
722
|
+
if (taker === void 0) return;
|
|
723
|
+
this.#taker = void 0;
|
|
724
|
+
taker(void 0);
|
|
397
725
|
}
|
|
398
|
-
/**
|
|
399
|
-
#
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
726
|
+
/** Close the walk's reader and release a waiting question — the walk is over, so the stream must stop holding the process open. */
|
|
727
|
+
#close() {
|
|
728
|
+
const taker = this.#taker;
|
|
729
|
+
this.#taker = void 0;
|
|
730
|
+
if (taker !== void 0) taker(void 0);
|
|
731
|
+
const rl = this.#interface;
|
|
732
|
+
if (rl === void 0) return;
|
|
733
|
+
this.#interface = void 0;
|
|
734
|
+
rl.removeAllListeners("close");
|
|
735
|
+
rl.close();
|
|
406
736
|
}
|
|
407
737
|
/**
|
|
408
|
-
* Narrow the resolved
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
738
|
+
* Narrow the resolved input to the `node:readline` `createInterface` boundary, never an assertion.
|
|
739
|
+
* The fallback only runs on a real piped `process.stdin` (or a `PassThrough` a test injects), both
|
|
740
|
+
* genuine readables; a minimal non-readable fake reaching here means the walk was driven with a
|
|
741
|
+
* stream it cannot use, which fails loudly rather than silently. `terminal: false` leaves readline
|
|
742
|
+
* as a line decoder and nothing else: the walk writes every prompt itself, through the same output
|
|
743
|
+
* every other line goes to, so nothing is written twice and a secret is never echoed.
|
|
412
744
|
*/
|
|
413
|
-
#readline(
|
|
745
|
+
#readline() {
|
|
414
746
|
const input = this.#input;
|
|
415
|
-
const output = this.#output;
|
|
416
747
|
if (!isReadable(input)) throw new TerminalError("DRIVER", "Terminal fallback requires a readable input stream");
|
|
417
748
|
return {
|
|
418
749
|
input,
|
|
419
|
-
|
|
420
|
-
...masked ? { terminal: false } : {}
|
|
750
|
+
terminal: false
|
|
421
751
|
};
|
|
422
752
|
}
|
|
423
753
|
};
|
|
424
754
|
//#endregion
|
|
425
755
|
//#region src/server/factories.ts
|
|
426
756
|
/**
|
|
427
|
-
* Create the interactive terminal
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
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.
|
|
433
763
|
*
|
|
434
764
|
* @param options - See {@link TerminalOptions}
|
|
435
|
-
* @returns A {@link TerminalInterface}
|
|
436
|
-
* / `select` / `checkbox` / `editor`) driven over the resolved streams
|
|
765
|
+
* @returns A {@link TerminalInterface} whose `ask` drives one whole form over the resolved streams
|
|
437
766
|
*
|
|
438
767
|
* @remarks
|
|
439
|
-
* - **
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
* - **
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
* `
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
768
|
+
* - **The form is the unit.** `ask` takes the caller's live form and returns its settled values. The
|
|
769
|
+
* 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,
|
|
771
|
+
* `password` masks, `confirm` takes a key, `editor` takes a block, `select` and `checkbox` drive a
|
|
772
|
+
* list, and an open `select` accepts a value its list does not offer.
|
|
773
|
+
* - **The form decides.** A blank answer binds as absence, the form evaluates, and a refusal
|
|
774
|
+
* re-walks only the fields the walk can still edit.
|
|
775
|
+
* - **Injectable and guard-narrowed.** `input` and `output` default to `process.stdin` and
|
|
776
|
+
* `process.stdout` but accept any stream of the declared minimal shape, resolved through their
|
|
777
|
+
* guards rather than an assertion, so a test drives a whole form with a fake TTY that emits
|
|
778
|
+
* scripted key chunks and records every rendered byte.
|
|
779
|
+
* - **Non-TTY fallback.** A piped stream cannot enter raw mode, so the same walk runs over
|
|
780
|
+
* `node:readline` line input.
|
|
452
781
|
*
|
|
453
782
|
* @example
|
|
454
783
|
* ```ts
|
|
455
|
-
* import {
|
|
784
|
+
* import { createForm } from '@orkestrel/form'
|
|
785
|
+
* import { createTerminal } from '@orkestrel/terminal/server'
|
|
456
786
|
*
|
|
457
787
|
* const terminal = createTerminal()
|
|
458
|
-
* const
|
|
459
|
-
*
|
|
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
|
+
* )
|
|
460
797
|
* ```
|
|
461
798
|
*/
|
|
462
799
|
function createTerminal(options) {
|
|
463
800
|
return new Terminal(options);
|
|
464
801
|
}
|
|
465
802
|
//#endregion
|
|
466
|
-
export { CARRIAGE_RETURN, CLEAR_DOWN, CSI, CSI_UP, CURSOR_HIDE, CURSOR_SHOW, ESCAPE, FALLBACK_CHECKBOX_HINT, FALLBACK_SELECT_HINT, LINE_FEED, Terminal, createTerminal, isInputStream, isOutputStream, isReadable,
|
|
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 };
|
|
467
804
|
|
|
468
805
|
//# sourceMappingURL=index.js.map
|