acuvo-code 0.3.6 → 0.4.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/ENTERPRISE.md +2 -2
- package/lib/chat.mjs +36 -7
- package/lib/input-box.mjs +299 -0
- package/package.json +1 -1
package/ENTERPRISE.md
CHANGED
|
@@ -188,8 +188,8 @@ copy, but it *is* a place a process starts, and this is a list of those. Six and
|
|
|
188
188
|
are the numbers to quote. Counting is the first thing a reviewer does.
|
|
189
189
|
|
|
190
190
|
⚠️ **This said "18 shipped files", then "41", then "90", then "101", then "108", and every
|
|
191
|
-
one went stale in turn.** The package ships **
|
|
192
|
-
`bin/` — about
|
|
191
|
+
one went stale in turn.** The package ships **120 files — 118 in `lib/`, 2 in
|
|
192
|
+
`bin/` — about 77954 lines**, with **236 test files** beside them (counted 2026-08-22).
|
|
193
193
|
|
|
194
194
|
⭐ **AND THE 108 WENT STALE IN THE MOST INSTRUCTIVE WAY POSSIBLE: THREE OF THE FILES IT
|
|
195
195
|
MISSED WERE REACHABLE FROM NOTHING.** `wiring-reach.test.mjs` was naming
|
package/lib/chat.mjs
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { createInterface } from 'node:readline';
|
|
25
|
+
import { readBoxedLine } from './input-box.mjs';
|
|
25
26
|
import { EXIT_INTERRUPTED } from './interrupt.mjs';
|
|
26
27
|
import { parseSlash, runSlashCommand } from './slash.mjs';
|
|
27
28
|
import { estimateMessagesTokens } from './compact.mjs';
|
|
@@ -319,7 +320,23 @@ export async function runChat({
|
|
|
319
320
|
const queued = interactive ? null : await readAllLines(input);
|
|
320
321
|
let queueIndex = 0;
|
|
321
322
|
|
|
322
|
-
|
|
323
|
+
/**
|
|
324
|
+
* ── ⚠️⚠️ READLINE IS GONE FROM THE INTERACTIVE PATH, AND IT HAD TO GO ──────
|
|
325
|
+
*
|
|
326
|
+
* `input-box.mjs` now owns the keyboard. Leaving the readline interface
|
|
327
|
+
* attached to the SAME stream was not merely redundant — both it and the box
|
|
328
|
+
* saw every Ctrl-C, so `onInterrupt` fired TWICE for one keypress. Measured,
|
|
329
|
+
* not theorised: a single `\x03` produced `['interrupt', 'interrupt']`.
|
|
330
|
+
*
|
|
331
|
+
* ⭐ A DOUBLE INTERRUPT IS NOT A COSMETIC BUG. The second Ctrl-C is the one
|
|
332
|
+
* that QUITS — so one press would have armed and fired the escape hatch in the
|
|
333
|
+
* same instant, ending a session the user meant only to nudge.
|
|
334
|
+
*
|
|
335
|
+
* The Node-internals note below about `question()` swallowing Ctrl-C is kept
|
|
336
|
+
* because it is why the box reads raw keys itself rather than asking readline
|
|
337
|
+
* for a line. It is history now, not a live constraint.
|
|
338
|
+
*/
|
|
339
|
+
const rl = null;
|
|
323
340
|
const state = { closed: false };
|
|
324
341
|
/**
|
|
325
342
|
* ⚠️ ATTACHED ONCE, NOT PER QUESTION — and the per-question version is why the
|
|
@@ -350,6 +367,8 @@ export async function runChat({
|
|
|
350
367
|
* to the model; this is the variable that makes the verb real.
|
|
351
368
|
*/
|
|
352
369
|
let pendingInject = null;
|
|
370
|
+
/** Lines the user has submitted this session — the box's Up/Down history. */
|
|
371
|
+
const typed = [];
|
|
353
372
|
|
|
354
373
|
try {
|
|
355
374
|
for (;;) {
|
|
@@ -374,12 +393,19 @@ export async function runChat({
|
|
|
374
393
|
* because `columns` is `undefined` when stdout is not a TTY and enormous
|
|
375
394
|
* when someone maximises on an ultrawide.
|
|
376
395
|
*/
|
|
377
|
-
|
|
378
|
-
if (interactive)
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
396
|
+
let line;
|
|
397
|
+
if (interactive) {
|
|
398
|
+
output.write('\n');
|
|
399
|
+
const got = await readBoxedLine({
|
|
400
|
+
input,
|
|
401
|
+
output,
|
|
402
|
+
history: typed,
|
|
403
|
+
onInterrupt,
|
|
404
|
+
});
|
|
405
|
+
line = got.value;
|
|
406
|
+
} else {
|
|
407
|
+
line = queueIndex < queued.length ? queued[queueIndex++] : null;
|
|
408
|
+
}
|
|
383
409
|
// Echo a piped prompt so a scripted transcript reads like a session.
|
|
384
410
|
if (!interactive && line !== null && line.trim()) output.write(`› ${line.trim()}
|
|
385
411
|
`);
|
|
@@ -389,6 +415,9 @@ export async function runChat({
|
|
|
389
415
|
if (line === null) break;
|
|
390
416
|
const task = line.trim();
|
|
391
417
|
if (!task) continue;
|
|
418
|
+
// ⚠️ Deduped against the PREVIOUS entry only: pressing Up should walk
|
|
419
|
+
// distinct instructions, not scroll through five copies of `npm test`.
|
|
420
|
+
if (typed[typed.length - 1] !== task) typed.push(task);
|
|
392
421
|
if (QUIT.has(task.toLowerCase())) break;
|
|
393
422
|
|
|
394
423
|
/**
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐⭐ A PERSISTENT INPUT BOX, BECAUSE READLINE CANNOT DRAW ONE ──────────
|
|
3
|
+
*
|
|
4
|
+
* Roman, repeatedly: *"the box that I am typing in right now needs to be real."*
|
|
5
|
+
*
|
|
6
|
+
* ── ⚠️⚠️ WHY READLINE WAS NEVER GOING TO WORK, MEASURED ─────────────────────
|
|
7
|
+
*
|
|
8
|
+
* Pre-drawing a four-sided box and asking `readline.question()` to type inside
|
|
9
|
+
* it produces this on the very first keystroke:
|
|
10
|
+
*
|
|
11
|
+
* \x1b[1G\x1b[0J
|
|
12
|
+
*
|
|
13
|
+
* Column 1, then **clear to end of screen**. Readline owns everything from the
|
|
14
|
+
* cursor down and erases it to redraw the line — so the bottom border and the
|
|
15
|
+
* right edge are gone before the user has typed a second character. No amount of
|
|
16
|
+
* re-drawing wins that fight; it repaints on every key.
|
|
17
|
+
*
|
|
18
|
+
* ⭐ SO THE ANSWER IS TO OWN THE RENDER. This is a small raw-mode line editor:
|
|
19
|
+
* it reads keys, keeps the buffer, and paints three lines itself. That is what
|
|
20
|
+
* every terminal app with a real input box does, and it is why they can have one.
|
|
21
|
+
*
|
|
22
|
+
* ── ⚠️ WHAT IT MUST NOT LOSE ────────────────────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* A half-built line editor is WORSE than a plain prompt: backspace that does
|
|
25
|
+
* nothing, or an arrow key that prints `^[[D`, makes the tool feel broken in a
|
|
26
|
+
* way `› ` never did. So the keys people actually use are all handled —
|
|
27
|
+
* backspace, delete, left/right, home/end, word-left/right, history up/down,
|
|
28
|
+
* Ctrl-C, Ctrl-D, Ctrl-U/K/W — and each is tested.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const ESC = '\x1b';
|
|
32
|
+
const CSI = `${ESC}[`;
|
|
33
|
+
|
|
34
|
+
/** Keys that are not text. Kept as one table so the handler stays readable. */
|
|
35
|
+
const KEY = Object.freeze({
|
|
36
|
+
ENTER: '\r',
|
|
37
|
+
NEWLINE: '\n',
|
|
38
|
+
BACKSPACE: '\x7f',
|
|
39
|
+
BACKSPACE_ALT: '\b',
|
|
40
|
+
CTRL_C: '\x03',
|
|
41
|
+
CTRL_D: '\x04',
|
|
42
|
+
CTRL_U: '\x15',
|
|
43
|
+
CTRL_K: '\x0b',
|
|
44
|
+
CTRL_W: '\x17',
|
|
45
|
+
CTRL_A: '\x01',
|
|
46
|
+
CTRL_E: '\x05',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Visible width of a string, ignoring ANSI. Deliberately simple: this package
|
|
51
|
+
* has no dependencies and a full grapheme/east-asian-width implementation is a
|
|
52
|
+
* library. It is correct for the ASCII and box characters we draw, and errs by
|
|
53
|
+
* over-counting a wide glyph rather than under — which wraps early rather than
|
|
54
|
+
* overflowing the border.
|
|
55
|
+
*/
|
|
56
|
+
export function visibleWidth(s) {
|
|
57
|
+
return String(s ?? '').replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').length;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Render the three lines of the box for a given buffer.
|
|
62
|
+
*
|
|
63
|
+
* Exported so the layout can be asserted without a terminal — the render and
|
|
64
|
+
* the key handling are separately testable, which is the only way a thing like
|
|
65
|
+
* this stays correct.
|
|
66
|
+
*
|
|
67
|
+
* @returns {{lines: string[], cursorColumn: number}} 1-based cursor column
|
|
68
|
+
*/
|
|
69
|
+
export function renderBox({ value = '', cursor = 0, columns = 80, prompt = '› ' } = {}) {
|
|
70
|
+
const width = Math.max(20, Math.min(100, columns - 1));
|
|
71
|
+
const inner = width - 2;
|
|
72
|
+
const promptWidth = visibleWidth(prompt);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* ⚠️ THE VIEW SCROLLS, THE BUFFER DOES NOT. A long line must not wrap — a
|
|
76
|
+
* wrapped line pushes the bottom border down and the box stops being a box.
|
|
77
|
+
* So the buffer is windowed around the cursor and the border stays put, which
|
|
78
|
+
* is what every real input does.
|
|
79
|
+
*/
|
|
80
|
+
const room = inner - promptWidth - 1;
|
|
81
|
+
let start = 0;
|
|
82
|
+
if (cursor > room) start = cursor - room;
|
|
83
|
+
const shown = value.slice(start, start + room);
|
|
84
|
+
|
|
85
|
+
const body = `${prompt}${shown}`;
|
|
86
|
+
const pad = ' '.repeat(Math.max(0, inner - visibleWidth(body)));
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
lines: [
|
|
90
|
+
`╭${'─'.repeat(width - 2)}╮`,
|
|
91
|
+
`│${body}${pad}│`,
|
|
92
|
+
`╰${'─'.repeat(width - 2)}╯`,
|
|
93
|
+
],
|
|
94
|
+
// 1-based: the │, then the prompt, then however far into the shown text.
|
|
95
|
+
cursorColumn: 1 + 1 + promptWidth + (cursor - start),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Apply one keypress to the editor state.
|
|
101
|
+
*
|
|
102
|
+
* ⚠️ PURE, AND THAT IS THE WHOLE POINT. Every key can be tested without a TTY,
|
|
103
|
+
* without timing, and without a terminal to inspect afterwards. The half of this
|
|
104
|
+
* module that touches the terminal does nothing but paint what this returns.
|
|
105
|
+
*
|
|
106
|
+
* @returns {{value, cursor, historyIndex, done?: 'submit'|'cancel'|'eof'}}
|
|
107
|
+
*/
|
|
108
|
+
export function applyKey(state, key) {
|
|
109
|
+
const { value, cursor, history = [], historyIndex = history.length } = state;
|
|
110
|
+
/**
|
|
111
|
+
* ⚠️ `history` IS CARRIED THROUGH, AND ITS ABSENCE WAS A REAL BUG. The first
|
|
112
|
+
* version returned only `{value, cursor, historyIndex}` — so the history array
|
|
113
|
+
* was dropped by the FIRST keystroke, and pressing Up afterwards silently did
|
|
114
|
+
* nothing. It looked like the history feature was unimplemented rather than
|
|
115
|
+
* like state was being lost, which is exactly the kind of bug a pure function
|
|
116
|
+
* makes visible and a stateful one hides.
|
|
117
|
+
*/
|
|
118
|
+
const keep = (over = {}) => ({ value, cursor, history, historyIndex, draft: state.draft, ...over });
|
|
119
|
+
|
|
120
|
+
if (key === KEY.ENTER || key === KEY.NEWLINE) return keep({ done: 'submit' });
|
|
121
|
+
if (key === KEY.CTRL_C) return keep({ done: 'cancel' });
|
|
122
|
+
/**
|
|
123
|
+
* ⚠️ Ctrl-D IS EOF ONLY ON AN EMPTY LINE. On a line with text it is
|
|
124
|
+
* forward-delete — collapsing the two would exit the session when someone
|
|
125
|
+
* meant to delete a character, which is a data-loss-shaped surprise.
|
|
126
|
+
*/
|
|
127
|
+
if (key === KEY.CTRL_D) {
|
|
128
|
+
if (value.length === 0) return keep({ done: 'eof' });
|
|
129
|
+
return keep({ value: value.slice(0, cursor) + value.slice(cursor + 1) });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (key === KEY.BACKSPACE || key === KEY.BACKSPACE_ALT) {
|
|
133
|
+
if (cursor === 0) return keep();
|
|
134
|
+
return keep({ value: value.slice(0, cursor - 1) + value.slice(cursor), cursor: cursor - 1 });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (key === KEY.CTRL_U) return keep({ value: value.slice(cursor), cursor: 0 });
|
|
138
|
+
if (key === KEY.CTRL_K) return keep({ value: value.slice(0, cursor) });
|
|
139
|
+
if (key === KEY.CTRL_A) return keep({ cursor: 0 });
|
|
140
|
+
if (key === KEY.CTRL_E) return keep({ cursor: value.length });
|
|
141
|
+
|
|
142
|
+
if (key === KEY.CTRL_W) {
|
|
143
|
+
const upto = value.slice(0, cursor);
|
|
144
|
+
const cut = upto.replace(/\s*\S+$/, '');
|
|
145
|
+
return keep({ value: cut + value.slice(cursor), cursor: cut.length });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Arrows and Home/End arrive as escape sequences.
|
|
149
|
+
if (key === `${CSI}D`) return keep({ cursor: Math.max(0, cursor - 1) });
|
|
150
|
+
if (key === `${CSI}C`) return keep({ cursor: Math.min(value.length, cursor + 1) });
|
|
151
|
+
if (key === `${CSI}H` || key === `${CSI}1~`) return keep({ cursor: 0 });
|
|
152
|
+
if (key === `${CSI}F` || key === `${CSI}4~`) return keep({ cursor: value.length });
|
|
153
|
+
if (key === `${CSI}3~`) return keep({ value: value.slice(0, cursor) + value.slice(cursor + 1) });
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* History. ⚠️ The index may sit one PAST the end — that position is "the line
|
|
157
|
+
* I was typing", so walking up and back down returns what you had rather than
|
|
158
|
+
* silently eating it.
|
|
159
|
+
*/
|
|
160
|
+
if (key === `${CSI}A`) {
|
|
161
|
+
if (historyIndex === 0 || history.length === 0) return keep();
|
|
162
|
+
const i = historyIndex - 1;
|
|
163
|
+
/**
|
|
164
|
+
* ⚠️ THE DRAFT IS SAVED ON THE WAY UP. Leaving it behind means a half-typed
|
|
165
|
+
* line is destroyed by a single Up press — the user glances at what they ran
|
|
166
|
+
* before, comes back, and their sentence is gone. Losing typed input to a
|
|
167
|
+
* navigation key is the least forgivable bug a line editor can have.
|
|
168
|
+
*/
|
|
169
|
+
const draft = historyIndex === history.length ? value : state.draft;
|
|
170
|
+
return keep({ value: history[i], cursor: history[i].length, historyIndex: i, draft });
|
|
171
|
+
}
|
|
172
|
+
if (key === `${CSI}B`) {
|
|
173
|
+
if (historyIndex >= history.length) return keep({ draft: state.draft });
|
|
174
|
+
const i = historyIndex + 1;
|
|
175
|
+
const next = i === history.length ? (state.draft ?? '') : history[i];
|
|
176
|
+
return keep({ value: next, cursor: next.length, historyIndex: i, draft: state.draft });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* ⚠️ EVERYTHING ELSE CONTROL-SHAPED IS DROPPED, NOT INSERTED. An unhandled
|
|
181
|
+
* escape sequence typed into the buffer shows the user `^[[5~` and looks like
|
|
182
|
+
* the tool is broken — the one impression a new line editor cannot afford.
|
|
183
|
+
*/
|
|
184
|
+
if (key.startsWith(ESC)) return keep();
|
|
185
|
+
if (key.length === 1 && key < ' ') return keep();
|
|
186
|
+
|
|
187
|
+
return keep({ value: value.slice(0, cursor) + key + value.slice(cursor), cursor: cursor + key.length });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Split a raw chunk into keys. A paste arrives as one chunk and an arrow key as
|
|
192
|
+
* three bytes, so neither "one byte per key" nor "one chunk per key" is right.
|
|
193
|
+
*/
|
|
194
|
+
export function splitKeys(chunk) {
|
|
195
|
+
const s = String(chunk);
|
|
196
|
+
const keys = [];
|
|
197
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
198
|
+
if (s[i] === ESC) {
|
|
199
|
+
const m = /^\x1b\[[0-9;]*[A-Za-z~]/.exec(s.slice(i));
|
|
200
|
+
if (m) { keys.push(m[0]); i += m[0].length - 1; continue; }
|
|
201
|
+
}
|
|
202
|
+
keys.push(s[i]);
|
|
203
|
+
}
|
|
204
|
+
return keys;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Draw the box and park the cursor inside it.
|
|
209
|
+
*
|
|
210
|
+
* ⚠️ THE CURSOR IS HIDDEN WHILE PAINTING. Without it, the cursor visibly darts
|
|
211
|
+
* to the end of each border as it is written — which reads as a flicker and is
|
|
212
|
+
* the difference between a rendered box and a drawn one.
|
|
213
|
+
*/
|
|
214
|
+
export function paint(output, state, { first = false } = {}) {
|
|
215
|
+
const { lines, cursorColumn } = renderBox(state);
|
|
216
|
+
const up = first ? '' : `${CSI}3A`;
|
|
217
|
+
output.write(
|
|
218
|
+
`${CSI}?25l${up}\r${CSI}0J${lines.join('\n')}\n` +
|
|
219
|
+
`${CSI}2A\r${CSI}${cursorColumn}G${CSI}?25h`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* ── ⭐⭐⭐ THE ONLY PART THAT TOUCHES A TERMINAL ─────────────────────────────
|
|
225
|
+
*
|
|
226
|
+
* Reads one line inside the box. Everything decision-shaped lives in `applyKey`
|
|
227
|
+
* and `renderBox`, which are pure and fully tested; this function does nothing
|
|
228
|
+
* but move bytes and paint what they return.
|
|
229
|
+
*
|
|
230
|
+
* @returns {Promise<{value: string|null, reason: 'submit'|'cancel'|'eof'}>}
|
|
231
|
+
*/
|
|
232
|
+
export function readBoxedLine({ input, output, history = [], onInterrupt = null, prompt = '› ' }) {
|
|
233
|
+
return new Promise((resolve) => {
|
|
234
|
+
let state = { value: '', cursor: 0, history, historyIndex: history.length, draft: '' };
|
|
235
|
+
const columns = () => output.columns ?? process.stdout.columns ?? 80;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* ⚠️ RAW MODE IS RESTORED ON EVERY EXIT PATH, INCLUDING THE UNHAPPY ONES.
|
|
239
|
+
* A process that leaves the terminal in raw mode hands the user a shell with
|
|
240
|
+
* no echo and no line editing — they have to type `reset` blind. That is the
|
|
241
|
+
* single worst thing a CLI can do to somebody's session.
|
|
242
|
+
*/
|
|
243
|
+
let finished = false;
|
|
244
|
+
const finish = (value, reason) => {
|
|
245
|
+
if (finished) return;
|
|
246
|
+
finished = true;
|
|
247
|
+
input.off('data', onData);
|
|
248
|
+
input.off('end', onEnd);
|
|
249
|
+
try { input.setRawMode?.(false); } catch { /* not a TTY any more */ }
|
|
250
|
+
output.write('\n');
|
|
251
|
+
resolve({ value, reason });
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const onEnd = () => finish(null, 'eof');
|
|
255
|
+
|
|
256
|
+
const onData = (chunk) => {
|
|
257
|
+
for (const key of splitKeys(chunk)) {
|
|
258
|
+
const next = applyKey(state, key);
|
|
259
|
+
if (next.done === 'submit') {
|
|
260
|
+
state = next;
|
|
261
|
+
// Repaint once so the committed line is what stays on screen.
|
|
262
|
+
paint(output, { ...state, columns: columns() });
|
|
263
|
+
return finish(state.value, 'submit');
|
|
264
|
+
}
|
|
265
|
+
if (next.done === 'cancel') {
|
|
266
|
+
/**
|
|
267
|
+
* ⚠️ Ctrl-C ON A NON-EMPTY LINE CLEARS IT; on an empty one it means
|
|
268
|
+
* "stop". Anything else makes the first Ctrl-C — the one people press
|
|
269
|
+
* to abandon a sentence — quit the whole session.
|
|
270
|
+
*/
|
|
271
|
+
/**
|
|
272
|
+
* ⚠️⚠️ Ctrl-C NEVER ENDS THE READ — it clears the line and hands the
|
|
273
|
+
* event on. The first version finished with `cancel` on an empty line,
|
|
274
|
+
* which ended the session on the FIRST press: there was then no prompt
|
|
275
|
+
* for a second Ctrl-C to arrive at, so the "press again to quit"
|
|
276
|
+
* escape hatch could never fire. `onInterrupt` owns that decision (see
|
|
277
|
+
* `interrupt.mjs`), and it can only own it if it keeps being called.
|
|
278
|
+
*
|
|
279
|
+
* ⭐ It is also what every shell does: Ctrl-C gives you a fresh line.
|
|
280
|
+
* Quitting is `exit`, or Ctrl-D on an empty one.
|
|
281
|
+
*/
|
|
282
|
+
state = { ...state, value: '', cursor: 0, historyIndex: history.length, draft: '' };
|
|
283
|
+
onInterrupt?.();
|
|
284
|
+
paint(output, { ...state, columns: columns() });
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (next.done === 'eof') return finish(null, 'eof');
|
|
288
|
+
state = next;
|
|
289
|
+
paint(output, { ...state, columns: columns() });
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
try { input.setRawMode?.(true); } catch { /* not a TTY */ }
|
|
294
|
+
input.resume?.();
|
|
295
|
+
paint(output, { ...state, columns: columns() }, { first: true });
|
|
296
|
+
input.on('data', onData);
|
|
297
|
+
input.once('end', onEnd);
|
|
298
|
+
});
|
|
299
|
+
}
|