acuvo-code 0.5.0 → 0.5.2
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 +4 -4
- package/lib/chat.mjs +16 -1
- package/lib/input-box.mjs +477 -409
- package/package.json +1 -1
package/ENTERPRISE.md
CHANGED
|
@@ -189,7 +189,7 @@ 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
191
|
one went stale in turn.** The package ships **120 files — 118 in `lib/`, 2 in
|
|
192
|
-
`bin/` — about
|
|
192
|
+
`bin/` — about 78328 lines**, with **237 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
|
|
@@ -203,7 +203,7 @@ exactly the kind of number this section warns about.
|
|
|
203
203
|
allowlist also carries **`test/`, `ENTERPRISE.md` and `ROADMAP.md`**, and the published
|
|
204
204
|
tarball is **265 files, 5.1 MB unpacked — 182 of them tests against 98 of `lib/`.** That is
|
|
205
205
|
deliberate, not drift (commit `ed08f2710`, *"ship the tests, and add CI that would have
|
|
206
|
-
caught the false green"*): a document that invites you to audit
|
|
206
|
+
caught the false green"*): a document that invites you to audit 78328 lines and then ships
|
|
207
207
|
you the source without the tests is asking to be taken on trust, which is the one thing this
|
|
208
208
|
file refuses to ask for. ⭐ **Run them yourself: `npm test` inside the installed package.**
|
|
209
209
|
The stale sentence is the more interesting failure — it under-claimed, so nobody would ever
|
|
@@ -892,8 +892,8 @@ For completeness, the properties none of them offers:
|
|
|
892
892
|
(`lib/media.mjs`), and generates imagery with no configuration and no account
|
|
893
893
|
(`lib/imagegen.mjs`) — critiqued before it is accepted, and reported as unreviewed when
|
|
894
894
|
no critic is available.
|
|
895
|
-
- ⭐ **Zero dependencies.** The entire auditable surface is
|
|
896
|
-
and there is no `node_modules` behind it. (Counted 2026-08-
|
|
895
|
+
- ⭐ **Zero dependencies.** The entire auditable surface is 120 files and 78328 lines,
|
|
896
|
+
and there is no `node_modules` behind it. (Counted 2026-08-22 from
|
|
897
897
|
`lib/*.mjs` + `bin/*.mjs`; `test/docs-truth.test.mjs` fails the build if this number
|
|
898
898
|
drifts, which is why it went 18 → 41 → 46 → 52 → 53 → 57 → 60 → 61 → 62 → 65 → 66 → 69 → 70 → 71 → 72 → 73 → 80 → 84 → 90 → 100 → 101 → 102 → 103 → 107 → 108 → 111 as modules landed (111 = the three that were WRITTEN and imported by nothing — `python.mjs`, `cache-floor.mjs`, `plan-coherence.mjs`; 108 = `warm-provider.mjs`, which keeps a session on the upstream that holds its prompt cache; 107 = `login.mjs`, the command that stores an Acuvo credential — until it existed, `writeAccount` was called by nothing and every user fell through to BYOK). ⚠️ Two of those three landed on this count while remaining UNREACHABLE, which is the sharpest illustration this document has that a file count is a claim about bytes, never about capability. ⭐ A
|
|
899
899
|
count that fails the build is the only kind that stays true — this one has now caught its own
|
package/lib/chat.mjs
CHANGED
|
@@ -353,7 +353,14 @@ export async function runChat({
|
|
|
353
353
|
* quits) has to arrive here too, or the escape hatch is a single-use one.
|
|
354
354
|
*/
|
|
355
355
|
if (rl) rl.on('SIGINT', () => { onInterrupt(); });
|
|
356
|
-
|
|
356
|
+
/**
|
|
357
|
+
* ⚠️⚠️ THE BANNER IS DEFERRED UNTIL AFTER THE PIN, AND THE ORDER IS NOT
|
|
358
|
+
* COSMETIC. `pinRegion` CLEARS the screen so the transcript starts at the top
|
|
359
|
+
* of a clean one — printed before that, the banner is erased by the very
|
|
360
|
+
* thing meant to sit beneath it, which is exactly what a screenshot showed:
|
|
361
|
+
* a banner cut in half with the conversation crammed at the bottom.
|
|
362
|
+
*/
|
|
363
|
+
const writeBanner = () => { if (banner) output.write(`${banner}\n`); };
|
|
357
364
|
// ⚠️ `/help` IS ADVERTISED IN THE ONE LINE EVERY SESSION PRINTS. A command
|
|
358
365
|
// surface nobody is told about is the same defect item 14 closed for `--help`:
|
|
359
366
|
// the feature worked and nothing a stranger would read mentioned it.
|
|
@@ -398,6 +405,13 @@ export async function runChat({
|
|
|
398
405
|
process.once('SIGTERM', releasePin);
|
|
399
406
|
}
|
|
400
407
|
|
|
408
|
+
/**
|
|
409
|
+
* ⭐ NOW the banner, at the top of a screen the pin has just cleared, with the
|
|
410
|
+
* conversation free to grow downward beneath it — which is the direction every
|
|
411
|
+
* scrollback in existence runs.
|
|
412
|
+
*/
|
|
413
|
+
writeBanner();
|
|
414
|
+
|
|
401
415
|
try {
|
|
402
416
|
for (;;) {
|
|
403
417
|
/**
|
|
@@ -425,6 +439,7 @@ export async function runChat({
|
|
|
425
439
|
if (interactive) {
|
|
426
440
|
output.write('\n');
|
|
427
441
|
const got = await readBoxedLine({
|
|
442
|
+
atRow: pin.enabled ? pin.bottom + 1 : 0,
|
|
428
443
|
input,
|
|
429
444
|
output,
|
|
430
445
|
history: typed,
|
package/lib/input-box.mjs
CHANGED
|
@@ -1,409 +1,477 @@
|
|
|
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
|
-
/**
|
|
71
|
-
* ── ⚠️ FULL WIDTH. THE 100-COLUMN CAP WAS WRONG AND IT LOOKED WRONG ────────
|
|
72
|
-
*
|
|
73
|
-
* Roman, from a screenshot: *"ours isn't the entire width."* In a ~200-column
|
|
74
|
-
* terminal a 100-column box reads as a half-finished element rather than as a
|
|
75
|
-
* deliberate measure — it is the input, and the input should be as wide as the
|
|
76
|
-
* place you are typing.
|
|
77
|
-
*
|
|
78
|
-
* ⚠️ `columns - 1`, NOT `columns`. A box drawn to the very last column makes
|
|
79
|
-
* many terminals wrap to the next row the moment the final border character is
|
|
80
|
-
* written, which pushes everything down by one and breaks the cursor
|
|
81
|
-
* arithmetic for the rest of the session.
|
|
82
|
-
*/
|
|
83
|
-
const width = Math.max(20, columns - 1);
|
|
84
|
-
const inner = width - 2;
|
|
85
|
-
const promptWidth = visibleWidth(prompt);
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* ⚠️ THE VIEW SCROLLS, THE BUFFER DOES NOT. A long line must not wrap — a
|
|
89
|
-
* wrapped line pushes the bottom border down and the box stops being a box.
|
|
90
|
-
* So the buffer is windowed around the cursor and the border stays put, which
|
|
91
|
-
* is what every real input does.
|
|
92
|
-
*/
|
|
93
|
-
const room = inner - promptWidth - 1;
|
|
94
|
-
let start = 0;
|
|
95
|
-
if (cursor > room) start = cursor - room;
|
|
96
|
-
const shown = value.slice(start, start + room);
|
|
97
|
-
|
|
98
|
-
const body = `${prompt}${shown}`;
|
|
99
|
-
const pad = ' '.repeat(Math.max(0,
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
*
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (key ===
|
|
165
|
-
if (key ===
|
|
166
|
-
if (key ===
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
}
|
|
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
|
+
/**
|
|
71
|
+
* ── ⚠️ FULL WIDTH. THE 100-COLUMN CAP WAS WRONG AND IT LOOKED WRONG ────────
|
|
72
|
+
*
|
|
73
|
+
* Roman, from a screenshot: *"ours isn't the entire width."* In a ~200-column
|
|
74
|
+
* terminal a 100-column box reads as a half-finished element rather than as a
|
|
75
|
+
* deliberate measure — it is the input, and the input should be as wide as the
|
|
76
|
+
* place you are typing.
|
|
77
|
+
*
|
|
78
|
+
* ⚠️ `columns - 1`, NOT `columns`. A box drawn to the very last column makes
|
|
79
|
+
* many terminals wrap to the next row the moment the final border character is
|
|
80
|
+
* written, which pushes everything down by one and breaks the cursor
|
|
81
|
+
* arithmetic for the rest of the session.
|
|
82
|
+
*/
|
|
83
|
+
const width = Math.max(20, columns - 1);
|
|
84
|
+
const inner = width - 2;
|
|
85
|
+
const promptWidth = visibleWidth(prompt);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* ⚠️ THE VIEW SCROLLS, THE BUFFER DOES NOT. A long line must not wrap — a
|
|
89
|
+
* wrapped line pushes the bottom border down and the box stops being a box.
|
|
90
|
+
* So the buffer is windowed around the cursor and the border stays put, which
|
|
91
|
+
* is what every real input does.
|
|
92
|
+
*/
|
|
93
|
+
const room = inner - promptWidth - 1;
|
|
94
|
+
let start = 0;
|
|
95
|
+
if (cursor > room) start = cursor - room;
|
|
96
|
+
const shown = value.slice(start, start + room);
|
|
97
|
+
|
|
98
|
+
const body = `${prompt}${shown}`;
|
|
99
|
+
const pad = ' '.repeat(Math.max(0, width - visibleWidth(body)));
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* ── ⚠️⚠️ A RULE AND A PROMPT — NOT A BOX. THE SCREENSHOT SETTLED IT ─────────
|
|
103
|
+
*
|
|
104
|
+
* I built a four-sided box because Roman said "copy Claude's box exactly", and
|
|
105
|
+
* a screenshot of the real thing shows there IS no box: a horizontal rule
|
|
106
|
+
* across the full width, then `❯ ` beneath it. The submitted line gets a
|
|
107
|
+
* subtle highlight bar rather than a border.
|
|
108
|
+
*
|
|
109
|
+
* ⭐ AND THE RULE IS WHY IT LOOKS SETTLED RATHER THAN DRAWN. Two borders and
|
|
110
|
+
* two side walls make the input an object floating in the terminal; one rule
|
|
111
|
+
* makes it the bottom of the page. Roman: *"ours is organised weirdly."*
|
|
112
|
+
*
|
|
113
|
+
* ⚠️ Two rows, not three — so the reserved region shrinks with it and the
|
|
114
|
+
* transcript gets a row back.
|
|
115
|
+
*/
|
|
116
|
+
return {
|
|
117
|
+
lines: [
|
|
118
|
+
'─'.repeat(width),
|
|
119
|
+
`${body}${pad}`,
|
|
120
|
+
],
|
|
121
|
+
// 1-based, and there is no left border to skip any more.
|
|
122
|
+
cursorColumn: 1 + promptWidth + (cursor - start),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Apply one keypress to the editor state.
|
|
128
|
+
*
|
|
129
|
+
* ⚠️ PURE, AND THAT IS THE WHOLE POINT. Every key can be tested without a TTY,
|
|
130
|
+
* without timing, and without a terminal to inspect afterwards. The half of this
|
|
131
|
+
* module that touches the terminal does nothing but paint what this returns.
|
|
132
|
+
*
|
|
133
|
+
* @returns {{value, cursor, historyIndex, done?: 'submit'|'cancel'|'eof'}}
|
|
134
|
+
*/
|
|
135
|
+
export function applyKey(state, key) {
|
|
136
|
+
const { value, cursor, history = [], historyIndex = history.length } = state;
|
|
137
|
+
/**
|
|
138
|
+
* ⚠️ `history` IS CARRIED THROUGH, AND ITS ABSENCE WAS A REAL BUG. The first
|
|
139
|
+
* version returned only `{value, cursor, historyIndex}` — so the history array
|
|
140
|
+
* was dropped by the FIRST keystroke, and pressing Up afterwards silently did
|
|
141
|
+
* nothing. It looked like the history feature was unimplemented rather than
|
|
142
|
+
* like state was being lost, which is exactly the kind of bug a pure function
|
|
143
|
+
* makes visible and a stateful one hides.
|
|
144
|
+
*/
|
|
145
|
+
const keep = (over = {}) => ({ value, cursor, history, historyIndex, draft: state.draft, ...over });
|
|
146
|
+
|
|
147
|
+
if (key === KEY.ENTER || key === KEY.NEWLINE) return keep({ done: 'submit' });
|
|
148
|
+
if (key === KEY.CTRL_C) return keep({ done: 'cancel' });
|
|
149
|
+
/**
|
|
150
|
+
* ⚠️ Ctrl-D IS EOF ONLY ON AN EMPTY LINE. On a line with text it is
|
|
151
|
+
* forward-delete — collapsing the two would exit the session when someone
|
|
152
|
+
* meant to delete a character, which is a data-loss-shaped surprise.
|
|
153
|
+
*/
|
|
154
|
+
if (key === KEY.CTRL_D) {
|
|
155
|
+
if (value.length === 0) return keep({ done: 'eof' });
|
|
156
|
+
return keep({ value: value.slice(0, cursor) + value.slice(cursor + 1) });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (key === KEY.BACKSPACE || key === KEY.BACKSPACE_ALT) {
|
|
160
|
+
if (cursor === 0) return keep();
|
|
161
|
+
return keep({ value: value.slice(0, cursor - 1) + value.slice(cursor), cursor: cursor - 1 });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (key === KEY.CTRL_U) return keep({ value: value.slice(cursor), cursor: 0 });
|
|
165
|
+
if (key === KEY.CTRL_K) return keep({ value: value.slice(0, cursor) });
|
|
166
|
+
if (key === KEY.CTRL_A) return keep({ cursor: 0 });
|
|
167
|
+
if (key === KEY.CTRL_E) return keep({ cursor: value.length });
|
|
168
|
+
|
|
169
|
+
if (key === KEY.CTRL_W) {
|
|
170
|
+
const upto = value.slice(0, cursor);
|
|
171
|
+
const cut = upto.replace(/\s*\S+$/, '');
|
|
172
|
+
return keep({ value: cut + value.slice(cursor), cursor: cut.length });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Arrows and Home/End arrive as escape sequences.
|
|
176
|
+
if (key === `${CSI}D`) return keep({ cursor: Math.max(0, cursor - 1) });
|
|
177
|
+
if (key === `${CSI}C`) return keep({ cursor: Math.min(value.length, cursor + 1) });
|
|
178
|
+
if (key === `${CSI}H` || key === `${CSI}1~`) return keep({ cursor: 0 });
|
|
179
|
+
if (key === `${CSI}F` || key === `${CSI}4~`) return keep({ cursor: value.length });
|
|
180
|
+
if (key === `${CSI}3~`) return keep({ value: value.slice(0, cursor) + value.slice(cursor + 1) });
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* History. ⚠️ The index may sit one PAST the end — that position is "the line
|
|
184
|
+
* I was typing", so walking up and back down returns what you had rather than
|
|
185
|
+
* silently eating it.
|
|
186
|
+
*/
|
|
187
|
+
if (key === `${CSI}A`) {
|
|
188
|
+
if (historyIndex === 0 || history.length === 0) return keep();
|
|
189
|
+
const i = historyIndex - 1;
|
|
190
|
+
/**
|
|
191
|
+
* ⚠️ THE DRAFT IS SAVED ON THE WAY UP. Leaving it behind means a half-typed
|
|
192
|
+
* line is destroyed by a single Up press — the user glances at what they ran
|
|
193
|
+
* before, comes back, and their sentence is gone. Losing typed input to a
|
|
194
|
+
* navigation key is the least forgivable bug a line editor can have.
|
|
195
|
+
*/
|
|
196
|
+
const draft = historyIndex === history.length ? value : state.draft;
|
|
197
|
+
return keep({ value: history[i], cursor: history[i].length, historyIndex: i, draft });
|
|
198
|
+
}
|
|
199
|
+
if (key === `${CSI}B`) {
|
|
200
|
+
if (historyIndex >= history.length) return keep({ draft: state.draft });
|
|
201
|
+
const i = historyIndex + 1;
|
|
202
|
+
const next = i === history.length ? (state.draft ?? '') : history[i];
|
|
203
|
+
return keep({ value: next, cursor: next.length, historyIndex: i, draft: state.draft });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* ⚠️ EVERYTHING ELSE CONTROL-SHAPED IS DROPPED, NOT INSERTED. An unhandled
|
|
208
|
+
* escape sequence typed into the buffer shows the user `^[[5~` and looks like
|
|
209
|
+
* the tool is broken — the one impression a new line editor cannot afford.
|
|
210
|
+
*/
|
|
211
|
+
if (key.startsWith(ESC)) return keep();
|
|
212
|
+
if (key.length === 1 && key < ' ') return keep();
|
|
213
|
+
|
|
214
|
+
return keep({ value: value.slice(0, cursor) + key + value.slice(cursor), cursor: cursor + key.length });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Split a raw chunk into keys. A paste arrives as one chunk and an arrow key as
|
|
219
|
+
* three bytes, so neither "one byte per key" nor "one chunk per key" is right.
|
|
220
|
+
*/
|
|
221
|
+
export function splitKeys(chunk) {
|
|
222
|
+
const s = String(chunk);
|
|
223
|
+
const keys = [];
|
|
224
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
225
|
+
if (s[i] === ESC) {
|
|
226
|
+
const m = /^\x1b\[[0-9;]*[A-Za-z~]/.exec(s.slice(i));
|
|
227
|
+
if (m) { keys.push(m[0]); i += m[0].length - 1; continue; }
|
|
228
|
+
}
|
|
229
|
+
keys.push(s[i]);
|
|
230
|
+
}
|
|
231
|
+
return keys;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Draw the box and park the cursor inside it.
|
|
236
|
+
*
|
|
237
|
+
* ⚠️ THE CURSOR IS HIDDEN WHILE PAINTING. Without it, the cursor visibly darts
|
|
238
|
+
* to the end of each border as it is written — which reads as a flicker and is
|
|
239
|
+
* the difference between a rendered box and a drawn one.
|
|
240
|
+
*/
|
|
241
|
+
export function paint(output, state, { first = false, atRow = 0 } = {}) {
|
|
242
|
+
const { lines, cursorColumn } = renderBox(state);
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* ── ⭐⭐⭐ PINNED: DRAW AT AN ABSOLUTE ROW, NOT WHEREVER THE CURSOR IS ───────
|
|
246
|
+
*
|
|
247
|
+
* Roman, from a screenshot: *"the text is going underneath instead of above,
|
|
248
|
+
* and it makes a new box."* Two boxes on screen, output between them.
|
|
249
|
+
*
|
|
250
|
+
* The cause: `pinRegion` reserved the bottom rows but this function still drew
|
|
251
|
+
* RELATIVE to the cursor. So the box was painted inline, scrolled away with
|
|
252
|
+
* the transcript, and the next turn painted a fresh one lower down — the
|
|
253
|
+
* reserved rows sat empty while the box wandered.
|
|
254
|
+
*
|
|
255
|
+
* ⭐ When pinned, the box has a FIXED HOME. `ESC[{row};1H` puts it there every
|
|
256
|
+
* time, so output scrolling above it cannot move it and no second box can
|
|
257
|
+
* exist.
|
|
258
|
+
*/
|
|
259
|
+
if (atRow > 0) {
|
|
260
|
+
const rows = lines.map((l, i) => `${CSI}${atRow + i};1H${CSI}2K${l}`).join('');
|
|
261
|
+
output.write(`${CSI}?25l${rows}${CSI}${atRow + 1};${cursorColumn}H${CSI}?25h`);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* ── ⚠️⚠️ THE CURSOR MATH, AND MY FIRST VERSION ATE THE SCREEN ──────────────
|
|
267
|
+
*
|
|
268
|
+
* Roman: *"it moves upwards every time you type a character then deletes the
|
|
269
|
+
* design you did."* Exactly right, and the arithmetic says why.
|
|
270
|
+
*
|
|
271
|
+
* After a paint the cursor rests on the INPUT line — line 2 of 3, not below
|
|
272
|
+
* the box. The first version began each repaint with `ESC[3A`, which is where
|
|
273
|
+
* it would be if the cursor were below. From line 2, moving up 3 lands ONE
|
|
274
|
+
* LINE ABOVE the top border — and the `ESC[0J` that follows clears from there
|
|
275
|
+
* to the bottom of the screen. So every keystroke crept upward and erased
|
|
276
|
+
* another line of the banner.
|
|
277
|
+
*
|
|
278
|
+
* ⭐ THE INVARIANT, WRITTEN DOWN BECAUSE IT IS THE WHOLE FUNCTION: this
|
|
279
|
+
* routine ENTERS with the cursor on the input line and LEAVES it there. So a
|
|
280
|
+
* repaint moves up exactly ONE line to reach the top border, and the final
|
|
281
|
+
* reposition moves up exactly one from the last line written.
|
|
282
|
+
*
|
|
283
|
+
* ╭────────╮ <- line 1 ESC[1A from the input line reaches here
|
|
284
|
+
* │› … │ <- line 2 cursor lives here, in and out
|
|
285
|
+
* ╰────────╯ <- line 3 cursor is here after writing; ESC[1A returns
|
|
286
|
+
*
|
|
287
|
+
* ⚠️ NO TRAILING NEWLINE. Writing one after the last border scrolls the
|
|
288
|
+
* viewport when the box is at the bottom of the screen, and every subsequent
|
|
289
|
+
* `up` is then off by a row for the rest of the session.
|
|
290
|
+
*/
|
|
291
|
+
/**
|
|
292
|
+
* ⚠️ THE ROW MATH FOLLOWS THE ROW COUNT, and it changed when the box became a
|
|
293
|
+
* rule. With TWO rows the cursor already ends on the prompt line after the
|
|
294
|
+
* write, so there is no reposition to make — the old `ESC[1A` was correct for
|
|
295
|
+
* three rows and would now land on the RULE, one row too high, which is the
|
|
296
|
+
* same off-by-one that walked the box up the screen before.
|
|
297
|
+
*/
|
|
298
|
+
const home = first ? '' : `\r${CSI}${lines.length - 1}A`;
|
|
299
|
+
output.write(
|
|
300
|
+
`${CSI}?25l${home}${CSI}0J${lines.join('\n')}` +
|
|
301
|
+
`\r${CSI}${cursorColumn}G${CSI}?25h`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* ── ⭐⭐⭐ THE ONLY PART THAT TOUCHES A TERMINAL ─────────────────────────────
|
|
307
|
+
*
|
|
308
|
+
* Reads one line inside the box. Everything decision-shaped lives in `applyKey`
|
|
309
|
+
* and `renderBox`, which are pure and fully tested; this function does nothing
|
|
310
|
+
* but move bytes and paint what they return.
|
|
311
|
+
*
|
|
312
|
+
* @returns {Promise<{value: string|null, reason: 'submit'|'cancel'|'eof'}>}
|
|
313
|
+
*/
|
|
314
|
+
export function readBoxedLine({ input, output, history = [], onInterrupt = null, prompt = '› ', atRow = 0 }) {
|
|
315
|
+
return new Promise((resolve) => {
|
|
316
|
+
let state = { value: '', cursor: 0, history, historyIndex: history.length, draft: '' };
|
|
317
|
+
const columns = () => output.columns ?? process.stdout.columns ?? 80;
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* ⚠️ RAW MODE IS RESTORED ON EVERY EXIT PATH, INCLUDING THE UNHAPPY ONES.
|
|
321
|
+
* A process that leaves the terminal in raw mode hands the user a shell with
|
|
322
|
+
* no echo and no line editing — they have to type `reset` blind. That is the
|
|
323
|
+
* single worst thing a CLI can do to somebody's session.
|
|
324
|
+
*/
|
|
325
|
+
let finished = false;
|
|
326
|
+
const finish = (value, reason) => {
|
|
327
|
+
if (finished) return;
|
|
328
|
+
finished = true;
|
|
329
|
+
input.off('data', onData);
|
|
330
|
+
input.off('end', onEnd);
|
|
331
|
+
try { input.setRawMode?.(false); } catch { /* not a TTY any more */ }
|
|
332
|
+
if (atRow > 0) {
|
|
333
|
+
/**
|
|
334
|
+
* ── ⭐⭐⭐ PINNED: THE BOX STAYS, THE ANSWER GOES ABOVE IT ─────────────
|
|
335
|
+
*
|
|
336
|
+
* Roman, from a screenshot: *"the text is going underneath instead of
|
|
337
|
+
* above, and it makes a new box."*
|
|
338
|
+
*
|
|
339
|
+
* With a reserved region the box has a permanent home in the bottom
|
|
340
|
+
* rows. What the user typed belongs in the TRANSCRIPT, so it is echoed
|
|
341
|
+
* into the scrolling area and the box is repainted empty — one box,
|
|
342
|
+
* always in the same place, with history flowing upward past it.
|
|
343
|
+
*
|
|
344
|
+
* ⚠️ The cursor is left at the bottom of the SCROLL REGION, so whatever
|
|
345
|
+
* prints next (the reply, an MCP warning, anything) lands above the box
|
|
346
|
+
* instead of on top of it.
|
|
347
|
+
*/
|
|
348
|
+
output.write(`${CSI}${atRow - 1};1H\n${prompt}${value ?? ''}\n`);
|
|
349
|
+
paint(output, { value: '', cursor: 0, columns: output.columns ?? 80 }, { atRow });
|
|
350
|
+
output.write(`${CSI}${atRow - 1};1H`);
|
|
351
|
+
} else {
|
|
352
|
+
/**
|
|
353
|
+
* ── ⚠️⚠️ UNPINNED: MOVE BELOW THE BOX BEFORE ANYTHING ELSE WRITES ────
|
|
354
|
+
*
|
|
355
|
+
* Seen in an earlier screenshot: an MCP warning printed straight ON TOP
|
|
356
|
+
* of the bottom border. The cursor rests on the INPUT line — line 2 of 3
|
|
357
|
+
* — so a bare `\n` lands it on the border row and the next write
|
|
358
|
+
* destroys it. Down one FIRST, then a newline.
|
|
359
|
+
*/
|
|
360
|
+
output.write(`${CSI}1B\n`);
|
|
361
|
+
}
|
|
362
|
+
resolve({ value, reason });
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const onEnd = () => finish(null, 'eof');
|
|
366
|
+
|
|
367
|
+
const onData = (chunk) => {
|
|
368
|
+
for (const key of splitKeys(chunk)) {
|
|
369
|
+
const next = applyKey(state, key);
|
|
370
|
+
if (next.done === 'submit') {
|
|
371
|
+
state = next;
|
|
372
|
+
// Repaint once so the committed line is what stays on screen.
|
|
373
|
+
paint(output, { ...state, columns: columns() }, { atRow });
|
|
374
|
+
return finish(state.value, 'submit');
|
|
375
|
+
}
|
|
376
|
+
if (next.done === 'cancel') {
|
|
377
|
+
/**
|
|
378
|
+
* ⚠️ Ctrl-C ON A NON-EMPTY LINE CLEARS IT; on an empty one it means
|
|
379
|
+
* "stop". Anything else makes the first Ctrl-C — the one people press
|
|
380
|
+
* to abandon a sentence — quit the whole session.
|
|
381
|
+
*/
|
|
382
|
+
/**
|
|
383
|
+
* ⚠️⚠️ Ctrl-C NEVER ENDS THE READ — it clears the line and hands the
|
|
384
|
+
* event on. The first version finished with `cancel` on an empty line,
|
|
385
|
+
* which ended the session on the FIRST press: there was then no prompt
|
|
386
|
+
* for a second Ctrl-C to arrive at, so the "press again to quit"
|
|
387
|
+
* escape hatch could never fire. `onInterrupt` owns that decision (see
|
|
388
|
+
* `interrupt.mjs`), and it can only own it if it keeps being called.
|
|
389
|
+
*
|
|
390
|
+
* ⭐ It is also what every shell does: Ctrl-C gives you a fresh line.
|
|
391
|
+
* Quitting is `exit`, or Ctrl-D on an empty one.
|
|
392
|
+
*/
|
|
393
|
+
state = { ...state, value: '', cursor: 0, historyIndex: history.length, draft: '' };
|
|
394
|
+
onInterrupt?.();
|
|
395
|
+
paint(output, { ...state, columns: columns() }, { atRow });
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (next.done === 'eof') return finish(null, 'eof');
|
|
399
|
+
state = next;
|
|
400
|
+
paint(output, { ...state, columns: columns() }, { atRow });
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
try { input.setRawMode?.(true); } catch { /* not a TTY */ }
|
|
405
|
+
input.resume?.();
|
|
406
|
+
paint(output, { ...state, columns: columns() }, { first: true, atRow });
|
|
407
|
+
input.on('data', onData);
|
|
408
|
+
input.once('end', onEnd);
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* ── ⭐⭐⭐ PINNING THE BOX TO THE BOTTOM OF THE SCREEN ───────────────────────
|
|
414
|
+
*
|
|
415
|
+
* Roman: *"we need that prompt box stuck down the bottom, it is professional."*
|
|
416
|
+
*
|
|
417
|
+
* A terminal can be told to scroll only PART of itself. `ESC[{top};{bottom}r`
|
|
418
|
+
* sets the scrolling region; everything printed scrolls inside it, and the rows
|
|
419
|
+
* below are left alone. Reserve the last three and the box never moves while
|
|
420
|
+
* output flows past above it.
|
|
421
|
+
*
|
|
422
|
+
* ── ⚠️⚠️ THE PART THAT MUST NEVER BE GOT WRONG ──────────────────────────────
|
|
423
|
+
*
|
|
424
|
+
* A process that exits WITHOUT releasing the region leaves the user with a
|
|
425
|
+
* terminal that scrolls inside a box forever, fixable only by typing `reset`
|
|
426
|
+
* blind. That is the same class of harm as leaving raw mode on, and it must be
|
|
427
|
+
* released on every path out — normal exit, Ctrl-C, SIGTERM, and an uncaught
|
|
428
|
+
* throw. `release()` is idempotent and safe to call from all of them.
|
|
429
|
+
*
|
|
430
|
+
* ⚠️ AND IT IS OPT-IN. A reserved region is a claim on somebody's whole screen;
|
|
431
|
+
* off a TTY, in CI, under a pipe or with ACUVO_NO_PIN=1 it is never set.
|
|
432
|
+
*/
|
|
433
|
+
export function pinRegion(output, { rows = 2, env = process.env } = {}) {
|
|
434
|
+
const height = output?.rows ?? process.stdout?.rows ?? 0;
|
|
435
|
+
const enabled = Boolean(output?.isTTY)
|
|
436
|
+
&& height > rows + 4
|
|
437
|
+
&& String(env.ACUVO_NO_PIN ?? '') !== '1'
|
|
438
|
+
&& String(env.CI ?? '').toLowerCase() !== 'true';
|
|
439
|
+
|
|
440
|
+
if (!enabled) return { enabled: false, release() {}, rows: 0, bottom: 0 };
|
|
441
|
+
|
|
442
|
+
const bottom = height - rows;
|
|
443
|
+
let released = false;
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* ── ⚠️⚠️ CLEAR, THEN START AT THE TOP. MY FIRST VERSION STARTED AT THE BOTTOM ─
|
|
447
|
+
*
|
|
448
|
+
* Roman, from a screenshot: the banner was cut in half with a huge empty gap
|
|
449
|
+
* beneath it and the conversation crammed at the bottom of the screen.
|
|
450
|
+
*
|
|
451
|
+
* The cause: this parked the cursor at the BOTTOM of the region, so the first
|
|
452
|
+
* line printed on the last usable row and everything after it scrolled. A
|
|
453
|
+
* terminal fills downward — the transcript has to START at the top and grow
|
|
454
|
+
* toward the input, which is what every scrollback in existence does.
|
|
455
|
+
*
|
|
456
|
+
* ⚠️ AND THE SCREEN IS CLEARED FIRST. Whatever the shell left behind would
|
|
457
|
+
* otherwise sit inside our region and scroll along with the session, so the
|
|
458
|
+
* banner would never be at the top of anything.
|
|
459
|
+
*/
|
|
460
|
+
output.write(`${CSI}2J${CSI}1;${bottom}r${CSI}1;1H`);
|
|
461
|
+
|
|
462
|
+
const release = () => {
|
|
463
|
+
if (released) return;
|
|
464
|
+
released = true;
|
|
465
|
+
/**
|
|
466
|
+
* ⚠️ `ESC[r` WITH NO ARGUMENTS RESETS TO THE FULL SCREEN. Then the cursor is
|
|
467
|
+
* moved below the reserved rows so the shell prompt does not land on top of
|
|
468
|
+
* our box — an exit that leaves the terminal technically correct and
|
|
469
|
+
* visually broken is still a bad exit.
|
|
470
|
+
*/
|
|
471
|
+
try {
|
|
472
|
+
output.write(`${CSI}r${CSI}${height};1H\n`);
|
|
473
|
+
} catch { /* the stream may already be gone on a hard exit */ }
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
return { enabled: true, release, rows, bottom };
|
|
477
|
+
}
|