acuvo-code 0.6.9 → 0.6.10
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/banner.mjs +131 -146
- package/lib/input-box.mjs +26 -1
- package/package.json +1 -1
package/lib/banner.mjs
CHANGED
|
@@ -1,90 +1,128 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ── ⭐⭐⭐ WHAT YOU SEE WHEN YOU TYPE `acuvo` ────────────────────────────────
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* with acuvo logo top left and details up top etc, like how claude code opens.
|
|
6
|
-
* have you actually designed the page?"* — and then: *"no we want OUR logo"*.
|
|
4
|
+
* ── ⚠️⚠️ REWRITTEN AGAINST A SCREENSHOT OF THE REFERENCE, NOT A DESCRIPTION ──
|
|
7
5
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* Roman, 2026-08-22, having put the two side by side: *"look at the sizing of
|
|
7
|
+
* logo and text near it, and what it says, the structure, the wording like
|
|
8
|
+
* theirs, the model version, the repo folder."*
|
|
11
9
|
*
|
|
12
|
-
*
|
|
10
|
+
* The reference, read off the image rather than remembered:
|
|
13
11
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
12
|
+
* [mark] Claude Code v2.1.219
|
|
13
|
+
* Opus 5 (1M context) with high effort · Claude Max
|
|
14
|
+
* C:\Projects\claude-build
|
|
15
|
+
*
|
|
16
|
+
* Three things that were wrong here, each a decision made without looking:
|
|
17
|
+
*
|
|
18
|
+
* 1. **The mark was SEVEN rows.** Theirs is three. A logo taller than the
|
|
19
|
+
* information beside it turns the opening screen into a title card for
|
|
20
|
+
* something you run forty times a day.
|
|
21
|
+
* 2. **The facts were a LABELLED TABLE** — `workspace / model / billing /
|
|
22
|
+
* can run` down a column. Theirs is three prose lines with no labels,
|
|
23
|
+
* because the values are self-describing: a version looks like a version,
|
|
24
|
+
* a path looks like a path. Labels are what you add when the reader cannot
|
|
25
|
+
* tell what they are looking at.
|
|
26
|
+
* 3. **The model line said only the model.** Theirs carries model, effort AND
|
|
27
|
+
* plan on one line separated by `·` — everything about "what am I talking
|
|
28
|
+
* to and who is paying" in one glance.
|
|
19
29
|
*
|
|
20
30
|
* ── ⚠️ THE CONSTRAINTS, WHICH ARE NOT PREFERENCES ───────────────────────────
|
|
21
31
|
*
|
|
22
|
-
* · **Half-block glyphs only** (▀ ▄ █)
|
|
23
|
-
*
|
|
24
|
-
*
|
|
32
|
+
* · **Half-block glyphs only** (▀ ▄ █) for the art, and only when the terminal
|
|
33
|
+
* measures them at ONE cell — see `lib/glyph-width.mjs`. They are East Asian
|
|
34
|
+
* Ambiguous, so where they render double-width the padding spaces stay narrow
|
|
35
|
+
* and the mark tears.
|
|
25
36
|
* · **No colour escapes here.** The caller owns colour and honours NO_COLOR; a
|
|
26
37
|
* module that hard-codes them emits garbage the moment output is piped.
|
|
27
|
-
* · **
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* ⭐ A NOTE ON DOING BETTER: `lib/terminal-graphics.mjs` can send a real PNG
|
|
31
|
-
* inline on Kitty and iTerm2. It is deliberately NOT used here — Windows
|
|
32
|
-
* Terminal speaks neither protocol, so the block art is what most users would
|
|
33
|
-
* see anyway, and one rendering that is the same everywhere beats two that
|
|
34
|
-
* disagree.
|
|
38
|
+
* · **Never wider than the terminal.** Not 80 — the actual width, which a split
|
|
39
|
+
* pane makes much smaller.
|
|
35
40
|
*/
|
|
36
41
|
|
|
37
42
|
/**
|
|
38
|
-
* The Acuvo mark,
|
|
39
|
-
*
|
|
40
|
-
*
|
|
43
|
+
* The Acuvo mark, in half-blocks. FOUR rows, down from seven.
|
|
44
|
+
*
|
|
45
|
+
* ⚠️ THE HEIGHT IS THE POINT. It has to sit beside three lines of text without
|
|
46
|
+
* dominating them — the proportion the reference uses, and the reason their
|
|
47
|
+
* opening screen reads as a status line rather than a splash.
|
|
41
48
|
*/
|
|
42
49
|
const MARK = [
|
|
43
|
-
'
|
|
44
|
-
'
|
|
45
|
-
'
|
|
46
|
-
'
|
|
47
|
-
' ▄█▀ ██ ▀██',
|
|
48
|
-
' ▄████▀▀█████',
|
|
49
|
-
'█▀ █▀ ▀█',
|
|
50
|
+
' ▄█▄',
|
|
51
|
+
' ▄█▀█▄',
|
|
52
|
+
' ▄█▄▄▄█▄',
|
|
53
|
+
'█▀ ▀█',
|
|
50
54
|
];
|
|
51
55
|
|
|
52
56
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* Every glyph here is ASCII, so it is width-1 by definition in every terminal,
|
|
56
|
-
* every font, every locale. No ambiguity class, nothing to measure, nothing to
|
|
57
|
-
* get wrong.
|
|
57
|
+
* The same mark in characters nothing can stretch.
|
|
58
58
|
*
|
|
59
|
-
* ⚠️ THIS IS NOT "NO LOGO" —
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* A degraded logo is a logo; a text substitute is a missing one.
|
|
59
|
+
* ⚠️ THIS IS NOT "NO LOGO" — the distinction matters. Roman asked for OUR mark
|
|
60
|
+
* and asked again when he got letters spelling the name. A terminal that cannot
|
|
61
|
+
* draw half-blocks must still get the angular A, drawn another way. A degraded
|
|
62
|
+
* logo is a logo; a wordmark standing in for one is a missing logo.
|
|
64
63
|
*/
|
|
65
64
|
const MARK_ASCII = [
|
|
66
|
-
'
|
|
67
|
-
'
|
|
68
|
-
'
|
|
69
|
-
'
|
|
70
|
-
' / /____\\ \\',
|
|
71
|
-
'/_/ \\_\\',
|
|
65
|
+
' /\\',
|
|
66
|
+
' / \\',
|
|
67
|
+
' /----\\',
|
|
68
|
+
'/ \\',
|
|
72
69
|
];
|
|
73
70
|
|
|
74
71
|
const GUTTER = 2;
|
|
75
72
|
|
|
76
73
|
/**
|
|
77
|
-
* The widest the banner may
|
|
78
|
-
*
|
|
79
|
-
* banner built to it fits everywhere.
|
|
74
|
+
* The widest the banner may be when the terminal will not say how wide it is.
|
|
75
|
+
* 80, because that is the narrowest terminal in real use.
|
|
80
76
|
*/
|
|
81
77
|
export const MAX_BANNER_COLUMNS = 80;
|
|
82
78
|
|
|
83
|
-
/** Below this the two-column layout stops being a layout
|
|
79
|
+
/** Below this the two-column layout stops being a layout. */
|
|
84
80
|
const MIN_TEXT_COLUMNS = 30;
|
|
85
81
|
|
|
82
|
+
/** Visible length, ignoring ANSI escapes — they occupy no cells. */
|
|
83
|
+
function visibleLength(s) {
|
|
84
|
+
return String(s).replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
85
|
+
}
|
|
86
|
+
|
|
86
87
|
/**
|
|
87
|
-
*
|
|
88
|
+
* Cut a possibly-coloured string to `width` visible cells, keeping escapes
|
|
89
|
+
* balanced so a truncation cannot leak colour into the rest of the screen.
|
|
90
|
+
*/
|
|
91
|
+
function clampToWidth(s, width) {
|
|
92
|
+
if (visibleLength(s) <= width) return s;
|
|
93
|
+
let out = '';
|
|
94
|
+
let seen = 0;
|
|
95
|
+
const re = /(\x1b\[[0-9;]*m)|([\s\S])/g;
|
|
96
|
+
let m;
|
|
97
|
+
while ((m = re.exec(s)) !== null) {
|
|
98
|
+
if (m[1]) { out += m[1]; continue; }
|
|
99
|
+
if (seen >= width) break;
|
|
100
|
+
out += m[2];
|
|
101
|
+
seen += 1;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* ⚠️ ONLY RE-CLOSE A STRING THAT WAS ACTUALLY COLOURED. Appending a reset
|
|
105
|
+
* unconditionally puts four bytes on the end of every truncated PLAIN line —
|
|
106
|
+
* harmless on a terminal, garbage the moment output is piped to a file.
|
|
107
|
+
*/
|
|
108
|
+
return out.includes('\x1b') ? `${out}\x1b[0m` : out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Elide in the MIDDLE, never the end.
|
|
113
|
+
*
|
|
114
|
+
* The informative parts of a path are the drive and the leaf; chopping the tail
|
|
115
|
+
* leaves `C:\Users\somebody\Projects\a-`, which identifies nothing.
|
|
116
|
+
*/
|
|
117
|
+
function fit(value, room) {
|
|
118
|
+
const s = String(value ?? '');
|
|
119
|
+
if (s.length <= room) return s;
|
|
120
|
+
const head = Math.ceil((room - 1) / 2);
|
|
121
|
+
return `${s.slice(0, head)}…${s.slice(s.length - (room - 1 - head))}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Build the opening screen.
|
|
88
126
|
*
|
|
89
127
|
* @param {object} o
|
|
90
128
|
* @param {string} o.version
|
|
@@ -104,63 +142,39 @@ export function openingScreen({
|
|
|
104
142
|
const brand = paint?.brand ?? ((s) => s);
|
|
105
143
|
|
|
106
144
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* produced was under 80 and it looked immaculate — in an 80-column terminal.
|
|
111
|
-
* In a narrower one, which is what a side panel or a split pane is, every
|
|
112
|
-
* single row wraps, and a wrapped banner does not read as dense. It reads as
|
|
113
|
-
* broken software, which is the first thing a new user sees.
|
|
114
|
-
*
|
|
115
|
-
* `- 1` because a line that exactly fills the width wraps on some terminals
|
|
116
|
-
* and not others, and the difference is not worth one column.
|
|
145
|
+
* ⚠️ THE WIDTH IS THE TERMINAL'S, NOT A CONSTANT. This built to a fixed 80 and
|
|
146
|
+
* never asked; every line was under 80 and it looked immaculate — in an
|
|
147
|
+
* 80-column terminal. In a split pane every row wrapped.
|
|
117
148
|
*/
|
|
118
149
|
const width = Math.max(20, Math.min(MAX_BANNER_COLUMNS, (columns ?? MAX_BANNER_COLUMNS) - 1));
|
|
119
150
|
|
|
120
151
|
const mark = style === 'text' ? MARK_ASCII : MARK;
|
|
121
152
|
const markWidth = Math.max(...mark.map((l) => l.length));
|
|
122
153
|
|
|
123
|
-
/**
|
|
124
|
-
* ⭐ THREE LAYOUTS, CHOSEN BY WHAT ACTUALLY FITS — never by a platform guess.
|
|
125
|
-
* Side by side when there is room for both; mark above the facts when there
|
|
126
|
-
* is room for the mark alone; facts only when there is not. The last one is
|
|
127
|
-
* rare and it still has to be right, because a 24-column terminal is somebody
|
|
128
|
-
* on a phone over SSH and they deserve a legible screen, not a torn one.
|
|
129
|
-
*/
|
|
130
154
|
const sideBySide = width >= markWidth + GUTTER + MIN_TEXT_COLUMNS;
|
|
131
155
|
const textColumns = sideBySide ? width - markWidth - GUTTER : width;
|
|
156
|
+
const room = Math.max(12, textColumns);
|
|
132
157
|
|
|
133
158
|
/**
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
159
|
+
* ── ⭐ THREE LINES, NO LABELS, IN THE REFERENCE'S ORDER ────────────────────
|
|
160
|
+
*
|
|
161
|
+
* Name and version · what you are talking to and who pays · where you are.
|
|
162
|
+
*
|
|
163
|
+
* ⚠️ `billing` JOINS THE MODEL LINE rather than taking a row of its own, which
|
|
164
|
+
* is how the reference does it ("… with high effort · Claude Max"). It matters
|
|
165
|
+
* more here than it does for them: our billing line is the one that says
|
|
166
|
+
* **"YOUR OWN OpenRouter key (not your Acuvo plan)"**, and somebody needs to
|
|
167
|
+
* see that beside the model, not three rows away from it.
|
|
138
168
|
*/
|
|
139
|
-
const room = Math.max(8, textColumns - 11);
|
|
140
|
-
const fit = (v) => {
|
|
141
|
-
const s = String(v ?? '');
|
|
142
|
-
if (s.length <= room) return s;
|
|
143
|
-
const head = Math.ceil((room - 1) / 2);
|
|
144
|
-
return `${s.slice(0, head)}…${s.slice(s.length - (room - 1 - head))}`;
|
|
145
|
-
};
|
|
146
|
-
|
|
147
169
|
const right = [
|
|
148
|
-
`
|
|
149
|
-
'',
|
|
150
|
-
|
|
151
|
-
`model ${fit(model)}`,
|
|
152
|
-
`billing ${fit(billing)}`,
|
|
153
|
-
`can run ${fit(canRun)}`,
|
|
154
|
-
'',
|
|
170
|
+
`Acuvo Code${version ? ` v${version}` : ''}`,
|
|
171
|
+
fit([model, billing].filter(Boolean).join(' · '), room),
|
|
172
|
+
fit(workspace, room),
|
|
155
173
|
];
|
|
156
174
|
|
|
157
175
|
const lines = [''];
|
|
158
176
|
|
|
159
177
|
if (sideBySide) {
|
|
160
|
-
/**
|
|
161
|
-
* ⚠️ THE TWO COLUMNS ARE ZIPPED, NOT CONCATENATED, and the row counts are
|
|
162
|
-
* allowed to differ — whichever is shorter simply runs out.
|
|
163
|
-
*/
|
|
164
178
|
const rows = Math.max(mark.length, right.length);
|
|
165
179
|
for (let i = 0; i < rows; i += 1) {
|
|
166
180
|
const text = right[i] ?? '';
|
|
@@ -170,39 +184,42 @@ export function openingScreen({
|
|
|
170
184
|
* right-hand column drifts.
|
|
171
185
|
*
|
|
172
186
|
* ⚠️⚠️ AND PADDED ONLY WHEN SOMETHING FOLLOWS. On a mark-only row the
|
|
173
|
-
* padding sits INSIDE the colour, before the reset, where `trimEnd`
|
|
174
|
-
*
|
|
175
|
-
*
|
|
187
|
+
* padding sits INSIDE the colour, before the reset, where `trimEnd` cannot
|
|
188
|
+
* reach it — so the coloured banner carried trailing whitespace the plain
|
|
189
|
+
* one did not, which means colour changed the layout.
|
|
176
190
|
*/
|
|
177
191
|
const raw = mark[i] ?? '';
|
|
178
192
|
const padded = text ? raw.padEnd(markWidth + GUTTER) : raw;
|
|
179
193
|
lines.push(`${mark[i] ? brand(padded) : padded}${text}`.trimEnd());
|
|
180
194
|
}
|
|
181
195
|
} else {
|
|
182
|
-
|
|
183
|
-
if (roomForMark) {
|
|
196
|
+
if (width >= markWidth) {
|
|
184
197
|
for (const row of mark) lines.push(brand(row));
|
|
185
198
|
lines.push('');
|
|
186
199
|
}
|
|
187
200
|
for (const text of right) lines.push(text.trimEnd());
|
|
188
201
|
}
|
|
189
202
|
|
|
190
|
-
lines.push('');
|
|
191
|
-
|
|
192
203
|
/**
|
|
193
|
-
* ⚠️
|
|
194
|
-
* above a one-shot run that has already been given its task is an instruction
|
|
195
|
-
* for something the user cannot do.
|
|
204
|
+
* ── ⚠️ WHAT IT MAY RUN IS A SEPARATE, QUIETER LINE ─────────────────────────
|
|
196
205
|
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
206
|
+
* The reference puts this at the very bottom of the screen ("bypass
|
|
207
|
+
* permissions on (shift+tab to cycle)"), away from the identity block, because
|
|
208
|
+
* it is a MODE rather than a fact about the session. Keeping it out of the
|
|
209
|
+
* three-line lockup is what stops that block growing back into the table it
|
|
210
|
+
* used to be.
|
|
199
211
|
*/
|
|
212
|
+
if (canRun) {
|
|
213
|
+
lines.push('', clampToWidth(fit(canRun, width), width));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
lines.push('');
|
|
217
|
+
|
|
200
218
|
if (interactive) {
|
|
201
219
|
/**
|
|
202
220
|
* ⭐ SHORTENED IN STAGES, NEVER TRUNCATED. A clamp would cut "leave" to
|
|
203
|
-
* "leav", and a hint with a word chopped in half
|
|
204
|
-
*
|
|
205
|
-
* impression this screen keeps making.
|
|
221
|
+
* "leav", and a hint with a word chopped in half reads as a rendering bug —
|
|
222
|
+
* precisely the impression this screen keeps making.
|
|
206
223
|
*/
|
|
207
224
|
const hints = [
|
|
208
225
|
' Type what you want done. /help for commands · exit to leave',
|
|
@@ -213,43 +230,11 @@ export function openingScreen({
|
|
|
213
230
|
}
|
|
214
231
|
|
|
215
232
|
/**
|
|
216
|
-
* ⚠️⚠️ THE LAST WORD ON WIDTH
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
* truncated rather than allowed to wrap and tear the layout.
|
|
233
|
+
* ⚠️⚠️ THE LAST WORD ON WIDTH. Everything above reasons about `.length`, which
|
|
234
|
+
* counts CODE UNITS, while a terminal counts CELLS — and the whole reason this
|
|
235
|
+
* file was rewritten is that those two disagree. Measured on the painted
|
|
236
|
+
* string with escapes discounted, so a mark wider than advertised is truncated
|
|
237
|
+
* rather than allowed to wrap and tear the layout.
|
|
222
238
|
*/
|
|
223
239
|
return lines.map((l) => clampToWidth(l, width)).join('\n');
|
|
224
240
|
}
|
|
225
|
-
|
|
226
|
-
/** Visible length, ignoring ANSI escapes — they occupy no cells. */
|
|
227
|
-
function visibleLength(s) {
|
|
228
|
-
return String(s).replace(/\[[0-9;]*m/g, '').length;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Cut a possibly-coloured string to `width` visible cells, keeping the escapes
|
|
233
|
-
* balanced so a truncation cannot leak colour into the rest of the screen.
|
|
234
|
-
*/
|
|
235
|
-
function clampToWidth(s, width) {
|
|
236
|
-
if (visibleLength(s) <= width) return s;
|
|
237
|
-
let out = '';
|
|
238
|
-
let seen = 0;
|
|
239
|
-
const re = /(\[[0-9;]*m)|([\s\S])/g;
|
|
240
|
-
let m;
|
|
241
|
-
while ((m = re.exec(s)) !== null) {
|
|
242
|
-
if (m[1]) { out += m[1]; continue; }
|
|
243
|
-
if (seen >= width) break;
|
|
244
|
-
out += m[2];
|
|
245
|
-
seen += 1;
|
|
246
|
-
}
|
|
247
|
-
/**
|
|
248
|
-
* ⚠️ ONLY RE-CLOSE A STRING THAT WAS ACTUALLY COLOURED. Appending a reset
|
|
249
|
-
* unconditionally puts four bytes on the end of every truncated PLAIN line —
|
|
250
|
-
* harmless on a terminal, and garbage the moment output is piped to a file or
|
|
251
|
-
* a CI log, which is the one place this module has already been told never to
|
|
252
|
-
* write escapes.
|
|
253
|
-
*/
|
|
254
|
-
return out.includes('') ? `${out}[0m` : out;
|
|
255
|
-
}
|
package/lib/input-box.mjs
CHANGED
|
@@ -557,7 +557,32 @@ export function pinRegion(output, { rows = 2, env = process.env } = {}) {
|
|
|
557
557
|
* is a quieter version of the same defect this rewrite removes.
|
|
558
558
|
*/
|
|
559
559
|
const makeRoom = '\n'.repeat(rows);
|
|
560
|
-
|
|
560
|
+
/**
|
|
561
|
+
* ── ⚠️⚠️ THE CURSOR GOES BACK WHERE THE CONTENT ENDED, NOT TO THE BOTTOM ──
|
|
562
|
+
*
|
|
563
|
+
* Roman, from a screenshot: the banner sat at the top, then forty blank
|
|
564
|
+
* rows, then the invitation alone near the bottom — "the prompt box is too
|
|
565
|
+
* low and you have to scroll down to see everything."
|
|
566
|
+
*
|
|
567
|
+
* The cause was the last move in the old sequence, `ESC[{bottom};1H`. It
|
|
568
|
+
* parked the cursor on the LAST row of the scrolling region, so the first
|
|
569
|
+
* thing printed after pinning landed at the bottom of the screen and the
|
|
570
|
+
* whole gap opened up behind it. A terminal fills DOWNWARD: after reserving
|
|
571
|
+
* rows, output has to carry on from exactly where the banner stopped.
|
|
572
|
+
*
|
|
573
|
+
* ⭐ DECSC/DECRC (`ESC7`/`ESC8`) rather than an absolute move, because the
|
|
574
|
+
* right row is "wherever the content happens to end" and only the terminal
|
|
575
|
+
* knows that. Setting a scroll region homes the cursor to (1,1) as a side
|
|
576
|
+
* effect — that is the reason a restore is needed at all, and the reason the
|
|
577
|
+
* old code moved the cursor somewhere explicit in the first place. It just
|
|
578
|
+
* moved it to the wrong somewhere.
|
|
579
|
+
*
|
|
580
|
+
* ⚠️ The save happens AFTER the newlines and the step back up, so the
|
|
581
|
+
* position being restored is already inside the region — a restore to a row
|
|
582
|
+
* below the region would put the cursor outside it, which is undefined
|
|
583
|
+
* across terminals.
|
|
584
|
+
*/
|
|
585
|
+
output.write(`${makeRoom}${CSI}${rows}A\x1b7${CSI}1;${bottom}r\x1b8`);
|
|
561
586
|
|
|
562
587
|
const release = () => {
|
|
563
588
|
if (released) return;
|