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