acuvo-code 0.5.0 → 0.5.1

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/lib/chat.mjs CHANGED
@@ -425,6 +425,7 @@ export async function runChat({
425
425
  if (interactive) {
426
426
  output.write('\n');
427
427
  const got = await readBoxedLine({
428
+ atRow: pin.enabled ? pin.bottom + 1 : 0,
428
429
  input,
429
430
  output,
430
431
  history: typed,
package/lib/input-box.mjs CHANGED
@@ -1,409 +1,447 @@
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, inner - visibleWidth(body)));
100
-
101
- return {
102
- lines: [
103
- `╭${'─'.repeat(width - 2)}╮`,
104
- `│${body}${pad}│`,
105
- `╰${'─'.repeat(width - 2)}╯`,
106
- ],
107
- // 1-based: the │, then the prompt, then however far into the shown text.
108
- cursorColumn: 1 + 1 + promptWidth + (cursor - start),
109
- };
110
- }
111
-
112
- /**
113
- * Apply one keypress to the editor state.
114
- *
115
- * ⚠️ PURE, AND THAT IS THE WHOLE POINT. Every key can be tested without a TTY,
116
- * without timing, and without a terminal to inspect afterwards. The half of this
117
- * module that touches the terminal does nothing but paint what this returns.
118
- *
119
- * @returns {{value, cursor, historyIndex, done?: 'submit'|'cancel'|'eof'}}
120
- */
121
- export function applyKey(state, key) {
122
- const { value, cursor, history = [], historyIndex = history.length } = state;
123
- /**
124
- * ⚠️ `history` IS CARRIED THROUGH, AND ITS ABSENCE WAS A REAL BUG. The first
125
- * version returned only `{value, cursor, historyIndex}` — so the history array
126
- * was dropped by the FIRST keystroke, and pressing Up afterwards silently did
127
- * nothing. It looked like the history feature was unimplemented rather than
128
- * like state was being lost, which is exactly the kind of bug a pure function
129
- * makes visible and a stateful one hides.
130
- */
131
- const keep = (over = {}) => ({ value, cursor, history, historyIndex, draft: state.draft, ...over });
132
-
133
- if (key === KEY.ENTER || key === KEY.NEWLINE) return keep({ done: 'submit' });
134
- if (key === KEY.CTRL_C) return keep({ done: 'cancel' });
135
- /**
136
- * ⚠️ Ctrl-D IS EOF ONLY ON AN EMPTY LINE. On a line with text it is
137
- * forward-delete — collapsing the two would exit the session when someone
138
- * meant to delete a character, which is a data-loss-shaped surprise.
139
- */
140
- if (key === KEY.CTRL_D) {
141
- if (value.length === 0) return keep({ done: 'eof' });
142
- return keep({ value: value.slice(0, cursor) + value.slice(cursor + 1) });
143
- }
144
-
145
- if (key === KEY.BACKSPACE || key === KEY.BACKSPACE_ALT) {
146
- if (cursor === 0) return keep();
147
- return keep({ value: value.slice(0, cursor - 1) + value.slice(cursor), cursor: cursor - 1 });
148
- }
149
-
150
- if (key === KEY.CTRL_U) return keep({ value: value.slice(cursor), cursor: 0 });
151
- if (key === KEY.CTRL_K) return keep({ value: value.slice(0, cursor) });
152
- if (key === KEY.CTRL_A) return keep({ cursor: 0 });
153
- if (key === KEY.CTRL_E) return keep({ cursor: value.length });
154
-
155
- if (key === KEY.CTRL_W) {
156
- const upto = value.slice(0, cursor);
157
- const cut = upto.replace(/\s*\S+$/, '');
158
- return keep({ value: cut + value.slice(cursor), cursor: cut.length });
159
- }
160
-
161
- // Arrows and Home/End arrive as escape sequences.
162
- if (key === `${CSI}D`) return keep({ cursor: Math.max(0, cursor - 1) });
163
- if (key === `${CSI}C`) return keep({ cursor: Math.min(value.length, cursor + 1) });
164
- if (key === `${CSI}H` || key === `${CSI}1~`) return keep({ cursor: 0 });
165
- if (key === `${CSI}F` || key === `${CSI}4~`) return keep({ cursor: value.length });
166
- if (key === `${CSI}3~`) return keep({ value: value.slice(0, cursor) + value.slice(cursor + 1) });
167
-
168
- /**
169
- * History. ⚠️ The index may sit one PAST the end — that position is "the line
170
- * I was typing", so walking up and back down returns what you had rather than
171
- * silently eating it.
172
- */
173
- if (key === `${CSI}A`) {
174
- if (historyIndex === 0 || history.length === 0) return keep();
175
- const i = historyIndex - 1;
176
- /**
177
- * ⚠️ THE DRAFT IS SAVED ON THE WAY UP. Leaving it behind means a half-typed
178
- * line is destroyed by a single Up press — the user glances at what they ran
179
- * before, comes back, and their sentence is gone. Losing typed input to a
180
- * navigation key is the least forgivable bug a line editor can have.
181
- */
182
- const draft = historyIndex === history.length ? value : state.draft;
183
- return keep({ value: history[i], cursor: history[i].length, historyIndex: i, draft });
184
- }
185
- if (key === `${CSI}B`) {
186
- if (historyIndex >= history.length) return keep({ draft: state.draft });
187
- const i = historyIndex + 1;
188
- const next = i === history.length ? (state.draft ?? '') : history[i];
189
- return keep({ value: next, cursor: next.length, historyIndex: i, draft: state.draft });
190
- }
191
-
192
- /**
193
- * ⚠️ EVERYTHING ELSE CONTROL-SHAPED IS DROPPED, NOT INSERTED. An unhandled
194
- * escape sequence typed into the buffer shows the user `^[[5~` and looks like
195
- * the tool is broken — the one impression a new line editor cannot afford.
196
- */
197
- if (key.startsWith(ESC)) return keep();
198
- if (key.length === 1 && key < ' ') return keep();
199
-
200
- return keep({ value: value.slice(0, cursor) + key + value.slice(cursor), cursor: cursor + key.length });
201
- }
202
-
203
- /**
204
- * Split a raw chunk into keys. A paste arrives as one chunk and an arrow key as
205
- * three bytes, so neither "one byte per key" nor "one chunk per key" is right.
206
- */
207
- export function splitKeys(chunk) {
208
- const s = String(chunk);
209
- const keys = [];
210
- for (let i = 0; i < s.length; i += 1) {
211
- if (s[i] === ESC) {
212
- const m = /^\x1b\[[0-9;]*[A-Za-z~]/.exec(s.slice(i));
213
- if (m) { keys.push(m[0]); i += m[0].length - 1; continue; }
214
- }
215
- keys.push(s[i]);
216
- }
217
- return keys;
218
- }
219
-
220
- /**
221
- * Draw the box and park the cursor inside it.
222
- *
223
- * ⚠️ THE CURSOR IS HIDDEN WHILE PAINTING. Without it, the cursor visibly darts
224
- * to the end of each border as it is written — which reads as a flicker and is
225
- * the difference between a rendered box and a drawn one.
226
- */
227
- export function paint(output, state, { first = false } = {}) {
228
- const { lines, cursorColumn } = renderBox(state);
229
-
230
- /**
231
- * ── ⚠️⚠️ THE CURSOR MATH, AND MY FIRST VERSION ATE THE SCREEN ──────────────
232
- *
233
- * Roman: *"it moves upwards every time you type a character then deletes the
234
- * design you did."* Exactly right, and the arithmetic says why.
235
- *
236
- * After a paint the cursor rests on the INPUT line line 2 of 3, not below
237
- * the box. The first version began each repaint with `ESC[3A`, which is where
238
- * it would be if the cursor were below. From line 2, moving up 3 lands ONE
239
- * LINE ABOVE the top border — and the `ESC[0J` that follows clears from there
240
- * to the bottom of the screen. So every keystroke crept upward and erased
241
- * another line of the banner.
242
- *
243
- * ⭐ THE INVARIANT, WRITTEN DOWN BECAUSE IT IS THE WHOLE FUNCTION: this
244
- * routine ENTERS with the cursor on the input line and LEAVES it there. So a
245
- * repaint moves up exactly ONE line to reach the top border, and the final
246
- * reposition moves up exactly one from the last line written.
247
- *
248
- * ╭────────╮ <- line 1 ESC[1A from the input line reaches here
249
- * │› … │ <- line 2 cursor lives here, in and out
250
- * ╰────────╯ <- line 3 cursor is here after writing; ESC[1A returns
251
- *
252
- * ⚠️ NO TRAILING NEWLINE. Writing one after the last border scrolls the
253
- * viewport when the box is at the bottom of the screen, and every subsequent
254
- * `up` is then off by a row for the rest of the session.
255
- */
256
- const home = first ? '' : `\r${CSI}1A`;
257
- output.write(
258
- `${CSI}?25l${home}${CSI}0J${lines.join('\n')}` +
259
- `${CSI}1A\r${CSI}${cursorColumn}G${CSI}?25h`,
260
- );
261
- }
262
-
263
- /**
264
- * ── ⭐⭐⭐ THE ONLY PART THAT TOUCHES A TERMINAL ─────────────────────────────
265
- *
266
- * Reads one line inside the box. Everything decision-shaped lives in `applyKey`
267
- * and `renderBox`, which are pure and fully tested; this function does nothing
268
- * but move bytes and paint what they return.
269
- *
270
- * @returns {Promise<{value: string|null, reason: 'submit'|'cancel'|'eof'}>}
271
- */
272
- export function readBoxedLine({ input, output, history = [], onInterrupt = null, prompt = '› ' }) {
273
- return new Promise((resolve) => {
274
- let state = { value: '', cursor: 0, history, historyIndex: history.length, draft: '' };
275
- const columns = () => output.columns ?? process.stdout.columns ?? 80;
276
-
277
- /**
278
- * ⚠️ RAW MODE IS RESTORED ON EVERY EXIT PATH, INCLUDING THE UNHAPPY ONES.
279
- * A process that leaves the terminal in raw mode hands the user a shell with
280
- * no echo and no line editing — they have to type `reset` blind. That is the
281
- * single worst thing a CLI can do to somebody's session.
282
- */
283
- let finished = false;
284
- const finish = (value, reason) => {
285
- if (finished) return;
286
- finished = true;
287
- input.off('data', onData);
288
- input.off('end', onEnd);
289
- try { input.setRawMode?.(false); } catch { /* not a TTY any more */ }
290
- /**
291
- * ── ⚠️⚠️ MOVE BELOW THE BOX BEFORE ANYTHING ELSE WRITES ─────────────────
292
- *
293
- * Seen in a screenshot: an MCP warning printed straight ON TOP of the
294
- * bottom border, which was left visible only where the message was
295
- * shorter than the box.
296
- *
297
- * The cursor rests on the INPUT line — line 2 of 3. A bare `\n` moves it
298
- * to line 3, which IS the bottom border, so the very next thing anyone
299
- * writes overwrites it. Down one FIRST, then a newline, so the cursor
300
- * lands on fresh ground below a box that stays intact.
301
- */
302
- output.write(`${CSI}1B\n`);
303
- resolve({ value, reason });
304
- };
305
-
306
- const onEnd = () => finish(null, 'eof');
307
-
308
- const onData = (chunk) => {
309
- for (const key of splitKeys(chunk)) {
310
- const next = applyKey(state, key);
311
- if (next.done === 'submit') {
312
- state = next;
313
- // Repaint once so the committed line is what stays on screen.
314
- paint(output, { ...state, columns: columns() });
315
- return finish(state.value, 'submit');
316
- }
317
- if (next.done === 'cancel') {
318
- /**
319
- * ⚠️ Ctrl-C ON A NON-EMPTY LINE CLEARS IT; on an empty one it means
320
- * "stop". Anything else makes the first Ctrl-Cthe one people press
321
- * to abandon a sentence quit the whole session.
322
- */
323
- /**
324
- * ⚠️⚠️ Ctrl-C NEVER ENDS THE READ it clears the line and hands the
325
- * event on. The first version finished with `cancel` on an empty line,
326
- * which ended the session on the FIRST press: there was then no prompt
327
- * for a second Ctrl-C to arrive at, so the "press again to quit"
328
- * escape hatch could never fire. `onInterrupt` owns that decision (see
329
- * `interrupt.mjs`), and it can only own it if it keeps being called.
330
- *
331
- * ⭐ It is also what every shell does: Ctrl-C gives you a fresh line.
332
- * Quitting is `exit`, or Ctrl-D on an empty one.
333
- */
334
- state = { ...state, value: '', cursor: 0, historyIndex: history.length, draft: '' };
335
- onInterrupt?.();
336
- paint(output, { ...state, columns: columns() });
337
- continue;
338
- }
339
- if (next.done === 'eof') return finish(null, 'eof');
340
- state = next;
341
- paint(output, { ...state, columns: columns() });
342
- }
343
- };
344
-
345
- try { input.setRawMode?.(true); } catch { /* not a TTY */ }
346
- input.resume?.();
347
- paint(output, { ...state, columns: columns() }, { first: true });
348
- input.on('data', onData);
349
- input.once('end', onEnd);
350
- });
351
- }
352
-
353
- /**
354
- * ── ⭐⭐⭐ PINNING THE BOX TO THE BOTTOM OF THE SCREEN ───────────────────────
355
- *
356
- * Roman: *"we need that prompt box stuck down the bottom, it is professional."*
357
- *
358
- * A terminal can be told to scroll only PART of itself. `ESC[{top};{bottom}r`
359
- * sets the scrolling region; everything printed scrolls inside it, and the rows
360
- * below are left alone. Reserve the last three and the box never moves while
361
- * output flows past above it.
362
- *
363
- * ── ⚠️⚠️ THE PART THAT MUST NEVER BE GOT WRONG ──────────────────────────────
364
- *
365
- * A process that exits WITHOUT releasing the region leaves the user with a
366
- * terminal that scrolls inside a box forever, fixable only by typing `reset`
367
- * blind. That is the same class of harm as leaving raw mode on, and it must be
368
- * released on every path out — normal exit, Ctrl-C, SIGTERM, and an uncaught
369
- * throw. `release()` is idempotent and safe to call from all of them.
370
- *
371
- * ⚠️ AND IT IS OPT-IN. A reserved region is a claim on somebody's whole screen;
372
- * off a TTY, in CI, under a pipe or with ACUVO_NO_PIN=1 it is never set.
373
- */
374
- export function pinRegion(output, { rows = 3, env = process.env } = {}) {
375
- const height = output?.rows ?? process.stdout?.rows ?? 0;
376
- const enabled = Boolean(output?.isTTY)
377
- && height > rows + 4
378
- && String(env.ACUVO_NO_PIN ?? '') !== '1'
379
- && String(env.CI ?? '').toLowerCase() !== 'true';
380
-
381
- if (!enabled) return { enabled: false, release() {}, rows: 0, bottom: 0 };
382
-
383
- const bottom = height - rows;
384
- let released = false;
385
-
386
- /**
387
- * ⚠️ THE CURSOR IS PARKED INSIDE THE SCROLL REGION BEFORE ANYTHING PRINTS.
388
- * Setting a region moves the cursor to home (1,1) on most terminals, so
389
- * without this the next line of output lands at the TOP of the screen and the
390
- * transcript reads backwards.
391
- */
392
- output.write(`${CSI}1;${bottom}r${CSI}${bottom};1H`);
393
-
394
- const release = () => {
395
- if (released) return;
396
- released = true;
397
- /**
398
- * ⚠️ `ESC[r` WITH NO ARGUMENTS RESETS TO THE FULL SCREEN. Then the cursor is
399
- * moved below the reserved rows so the shell prompt does not land on top of
400
- * our box — an exit that leaves the terminal technically correct and
401
- * visually broken is still a bad exit.
402
- */
403
- try {
404
- output.write(`${CSI}r${CSI}${height};1H\n`);
405
- } catch { /* the stream may already be gone on a hard exit */ }
406
- };
407
-
408
- return { enabled: true, release, rows, bottom };
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, inner - visibleWidth(body)));
100
+
101
+ return {
102
+ lines: [
103
+ `╭${'─'.repeat(width - 2)}╮`,
104
+ `│${body}${pad}│`,
105
+ `╰${'─'.repeat(width - 2)}╯`,
106
+ ],
107
+ // 1-based: the │, then the prompt, then however far into the shown text.
108
+ cursorColumn: 1 + 1 + promptWidth + (cursor - start),
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Apply one keypress to the editor state.
114
+ *
115
+ * ⚠️ PURE, AND THAT IS THE WHOLE POINT. Every key can be tested without a TTY,
116
+ * without timing, and without a terminal to inspect afterwards. The half of this
117
+ * module that touches the terminal does nothing but paint what this returns.
118
+ *
119
+ * @returns {{value, cursor, historyIndex, done?: 'submit'|'cancel'|'eof'}}
120
+ */
121
+ export function applyKey(state, key) {
122
+ const { value, cursor, history = [], historyIndex = history.length } = state;
123
+ /**
124
+ * ⚠️ `history` IS CARRIED THROUGH, AND ITS ABSENCE WAS A REAL BUG. The first
125
+ * version returned only `{value, cursor, historyIndex}` — so the history array
126
+ * was dropped by the FIRST keystroke, and pressing Up afterwards silently did
127
+ * nothing. It looked like the history feature was unimplemented rather than
128
+ * like state was being lost, which is exactly the kind of bug a pure function
129
+ * makes visible and a stateful one hides.
130
+ */
131
+ const keep = (over = {}) => ({ value, cursor, history, historyIndex, draft: state.draft, ...over });
132
+
133
+ if (key === KEY.ENTER || key === KEY.NEWLINE) return keep({ done: 'submit' });
134
+ if (key === KEY.CTRL_C) return keep({ done: 'cancel' });
135
+ /**
136
+ * ⚠️ Ctrl-D IS EOF ONLY ON AN EMPTY LINE. On a line with text it is
137
+ * forward-delete — collapsing the two would exit the session when someone
138
+ * meant to delete a character, which is a data-loss-shaped surprise.
139
+ */
140
+ if (key === KEY.CTRL_D) {
141
+ if (value.length === 0) return keep({ done: 'eof' });
142
+ return keep({ value: value.slice(0, cursor) + value.slice(cursor + 1) });
143
+ }
144
+
145
+ if (key === KEY.BACKSPACE || key === KEY.BACKSPACE_ALT) {
146
+ if (cursor === 0) return keep();
147
+ return keep({ value: value.slice(0, cursor - 1) + value.slice(cursor), cursor: cursor - 1 });
148
+ }
149
+
150
+ if (key === KEY.CTRL_U) return keep({ value: value.slice(cursor), cursor: 0 });
151
+ if (key === KEY.CTRL_K) return keep({ value: value.slice(0, cursor) });
152
+ if (key === KEY.CTRL_A) return keep({ cursor: 0 });
153
+ if (key === KEY.CTRL_E) return keep({ cursor: value.length });
154
+
155
+ if (key === KEY.CTRL_W) {
156
+ const upto = value.slice(0, cursor);
157
+ const cut = upto.replace(/\s*\S+$/, '');
158
+ return keep({ value: cut + value.slice(cursor), cursor: cut.length });
159
+ }
160
+
161
+ // Arrows and Home/End arrive as escape sequences.
162
+ if (key === `${CSI}D`) return keep({ cursor: Math.max(0, cursor - 1) });
163
+ if (key === `${CSI}C`) return keep({ cursor: Math.min(value.length, cursor + 1) });
164
+ if (key === `${CSI}H` || key === `${CSI}1~`) return keep({ cursor: 0 });
165
+ if (key === `${CSI}F` || key === `${CSI}4~`) return keep({ cursor: value.length });
166
+ if (key === `${CSI}3~`) return keep({ value: value.slice(0, cursor) + value.slice(cursor + 1) });
167
+
168
+ /**
169
+ * History. ⚠️ The index may sit one PAST the end — that position is "the line
170
+ * I was typing", so walking up and back down returns what you had rather than
171
+ * silently eating it.
172
+ */
173
+ if (key === `${CSI}A`) {
174
+ if (historyIndex === 0 || history.length === 0) return keep();
175
+ const i = historyIndex - 1;
176
+ /**
177
+ * ⚠️ THE DRAFT IS SAVED ON THE WAY UP. Leaving it behind means a half-typed
178
+ * line is destroyed by a single Up press — the user glances at what they ran
179
+ * before, comes back, and their sentence is gone. Losing typed input to a
180
+ * navigation key is the least forgivable bug a line editor can have.
181
+ */
182
+ const draft = historyIndex === history.length ? value : state.draft;
183
+ return keep({ value: history[i], cursor: history[i].length, historyIndex: i, draft });
184
+ }
185
+ if (key === `${CSI}B`) {
186
+ if (historyIndex >= history.length) return keep({ draft: state.draft });
187
+ const i = historyIndex + 1;
188
+ const next = i === history.length ? (state.draft ?? '') : history[i];
189
+ return keep({ value: next, cursor: next.length, historyIndex: i, draft: state.draft });
190
+ }
191
+
192
+ /**
193
+ * ⚠️ EVERYTHING ELSE CONTROL-SHAPED IS DROPPED, NOT INSERTED. An unhandled
194
+ * escape sequence typed into the buffer shows the user `^[[5~` and looks like
195
+ * the tool is broken — the one impression a new line editor cannot afford.
196
+ */
197
+ if (key.startsWith(ESC)) return keep();
198
+ if (key.length === 1 && key < ' ') return keep();
199
+
200
+ return keep({ value: value.slice(0, cursor) + key + value.slice(cursor), cursor: cursor + key.length });
201
+ }
202
+
203
+ /**
204
+ * Split a raw chunk into keys. A paste arrives as one chunk and an arrow key as
205
+ * three bytes, so neither "one byte per key" nor "one chunk per key" is right.
206
+ */
207
+ export function splitKeys(chunk) {
208
+ const s = String(chunk);
209
+ const keys = [];
210
+ for (let i = 0; i < s.length; i += 1) {
211
+ if (s[i] === ESC) {
212
+ const m = /^\x1b\[[0-9;]*[A-Za-z~]/.exec(s.slice(i));
213
+ if (m) { keys.push(m[0]); i += m[0].length - 1; continue; }
214
+ }
215
+ keys.push(s[i]);
216
+ }
217
+ return keys;
218
+ }
219
+
220
+ /**
221
+ * Draw the box and park the cursor inside it.
222
+ *
223
+ * ⚠️ THE CURSOR IS HIDDEN WHILE PAINTING. Without it, the cursor visibly darts
224
+ * to the end of each border as it is written — which reads as a flicker and is
225
+ * the difference between a rendered box and a drawn one.
226
+ */
227
+ export function paint(output, state, { first = false, atRow = 0 } = {}) {
228
+ const { lines, cursorColumn } = renderBox(state);
229
+
230
+ /**
231
+ * ── ⭐⭐⭐ PINNED: DRAW AT AN ABSOLUTE ROW, NOT WHEREVER THE CURSOR IS ───────
232
+ *
233
+ * Roman, from a screenshot: *"the text is going underneath instead of above,
234
+ * and it makes a new box."* Two boxes on screen, output between them.
235
+ *
236
+ * The cause: `pinRegion` reserved the bottom rows but this function still drew
237
+ * RELATIVE to the cursor. So the box was painted inline, scrolled away with
238
+ * the transcript, and the next turn painted a fresh one lower down the
239
+ * reserved rows sat empty while the box wandered.
240
+ *
241
+ * When pinned, the box has a FIXED HOME. `ESC[{row};1H` puts it there every
242
+ * time, so output scrolling above it cannot move it and no second box can
243
+ * exist.
244
+ */
245
+ if (atRow > 0) {
246
+ const rows = lines.map((l, i) => `${CSI}${atRow + i};1H${CSI}2K${l}`).join('');
247
+ output.write(`${CSI}?25l${rows}${CSI}${atRow + 1};${cursorColumn}H${CSI}?25h`);
248
+ return;
249
+ }
250
+
251
+ /**
252
+ * ── ⚠️⚠️ THE CURSOR MATH, AND MY FIRST VERSION ATE THE SCREEN ──────────────
253
+ *
254
+ * Roman: *"it moves upwards every time you type a character then deletes the
255
+ * design you did."* Exactly right, and the arithmetic says why.
256
+ *
257
+ * After a paint the cursor rests on the INPUT line — line 2 of 3, not below
258
+ * the box. The first version began each repaint with `ESC[3A`, which is where
259
+ * it would be if the cursor were below. From line 2, moving up 3 lands ONE
260
+ * LINE ABOVE the top border — and the `ESC[0J` that follows clears from there
261
+ * to the bottom of the screen. So every keystroke crept upward and erased
262
+ * another line of the banner.
263
+ *
264
+ * THE INVARIANT, WRITTEN DOWN BECAUSE IT IS THE WHOLE FUNCTION: this
265
+ * routine ENTERS with the cursor on the input line and LEAVES it there. So a
266
+ * repaint moves up exactly ONE line to reach the top border, and the final
267
+ * reposition moves up exactly one from the last line written.
268
+ *
269
+ * ╭────────╮ <- line 1 ESC[1A from the input line reaches here
270
+ * │› … │ <- line 2 cursor lives here, in and out
271
+ * ╰────────╯ <- line 3 cursor is here after writing; ESC[1A returns
272
+ *
273
+ * ⚠️ NO TRAILING NEWLINE. Writing one after the last border scrolls the
274
+ * viewport when the box is at the bottom of the screen, and every subsequent
275
+ * `up` is then off by a row for the rest of the session.
276
+ */
277
+ const home = first ? '' : `\r${CSI}1A`;
278
+ output.write(
279
+ `${CSI}?25l${home}${CSI}0J${lines.join('\n')}` +
280
+ `${CSI}1A\r${CSI}${cursorColumn}G${CSI}?25h`,
281
+ );
282
+ }
283
+
284
+ /**
285
+ * ── ⭐⭐⭐ THE ONLY PART THAT TOUCHES A TERMINAL ─────────────────────────────
286
+ *
287
+ * Reads one line inside the box. Everything decision-shaped lives in `applyKey`
288
+ * and `renderBox`, which are pure and fully tested; this function does nothing
289
+ * but move bytes and paint what they return.
290
+ *
291
+ * @returns {Promise<{value: string|null, reason: 'submit'|'cancel'|'eof'}>}
292
+ */
293
+ export function readBoxedLine({ input, output, history = [], onInterrupt = null, prompt = '› ', atRow = 0 }) {
294
+ return new Promise((resolve) => {
295
+ let state = { value: '', cursor: 0, history, historyIndex: history.length, draft: '' };
296
+ const columns = () => output.columns ?? process.stdout.columns ?? 80;
297
+
298
+ /**
299
+ * ⚠️ RAW MODE IS RESTORED ON EVERY EXIT PATH, INCLUDING THE UNHAPPY ONES.
300
+ * A process that leaves the terminal in raw mode hands the user a shell with
301
+ * no echo and no line editing — they have to type `reset` blind. That is the
302
+ * single worst thing a CLI can do to somebody's session.
303
+ */
304
+ let finished = false;
305
+ const finish = (value, reason) => {
306
+ if (finished) return;
307
+ finished = true;
308
+ input.off('data', onData);
309
+ input.off('end', onEnd);
310
+ try { input.setRawMode?.(false); } catch { /* not a TTY any more */ }
311
+ if (atRow > 0) {
312
+ /**
313
+ * ── ⭐⭐⭐ PINNED: THE BOX STAYS, THE ANSWER GOES ABOVE IT ─────────────
314
+ *
315
+ * Roman, from a screenshot: *"the text is going underneath instead of
316
+ * above, and it makes a new box."*
317
+ *
318
+ * With a reserved region the box has a permanent home in the bottom
319
+ * rows. What the user typed belongs in the TRANSCRIPT, so it is echoed
320
+ * into the scrolling area and the box is repainted empty — one box,
321
+ * always in the same place, with history flowing upward past it.
322
+ *
323
+ * ⚠️ The cursor is left at the bottom of the SCROLL REGION, so whatever
324
+ * prints next (the reply, an MCP warning, anything) lands above the box
325
+ * instead of on top of it.
326
+ */
327
+ output.write(`${CSI}${atRow - 1};1H\n${prompt}${value ?? ''}\n`);
328
+ paint(output, { value: '', cursor: 0, columns: output.columns ?? 80 }, { atRow });
329
+ output.write(`${CSI}${atRow - 1};1H`);
330
+ } else {
331
+ /**
332
+ * ── ⚠️⚠️ UNPINNED: MOVE BELOW THE BOX BEFORE ANYTHING ELSE WRITES ────
333
+ *
334
+ * Seen in an earlier screenshot: an MCP warning printed straight ON TOP
335
+ * of the bottom border. The cursor rests on the INPUT line — line 2 of 3
336
+ * so a bare `\n` lands it on the border row and the next write
337
+ * destroys it. Down one FIRST, then a newline.
338
+ */
339
+ output.write(`${CSI}1B\n`);
340
+ }
341
+ resolve({ value, reason });
342
+ };
343
+
344
+ const onEnd = () => finish(null, 'eof');
345
+
346
+ const onData = (chunk) => {
347
+ for (const key of splitKeys(chunk)) {
348
+ const next = applyKey(state, key);
349
+ if (next.done === 'submit') {
350
+ state = next;
351
+ // Repaint once so the committed line is what stays on screen.
352
+ paint(output, { ...state, columns: columns() }, { atRow });
353
+ return finish(state.value, 'submit');
354
+ }
355
+ if (next.done === 'cancel') {
356
+ /**
357
+ * ⚠️ Ctrl-C ON A NON-EMPTY LINE CLEARS IT; on an empty one it means
358
+ * "stop". Anything else makes the first Ctrl-C the one people press
359
+ * to abandon a sentence quit the whole session.
360
+ */
361
+ /**
362
+ * ⚠️⚠️ Ctrl-C NEVER ENDS THE READ — it clears the line and hands the
363
+ * event on. The first version finished with `cancel` on an empty line,
364
+ * which ended the session on the FIRST press: there was then no prompt
365
+ * for a second Ctrl-C to arrive at, so the "press again to quit"
366
+ * escape hatch could never fire. `onInterrupt` owns that decision (see
367
+ * `interrupt.mjs`), and it can only own it if it keeps being called.
368
+ *
369
+ * It is also what every shell does: Ctrl-C gives you a fresh line.
370
+ * Quitting is `exit`, or Ctrl-D on an empty one.
371
+ */
372
+ state = { ...state, value: '', cursor: 0, historyIndex: history.length, draft: '' };
373
+ onInterrupt?.();
374
+ paint(output, { ...state, columns: columns() }, { atRow });
375
+ continue;
376
+ }
377
+ if (next.done === 'eof') return finish(null, 'eof');
378
+ state = next;
379
+ paint(output, { ...state, columns: columns() }, { atRow });
380
+ }
381
+ };
382
+
383
+ try { input.setRawMode?.(true); } catch { /* not a TTY */ }
384
+ input.resume?.();
385
+ paint(output, { ...state, columns: columns() }, { first: true, atRow });
386
+ input.on('data', onData);
387
+ input.once('end', onEnd);
388
+ });
389
+ }
390
+
391
+ /**
392
+ * ── ⭐⭐⭐ PINNING THE BOX TO THE BOTTOM OF THE SCREEN ───────────────────────
393
+ *
394
+ * Roman: *"we need that prompt box stuck down the bottom, it is professional."*
395
+ *
396
+ * A terminal can be told to scroll only PART of itself. `ESC[{top};{bottom}r`
397
+ * sets the scrolling region; everything printed scrolls inside it, and the rows
398
+ * below are left alone. Reserve the last three and the box never moves while
399
+ * output flows past above it.
400
+ *
401
+ * ── ⚠️⚠️ THE PART THAT MUST NEVER BE GOT WRONG ──────────────────────────────
402
+ *
403
+ * A process that exits WITHOUT releasing the region leaves the user with a
404
+ * terminal that scrolls inside a box forever, fixable only by typing `reset`
405
+ * blind. That is the same class of harm as leaving raw mode on, and it must be
406
+ * released on every path out — normal exit, Ctrl-C, SIGTERM, and an uncaught
407
+ * throw. `release()` is idempotent and safe to call from all of them.
408
+ *
409
+ * ⚠️ AND IT IS OPT-IN. A reserved region is a claim on somebody's whole screen;
410
+ * off a TTY, in CI, under a pipe or with ACUVO_NO_PIN=1 it is never set.
411
+ */
412
+ export function pinRegion(output, { rows = 3, env = process.env } = {}) {
413
+ const height = output?.rows ?? process.stdout?.rows ?? 0;
414
+ const enabled = Boolean(output?.isTTY)
415
+ && height > rows + 4
416
+ && String(env.ACUVO_NO_PIN ?? '') !== '1'
417
+ && String(env.CI ?? '').toLowerCase() !== 'true';
418
+
419
+ if (!enabled) return { enabled: false, release() {}, rows: 0, bottom: 0 };
420
+
421
+ const bottom = height - rows;
422
+ let released = false;
423
+
424
+ /**
425
+ * ⚠️ THE CURSOR IS PARKED INSIDE THE SCROLL REGION BEFORE ANYTHING PRINTS.
426
+ * Setting a region moves the cursor to home (1,1) on most terminals, so
427
+ * without this the next line of output lands at the TOP of the screen and the
428
+ * transcript reads backwards.
429
+ */
430
+ output.write(`${CSI}1;${bottom}r${CSI}${bottom};1H`);
431
+
432
+ const release = () => {
433
+ if (released) return;
434
+ released = true;
435
+ /**
436
+ * ⚠️ `ESC[r` WITH NO ARGUMENTS RESETS TO THE FULL SCREEN. Then the cursor is
437
+ * moved below the reserved rows so the shell prompt does not land on top of
438
+ * our box — an exit that leaves the terminal technically correct and
439
+ * visually broken is still a bad exit.
440
+ */
441
+ try {
442
+ output.write(`${CSI}r${CSI}${height};1H\n`);
443
+ } catch { /* the stream may already be gone on a hard exit */ }
444
+ };
445
+
446
+ return { enabled: true, release, rows, bottom };
447
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acuvo-code",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Acuvo Code — the terminal client for the Acuvo capability registry. Zero dependencies, by design.",
5
5
  "type": "module",
6
6
  "bin": {