create-pathfinder 1.6.0 → 1.8.0
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/CLAUDE.md +2 -0
- package/README.md +21 -4
- package/bin/create-pathfinder.mjs +22 -0
- package/context/ai-interaction.md +1 -0
- package/package.json +1 -1
- package/skills/complete-feature/SKILL.md +3 -2
- package/skills/load-feature/SKILL.md +2 -1
- package/skills/setup-tracker/SKILL.md +74 -0
- package/skills/start-feature/SKILL.md +1 -0
- package/skills/sync-tracker/SKILL.md +120 -0
- package/skills/to-specs/SKILL.md +4 -0
- package/src/cells.mjs +324 -0
- package/src/cli.mjs +46 -18
- package/src/detect.mjs +1 -1
- package/src/kickstart-prompt.mjs +1 -1
- package/src/prompt.mjs +116 -11
- package/src/select.mjs +426 -0
- package/src/theme.mjs +241 -13
- package/templates/feature-spec.template.md +6 -0
- package/templates/tracker.template.md +359 -0
package/src/select.mjs
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A vertical list you answer with the arrow keys, drawn on a terminal that
|
|
3
|
+
* belongs to somebody else.
|
|
4
|
+
*
|
|
5
|
+
* The whole design follows from one decision: **readline stays the owner of raw
|
|
6
|
+
* mode for the entire run.** `setRawMode` is never called here, or anywhere in
|
|
7
|
+
* this package. `createInterface` turns raw mode on because stdin is a TTY and
|
|
8
|
+
* `rl.close()` turns it off, and this module simply borrows the keyboard in
|
|
9
|
+
* between and gives it back. Owning raw mode would mean owning its restoration
|
|
10
|
+
* on every exit path including the ones nobody plans for, and the interrupt
|
|
11
|
+
* guarantee this package already has would become something to maintain rather
|
|
12
|
+
* than something that holds by construction.
|
|
13
|
+
*
|
|
14
|
+
* ## Two listeners are borrowed, not one
|
|
15
|
+
*
|
|
16
|
+
* An open Interface registers exactly one `keypress` listener on its input and
|
|
17
|
+
* exactly one `resize` listener on its output — verified identical on Node
|
|
18
|
+
* 18.12.1, 18.17.0, 20.20.2, 22.23.1 and 26.5.0.
|
|
19
|
+
*
|
|
20
|
+
* The first is obvious: removing it disengages readline's line editor
|
|
21
|
+
* completely — no echo, no history, no line buffer — while the Interface stays
|
|
22
|
+
* open and therefore still holds raw mode. The stream's keypress decoder keeps
|
|
23
|
+
* running because a listener of our own goes on immediately.
|
|
24
|
+
*
|
|
25
|
+
* The second was found by experiment and is **not optional**. On SIGWINCH an
|
|
26
|
+
* open Interface refreshes its own line, emitting `ESC[1G`, `ESC[0J` and
|
|
27
|
+
* `ESC[3G` — none of which are in this package's escape budget — and painting
|
|
28
|
+
* its default `"> "` prompt into the middle of the frame. Borrowing the
|
|
29
|
+
* keyboard without borrowing this leaves readline drawing on top of us the
|
|
30
|
+
* moment someone drags a window edge.
|
|
31
|
+
*
|
|
32
|
+
* ## The cursor is never hidden
|
|
33
|
+
*
|
|
34
|
+
* No `?25l`, ever. It parks visibly at column 0 below the block, which is why
|
|
35
|
+
* nothing needs restoring, why no signal handler exists, and why an interrupted
|
|
36
|
+
* run cannot leave a terminal with an invisible cursor. A repainting renderer
|
|
37
|
+
* is exactly the kind of code that reaches for cursor hiding, so the absence is
|
|
38
|
+
* stated here rather than left to be noticed.
|
|
39
|
+
*
|
|
40
|
+
* ## The question is printed once, above everything this module repaints
|
|
41
|
+
*
|
|
42
|
+
* The repainted block is the option rows, a blank line, and the hint — and
|
|
43
|
+
* nothing else. The question is written once before the block and never
|
|
44
|
+
* touched again.
|
|
45
|
+
*
|
|
46
|
+
* That is a width decision rather than an aesthetic one. The longest question
|
|
47
|
+
* the CLI asks is 70 cells, so a question inside the block would force a
|
|
48
|
+
* minimum terminal width of 71 and put keyboard selection out of reach of a
|
|
49
|
+
* split pane. Outside the block it is free to wrap across as many rows as it
|
|
50
|
+
* likes: the cursor-up count is computed over the block, the question is not in
|
|
51
|
+
* it, and a row count that is not part of the arithmetic cannot corrupt it.
|
|
52
|
+
*
|
|
53
|
+
* ## Every row is clipped, and `.length` is never a width
|
|
54
|
+
*
|
|
55
|
+
* The byte stream this renderer emits is width-independent — it always says
|
|
56
|
+
* `ESC[7A` for a seven-row block — so a row wider than the terminal takes two
|
|
57
|
+
* rows, the cursor moves up one row too few, and every repaint leaves a copy of
|
|
58
|
+
* the frame behind. Reproduced during the prototype at 24 columns: five copies
|
|
59
|
+
* of the question for four keypresses.
|
|
60
|
+
*
|
|
61
|
+
* Clipping every row to `columns - 1` makes the row count equal the line count
|
|
62
|
+
* by construction. It has to be `theme.clip` and `theme.width` rather than
|
|
63
|
+
* `String.prototype.slice` and `.length`: `theme.ok("✓ Git repository detected")`
|
|
64
|
+
* has a `.length` of 34 and occupies 25 cells, and a slice through an escape
|
|
65
|
+
* sequence prints its tail as literal text.
|
|
66
|
+
*
|
|
67
|
+
* ## What this module deliberately is not
|
|
68
|
+
*
|
|
69
|
+
* No filtering, no fuzzy search, no scrolling viewport, no mouse, no spinner,
|
|
70
|
+
* no animation, no colour of its own. The highlight is carried by a pointer
|
|
71
|
+
* glyph, which is the one presentation that survives every tier, both
|
|
72
|
+
* alphabets, and a terminal that renders no colour at all.
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/** Terminal width to assume when the stream will not say. */
|
|
76
|
+
const FALLBACK_COLUMNS = 80;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* How wide the terminal is *right now*, not when the theme was built.
|
|
80
|
+
*
|
|
81
|
+
* Deliberately read from the stream on every paint. A window can be dragged
|
|
82
|
+
* while a question is on screen, and a renderer clipping against a remembered
|
|
83
|
+
* width would clip against a terminal that no longer exists.
|
|
84
|
+
*/
|
|
85
|
+
function liveColumns(output, theme) {
|
|
86
|
+
const columns = output?.columns;
|
|
87
|
+
if (Number.isInteger(columns) && columns > 0) return columns;
|
|
88
|
+
return theme.columns ?? FALLBACK_COLUMNS;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Pad `text` on the right to `cells` columns of *rendered* width.
|
|
93
|
+
*
|
|
94
|
+
* Not `padEnd`, which counts UTF-16 units: it pads `[x]` and `◉` to different
|
|
95
|
+
* places, and would pad a coloured string by the length of its escape sequence.
|
|
96
|
+
*/
|
|
97
|
+
function padTo(text, cells, theme) {
|
|
98
|
+
const short = cells - theme.width(text);
|
|
99
|
+
return short > 0 ? text + " ".repeat(short) : text;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* How wide the label column has to be for the second column to line up.
|
|
104
|
+
*
|
|
105
|
+
* Zero when there is nothing to line up against. A list of editors has nothing
|
|
106
|
+
* to the right of its labels, and padding them would emit trailing whitespace
|
|
107
|
+
* that means nothing and clips first.
|
|
108
|
+
*
|
|
109
|
+
* @param {{label: string, hint?: string}[]} options
|
|
110
|
+
* @param {object} theme
|
|
111
|
+
* @returns {number}
|
|
112
|
+
*/
|
|
113
|
+
export function alignmentWidth(options, theme) {
|
|
114
|
+
if (!options.some((option) => Boolean(option.hint))) return 0;
|
|
115
|
+
return Math.max(...options.map((option) => theme.width(option.label)));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* One option, as text: its label, the path it writes to, and whatever the
|
|
120
|
+
* detector had to say about it.
|
|
121
|
+
*
|
|
122
|
+
* Exported because **both** renderers need the same grammar. The classic
|
|
123
|
+
* numbered list and the keyboard selector draw completely different frames
|
|
124
|
+
* around an option, but the option itself reads the same in each — and it has
|
|
125
|
+
* to, because the classic path is a supported way to answer the question and
|
|
126
|
+
* not a reduced one. Two implementations of this sentence would drift, and the
|
|
127
|
+
* drift would be invisible until someone ran the same install twice under
|
|
128
|
+
* different terminals.
|
|
129
|
+
*
|
|
130
|
+
* What is *not* here: the pointer, the checkbox, and the clipping. Those belong
|
|
131
|
+
* to a frame, and only one of the two renderers draws them.
|
|
132
|
+
*
|
|
133
|
+
* @param {object} args
|
|
134
|
+
* @param {object} args.theme
|
|
135
|
+
* @param {{label: string, hint?: string, note?: string}} args.option
|
|
136
|
+
* @param {number} [args.labelWidth] - from `alignmentWidth`
|
|
137
|
+
* @param {boolean} [args.withNote] - false to compose the row without its
|
|
138
|
+
* suffix, which is how a caller asks "how short can this row be".
|
|
139
|
+
* @returns {string}
|
|
140
|
+
*/
|
|
141
|
+
export function optionRow({ theme, option, labelWidth = 0, withNote = true }) {
|
|
142
|
+
const label = labelWidth > 0 ? padTo(option.label, labelWidth, theme) : option.label;
|
|
143
|
+
const row = label + (option.hint ? ` -> ${option.hint}` : "");
|
|
144
|
+
return withNote && option.note ? `${row} ${option.note}` : row;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Build the block this renderer repaints: the option rows, a blank line, and
|
|
149
|
+
* the hint.
|
|
150
|
+
*
|
|
151
|
+
* Pure, and exported for that reason — the no-wrap guarantee is a property of
|
|
152
|
+
* these strings at a given width, and asserting it should not require
|
|
153
|
+
* synthesizing a terminal and pressing keys at it.
|
|
154
|
+
*
|
|
155
|
+
* Every returned line is already clipped to `columns - 1`, so the caller may
|
|
156
|
+
* count them as rows without checking anything.
|
|
157
|
+
*
|
|
158
|
+
* @param {object} args
|
|
159
|
+
* @param {object} args.theme
|
|
160
|
+
* @param {{label: string, value: unknown, hint?: string, note?: string}[]} args.options
|
|
161
|
+
* @param {number} args.cursor - index of the highlighted row
|
|
162
|
+
* @param {Set<number>} [args.selected] - checked indices, multi-select only
|
|
163
|
+
* @param {boolean} [args.multi]
|
|
164
|
+
* @param {number} args.columns
|
|
165
|
+
* @returns {string[]}
|
|
166
|
+
*/
|
|
167
|
+
export function renderBlock({ theme, options, cursor, selected = new Set(), multi = false, columns }) {
|
|
168
|
+
const budget = Math.max(0, columns - 1);
|
|
169
|
+
const glyph = theme.glyph;
|
|
170
|
+
|
|
171
|
+
// An unhighlighted row spends the same cells on nothing that a highlighted one
|
|
172
|
+
// spends on the pointer, so the labels never shift sideways as the cursor
|
|
173
|
+
// moves. Measured rather than assumed to be one: `❯` is one cell and `>` is
|
|
174
|
+
// one cell today, and a future pointer that is not would silently misalign
|
|
175
|
+
// every row.
|
|
176
|
+
const blank = " ".repeat(theme.width(glyph.pointer));
|
|
177
|
+
|
|
178
|
+
const labelWidth = alignmentWidth(options, theme);
|
|
179
|
+
|
|
180
|
+
const rows = options.map((option, index) => {
|
|
181
|
+
const pointer = index === cursor ? glyph.pointer : blank;
|
|
182
|
+
const box = multi ? `${selected.has(index) ? glyph.checked : glyph.unchecked} ` : "";
|
|
183
|
+
const frame = `${pointer} ${box}`;
|
|
184
|
+
|
|
185
|
+
// The suffix renders whole or not at all.
|
|
186
|
+
//
|
|
187
|
+
// `note` carries `(detected)`, which the ENVIRONMENT phase has already
|
|
188
|
+
// reported — so it duplicates information rather than carrying it, and a
|
|
189
|
+
// truncated `(detec` would be strictly worse than its absence. This is also
|
|
190
|
+
// why the minimum width is 49 rather than the wider terminal a guaranteed
|
|
191
|
+
// `note` would demand: a suffix that says nothing new does not get to decide
|
|
192
|
+
// whether the whole interaction is available.
|
|
193
|
+
const decorated = frame + optionRow({ theme, option, labelWidth });
|
|
194
|
+
const bare = frame + optionRow({ theme, option, labelWidth, withNote: false });
|
|
195
|
+
|
|
196
|
+
return theme.clip(theme.width(decorated) <= budget ? decorated : bare, budget);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const keys = multi
|
|
200
|
+
? `${glyph.arrowUp}${glyph.arrowDown} move space toggle enter confirm`
|
|
201
|
+
: `${glyph.arrowUp}${glyph.arrowDown} move enter confirm`;
|
|
202
|
+
|
|
203
|
+
return [...rows, "", theme.clip(` ${keys}`, budget)];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Ask one question with the arrow keys, over a readline Interface somebody else
|
|
208
|
+
* opened.
|
|
209
|
+
*
|
|
210
|
+
* Returns the chosen `value`, or an array of them when `multi`, or `null` when
|
|
211
|
+
* nobody answered — Escape, or a stream that ended. `null` is the same refusal
|
|
212
|
+
* every existing call site already reads from the classic path, which is what
|
|
213
|
+
* lets this be swapped in behind an unchanged interface.
|
|
214
|
+
*
|
|
215
|
+
* @param {object} args
|
|
216
|
+
* @param {import("node:readline").Interface} args.readline - the raw-mode owner
|
|
217
|
+
* @param {object} args.theme
|
|
218
|
+
* @param {string} args.question
|
|
219
|
+
* @param {{label: string, value: unknown, hint?: string, note?: string, key?: string}[]} args.options
|
|
220
|
+
* @param {boolean} [args.multi]
|
|
221
|
+
* @param {unknown[]} [args.initial] - values checked at the start in `multi`,
|
|
222
|
+
* and the value highlighted at the start otherwise. One shape for both, so a
|
|
223
|
+
* caller never has to remember which mode takes which.
|
|
224
|
+
* @param {() => void} [args.raiseInterrupt] - how Ctrl-C re-raises. Injected so
|
|
225
|
+
* that a test can observe the interrupt rather than be killed by it.
|
|
226
|
+
* @returns {Promise<unknown|unknown[]|null>}
|
|
227
|
+
*/
|
|
228
|
+
export function select({
|
|
229
|
+
readline,
|
|
230
|
+
theme,
|
|
231
|
+
question,
|
|
232
|
+
options,
|
|
233
|
+
multi = false,
|
|
234
|
+
initial = [],
|
|
235
|
+
raiseInterrupt = () => process.kill(process.pid, "SIGINT"),
|
|
236
|
+
}) {
|
|
237
|
+
// Nothing to choose between is not a question. Returning before anything is
|
|
238
|
+
// printed keeps an empty list from leaving a hint line on screen that no key
|
|
239
|
+
// can dismiss.
|
|
240
|
+
if (!Array.isArray(options) || options.length === 0) return Promise.resolve(null);
|
|
241
|
+
|
|
242
|
+
const input = readline.input;
|
|
243
|
+
const output = readline.output;
|
|
244
|
+
const write = (text) => output.write(text);
|
|
245
|
+
|
|
246
|
+
const selected = new Set();
|
|
247
|
+
if (multi) {
|
|
248
|
+
options.forEach((option, index) => {
|
|
249
|
+
if (initial.includes(option.value)) selected.add(index);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// In `multi` the cursor starts at the top whatever is checked; in a
|
|
254
|
+
// single-select it starts on the answer the caller would have defaulted to,
|
|
255
|
+
// which is what keeps the common case one keystroke.
|
|
256
|
+
const highlighted = multi ? -1 : options.findIndex((option) => initial.includes(option.value));
|
|
257
|
+
let cursor = highlighted >= 0 ? highlighted : 0;
|
|
258
|
+
|
|
259
|
+
// How many rows the block on screen occupies. Zero means nothing has been
|
|
260
|
+
// drawn yet, which is also what a resize resets it to.
|
|
261
|
+
let painted = 0;
|
|
262
|
+
|
|
263
|
+
const paint = () => {
|
|
264
|
+
const lines = renderBlock({
|
|
265
|
+
theme,
|
|
266
|
+
options,
|
|
267
|
+
cursor,
|
|
268
|
+
selected,
|
|
269
|
+
multi,
|
|
270
|
+
columns: liveColumns(output, theme),
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Back to the top of the block drawn last time, then rewrite every row in
|
|
274
|
+
// place. Each row is cleared before it is written, so a shorter row can
|
|
275
|
+
// never leave the tail of a longer one behind it.
|
|
276
|
+
write(theme.line.up(painted));
|
|
277
|
+
for (const line of lines) write(theme.line.start() + theme.line.clear() + line + "\n");
|
|
278
|
+
painted = lines.length;
|
|
279
|
+
|
|
280
|
+
// The cursor is now parked at column 0 below the block, plainly visible.
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
return new Promise((resolve) => {
|
|
284
|
+
let settled = false;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Repaint from scratch, because the block on screen belongs to a width that
|
|
288
|
+
* no longer exists.
|
|
289
|
+
*
|
|
290
|
+
* `painted` counts rows at the *old* width, so moving up by it would be
|
|
291
|
+
* wrong however carefully it was counted. Forgetting it is the honest
|
|
292
|
+
* recovery: the stale frame stays in scrollback and a clean one is drawn
|
|
293
|
+
* below it. A resize costs one duplicated frame and never a corrupted
|
|
294
|
+
* terminal.
|
|
295
|
+
*/
|
|
296
|
+
const onResize = () => {
|
|
297
|
+
painted = 0;
|
|
298
|
+
paint();
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
function onKey(_sequence, key = {}) {
|
|
302
|
+
// Ctrl-C. Borrowing readline's keypress listener took its Ctrl-C handling
|
|
303
|
+
// with it, so that handling is owed back: give the keyboard up, let
|
|
304
|
+
// readline drop raw mode on close, then re-raise the signal so the process
|
|
305
|
+
// dies exactly the way it would have anyway — exit 130, no handler,
|
|
306
|
+
// nothing swallowed.
|
|
307
|
+
if (key.ctrl && key.name === "c") {
|
|
308
|
+
if (settled) return;
|
|
309
|
+
settled = true;
|
|
310
|
+
|
|
311
|
+
// Restore first, so that closing readline finds the terminal exactly as
|
|
312
|
+
// readline left it — and so the `close` listener below, which would
|
|
313
|
+
// otherwise fire from this very `close()`, is already gone.
|
|
314
|
+
restore();
|
|
315
|
+
write("\n");
|
|
316
|
+
readline.close();
|
|
317
|
+
raiseInterrupt();
|
|
318
|
+
|
|
319
|
+
// Reached only if the interrupt did not end the process, which is the
|
|
320
|
+
// case under test and never in a real run. Settling is strictly safer
|
|
321
|
+
// than leaving a promise nobody can resolve.
|
|
322
|
+
resolve(null);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (key.name === "up" || key.name === "k") {
|
|
327
|
+
cursor = (cursor - 1 + options.length) % options.length;
|
|
328
|
+
paint();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (key.name === "down" || key.name === "j") {
|
|
333
|
+
cursor = (cursor + 1) % options.length;
|
|
334
|
+
paint();
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (multi && key.name === "space") {
|
|
339
|
+
if (selected.has(cursor)) selected.delete(cursor);
|
|
340
|
+
else selected.add(cursor);
|
|
341
|
+
paint();
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (key.name === "escape") {
|
|
346
|
+
paint();
|
|
347
|
+
finish(null);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (key.name === "return" || key.name === "enter") {
|
|
352
|
+
paint();
|
|
353
|
+
finish(answer());
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Hidden accelerators, and hidden is the point.
|
|
358
|
+
//
|
|
359
|
+
// `confirm` is a two-row select, and `y`/`n` kept working there costs one
|
|
360
|
+
// lookup and saves anyone with the old habit a keystroke. They are not
|
|
361
|
+
// presented in the hint, because the presented interaction is the arrow
|
|
362
|
+
// keys — an accelerator advertised is a second interaction to learn.
|
|
363
|
+
// Single-select only: in a checkbox list every letter is a candidate for
|
|
364
|
+
// some future label and none of them should silently submit the form.
|
|
365
|
+
if (!multi) {
|
|
366
|
+
const accelerated = options.findIndex((option) => option.key !== undefined && option.key === key.name);
|
|
367
|
+
if (accelerated >= 0) {
|
|
368
|
+
cursor = accelerated;
|
|
369
|
+
paint();
|
|
370
|
+
finish(options[accelerated].value);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Everything else is ignored rather than echoed, which is what stops a
|
|
376
|
+
// stray escape byte from being printed as text into the middle of the
|
|
377
|
+
// block.
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** What the current state means as an answer. */
|
|
381
|
+
const answer = () =>
|
|
382
|
+
multi
|
|
383
|
+
? [...selected].sort((a, b) => a - b).map((index) => options[index].value)
|
|
384
|
+
: options[cursor].value;
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The stream ended while the question was on screen — Ctrl-D, or a pipe
|
|
388
|
+
* that closed under us.
|
|
389
|
+
*
|
|
390
|
+
* Resolving `null` rather than hanging: an unanswered question is a refusal
|
|
391
|
+
* every call site already knows how to read, and a prompt that waits
|
|
392
|
+
* forever on a closed stdin is how a CI job hangs at 3am.
|
|
393
|
+
*/
|
|
394
|
+
const onClose = () => finish(null);
|
|
395
|
+
|
|
396
|
+
// Take the keyboard, and the resize handler, remembering exactly what was
|
|
397
|
+
// taken so exactly that can be given back.
|
|
398
|
+
const borrowedKeypress = input.listeners("keypress");
|
|
399
|
+
input.removeAllListeners("keypress");
|
|
400
|
+
const borrowedResize = output.listeners("resize");
|
|
401
|
+
output.removeAllListeners("resize");
|
|
402
|
+
|
|
403
|
+
function restore() {
|
|
404
|
+
input.removeListener("keypress", onKey);
|
|
405
|
+
for (const listener of borrowedKeypress) input.on("keypress", listener);
|
|
406
|
+
output.removeListener("resize", onResize);
|
|
407
|
+
for (const listener of borrowedResize) output.on("resize", listener);
|
|
408
|
+
readline.removeListener("close", onClose);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function finish(value) {
|
|
412
|
+
if (settled) return;
|
|
413
|
+
settled = true;
|
|
414
|
+
restore();
|
|
415
|
+
resolve(value);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
input.on("keypress", onKey);
|
|
419
|
+
output.on("resize", onResize);
|
|
420
|
+
readline.on("close", onClose);
|
|
421
|
+
|
|
422
|
+
// Once, above the block, and never again. It may wrap; nothing counts it.
|
|
423
|
+
write(`? ${question}\n\n`);
|
|
424
|
+
paint();
|
|
425
|
+
});
|
|
426
|
+
}
|