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