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