acuvo-code 0.6.3 → 0.6.5

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 CHANGED
@@ -188,7 +188,7 @@ copy, but it *is* a place a process starts, and this is a list of those. Six and
188
188
  are the numbers to quote. Counting is the first thing a reviewer does.
189
189
 
190
190
  ⚠️ **This said "18 shipped files", then "41", then "90", then "101", then "108", and every
191
- one went stale in turn.** The package ships **120 files — 118 in `lib/`, 2 in
191
+ one went stale in turn.** The package ships **122 files — 120 in `lib/`, 2 in
192
192
  `bin/` — about 78511 lines**, with **238 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
@@ -892,7 +892,7 @@ 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 120 files and 78511 lines,
895
+ - ⭐ **Zero dependencies.** The entire auditable surface is 122 files and 79553 lines,
896
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
package/bin/acuvo.mjs CHANGED
@@ -143,6 +143,23 @@ const asker = createAsker();
143
143
  * word "compaction" to stop paying for a transcript they cannot see.
144
144
  */
145
145
  import { runDoctor, formatDoctor } from '../lib/doctor.mjs';
146
+
147
+ /**
148
+ * The package version, readable from anywhere in this file.
149
+ *
150
+ * ⚠️ THE LOCAL `pkgVersion` IS DECLARED 300 LINES BELOW ITS FIRST USE and is a
151
+ * `const`, so reaching for it earlier is a temporal-dead-zone ReferenceError
152
+ * rather than an undefined — which is how `--render-report` crashed the moment
153
+ * it was wired in. A second reader is the small fix; hoisting the const would
154
+ * move a declaration other code depends on being late.
155
+ */
156
+ function readPkgVersion() {
157
+ try {
158
+ return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
159
+ } catch {
160
+ return '';
161
+ }
162
+ }
146
163
  /**
147
164
  * ── ⭐ SHELL COMPLETION — built, tested, and reachable from nothing until now ──
148
165
  * `lib/completion.mjs` is 509 lines that generate bash, zsh and fish scripts
@@ -309,6 +326,7 @@ const LIFECYCLE_USAGE = [
309
326
  * sentence changed.
310
327
  */
311
328
  ' --doctor Say what is actually working here: key, model chain, media',
329
+ ' --render-report Print how this terminal draws the opening screen, to paste back',
312
330
  ' endpoints, which tools would be offered, git. Every dark or',
313
331
  ' broken line names the exact variable that fixes it. Exits 0',
314
332
  ' when nothing is broken. ⚠️ It VERIFIES over the network: your',
@@ -351,7 +369,7 @@ const VALUED_LIFECYCLE_FLAGS = new Map([
351
369
  function extractLifecycleFlags(argv) {
352
370
  const flags = {
353
371
  sessions: false, resume: null, continueLatest: false, save: true, audit: true,
354
- doctor: false, replay: null, diff: null, only: null, design: null,
372
+ doctor: false, renderReport: false, replay: null, diff: null, only: null, design: null,
355
373
  login: false, loginToken: null, logout: false, whoami: false,
356
374
  };
357
375
  const rest = [];
@@ -359,6 +377,13 @@ function extractLifecycleFlags(argv) {
359
377
  const arg = argv[i];
360
378
  if (arg === '--sessions') { flags.sessions = true; continue; }
361
379
  if (arg === '--doctor') { flags.doctor = true; continue; }
380
+ /**
381
+ * ⭐ THE INSTRUMENT FOR A SCREEN I CANNOT SEE. Five reports, four fixes,
382
+ * and every one reasoned from bytes that render perfectly on my machine.
383
+ * This prints the facts that decide the layout so the next round is
384
+ * arithmetic instead of inference.
385
+ */
386
+ if (arg === '--render-report' || arg === '--render') { flags.renderReport = true; continue; }
362
387
  if (arg === '--logout') { flags.logout = true; continue; }
363
388
  if (arg === '--whoami') { flags.whoami = true; continue; }
364
389
  /**
@@ -1185,6 +1210,22 @@ ${formatBoard(listed)}
1185
1210
  return EXIT_OK;
1186
1211
  }
1187
1212
 
1213
+ /**
1214
+ * ⚠️ BEFORE `--doctor`, AND BEFORE ANYTHING THAT PRINTS. This report is about
1215
+ * how the terminal DRAWS, so nothing may have written to the screen ahead of
1216
+ * it — a banner above the ruler would be measuring a screen we had already
1217
+ * disturbed.
1218
+ */
1219
+ if (life.renderReport) {
1220
+ const { renderReport, formatRenderReport } = await import('../lib/render-report.mjs');
1221
+ const rep = await renderReport({ input: process.stdin, output: process.stdout, env: process.env, version: readPkgVersion() });
1222
+ if (opts.json) process.stdout.write(`${JSON.stringify(rep, null, 2)}
1223
+ `);
1224
+ else process.stdout.write(`${formatRenderReport(rep)}
1225
+ `);
1226
+ return EXIT_OK;
1227
+ }
1228
+
1188
1229
  if (life.doctor) {
1189
1230
  const report = await runDoctor({ root, allowRun: opts.allowRun, maxRounds: opts.maxRounds, skipNetwork: opts.offline === true });
1190
1231
  if (opts.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
@@ -1489,6 +1530,28 @@ ${formatBoard(listed)}
1489
1530
  * chat loop owns the invitation, and neither should own both.
1490
1531
  */
1491
1532
  const { openingScreen } = await import('../lib/banner.mjs');
1533
+ /**
1534
+ * ── ⭐⭐⭐ ASK THE TERMINAL, DO NOT ASSUME IT ────────────────────────────────
1535
+ *
1536
+ * The half-block mark is only correct if this terminal renders ▀ ▄ █ at ONE
1537
+ * cell. They are East-Asian-Ambiguous, so that is a property of the font and
1538
+ * the emulator, not of the character — and where they come out double-width
1539
+ * the mark TEARS: the padding spaces stay narrow while the blocks do not, so
1540
+ * every row shifts by a different amount and the right-hand column is pushed
1541
+ * off the screen.
1542
+ *
1543
+ * ⚠️ THIS IS THE FOURTH ATTEMPT AT THIS SCREEN AND THE FIRST THAT MEASURES.
1544
+ * The previous three reasoned from byte sequences that were correct on my
1545
+ * machine and wrong on Roman's, which is the only machine that counts.
1546
+ * `measureCellWidth` prints the glyph and asks the terminal where the cursor
1547
+ * landed — not an inference about fonts, the terminal describing itself.
1548
+ * Unknown falls back to the ASCII mark: a plainer logo on a capable terminal
1549
+ * costs a little beauty, torn blocks on an incapable one cost a first
1550
+ * impression.
1551
+ */
1552
+ const { measureCellWidth, bannerStyle } = await import('../lib/glyph-width.mjs');
1553
+ const cellWidth = await measureCellWidth({ input: process.stdin, output: process.stdout });
1554
+ const bannerLook = bannerStyle({ cellWidth, env: process.env });
1492
1555
  /**
1493
1556
  * ── ⭐⭐ THE BRAND NAME, NOT THE VENDOR'S ────────────────────────────────────
1494
1557
  *
@@ -1518,6 +1581,14 @@ ${formatBoard(listed)}
1518
1581
  * site.
1519
1582
  */
1520
1583
  paint: createPainter(colourEnabled()),
1584
+ style: bannerLook,
1585
+ /**
1586
+ * ⚠️ THE REAL WIDTH, NOT A CONSTANT. The banner used to build to a fixed
1587
+ * 80 columns and never ask, so in any narrower terminal — a split pane, a
1588
+ * side panel — every row wrapped. `columns` is undefined off a TTY, which
1589
+ * the banner reads as "assume 80": right for a pipe, wrong for nothing.
1590
+ */
1591
+ columns: process.stdout.columns ?? null,
1521
1592
  });
1522
1593
  if (opts.json) process.stderr.write(banner);
1523
1594
  else process.stdout.write(banner);
package/lib/banner.mjs CHANGED
@@ -1,155 +1,255 @@
1
- /**
2
- * ── ⭐⭐⭐ WHAT YOU SEE WHEN YOU TYPE `acuvo` ────────────────────────────────
3
- *
4
- * Roman, 2026-08-22, having typed it: *"it's not opening as a typable terminal,
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"*.
7
- *
8
- * So this is the real mark, not letters spelling the name. It is
9
- * `console/public/brand/acuvo-mark.png` — the angular A — resampled to
10
- * half-blocks at 14x14 and embedded as text.
11
- *
12
- * ── ⚠️ WHY IT IS EMBEDDED RATHER THAN DECODED AT RUNTIME ────────────────────
13
- *
14
- * This package has ZERO dependencies, deliberately, and Node cannot decode a
15
- * PNG without one. Converting at build time and pasting the result keeps that
16
- * promise and costs nothing at startup. If the mark ever changes, re-run the
17
- * conversion — the logo is DERIVED from the brand asset, not drawn by hand, so
18
- * it stays honest to it.
19
- *
20
- * ── ⚠️ THE CONSTRAINTS, WHICH ARE NOT PREFERENCES ───────────────────────────
21
- *
22
- * · **Half-block glyphs only** (▀ ▄ █). Braille and box-drawing render as tofu
23
- * in cmd.exe and in many CI log viewers, and a logo that renders as question
24
- * marks is worse than no logo — on the exact platform this is developed on.
25
- * · **No colour escapes here.** The caller owns colour and honours NO_COLOR; a
26
- * module that hard-codes them emits garbage the moment output is piped.
27
- * · **Every line under 80 columns.** The narrowest terminal in real use is 80,
28
- * and a wrapped banner does not read as dense, it reads as broken.
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.
35
- */
36
-
37
- /**
38
- * The Acuvo mark, resampled from the brand PNG. Seven rows: tall enough to be
39
- * the mark rather than a smudge, short enough for something run forty times a
40
- * day rather than opened once.
41
- */
42
- const MARK = [
43
- ' ▄█',
44
- ' ▄███',
45
- ' ▄█▀▀██',
46
- ' ▄█▀ ▀██',
47
- ' ▄█▀ ██ ▀██',
48
- ' ▄████▀▀█████',
49
- '█▀ █▀ ▀█',
50
- ];
51
-
52
- const MARK_WIDTH = Math.max(...MARK.map((l) => l.length));
53
- const GUTTER = 2;
54
- export const MAX_BANNER_COLUMNS = 80;
55
-
56
- /** Columns left for the detail rows once the mark and gutter are placed. */
57
- const TEXT_COLUMNS = MAX_BANNER_COLUMNS - MARK_WIDTH - GUTTER;
58
-
59
- /**
60
- * Build the opening screen: mark on the left, facts on the right.
61
- *
62
- * @param {object} o
63
- * @param {string} o.version
64
- * @param {string} o.workspace already shortened by the caller
65
- * @param {string} o.model
66
- * @param {string} o.billing who this run will charge
67
- * @param {string} o.canRun what it may execute, or that it may not
68
- * @param {boolean} [o.interactive] whether a prompt follows
69
- * @returns {string}
70
- */
71
- export function openingScreen({ version, workspace, model, billing, canRun, interactive = false, paint = null }) {
72
- /**
73
- * ── ⭐ THE MARK IS BRAND GREEN, AND ONLY THE MARK ────────────────────────────
74
- *
75
- * Roman: *"can you colour our logo green in the terminal?"* #C8E91E, sampled
76
- * from the brand PNG rather than picked by eye.
77
- *
78
- * ⚠️ THE PAINTER IS INJECTED, NOT IMPORTED. The caller already decided whether
79
- * this stream can take colour — it owns NO_COLOR, FORCE_COLOR, TERM=dumb and
80
- * TTY detection. A module that reaches for colour itself will eventually
81
- * disagree with that decision and write escapes into somebody's redirected
82
- * file.
83
- *
84
- * ⚠️ AND ONLY THE LOGO. Colouring the detail rows would make the one line that
85
- * must be read as a warning — `billing: YOUR OWN OpenRouter key` — compete
86
- * with decoration.
87
- */
88
- const brand = paint?.brand ?? ((s) => s);
89
- /**
90
- * ── ⚠️⚠️ ELIDED, BECAUSE THE VALUES ARE NOT OURS ────────────────────────────
91
- *
92
- * Every value arrives at runtime: a deep monorepo path, a long model id, the
93
- * shell-mode warning. The first version assumed they would be short and its
94
- * own test caught it wrapping at 80 columns on ordinary inputs.
95
- *
96
- * ELIDED IN THE MIDDLE, NOT THE END. The informative parts of a path are
97
- * the drive and the leaf; chopping the tail leaves
98
- * `C:\Users\somebody\Projects\a-`, which identifies nothing. Same for a model
99
- * id, where the family is at the front and the variant at the back.
100
- */
101
- const room = TEXT_COLUMNS - 11;
102
- const fit = (v) => {
103
- const s = String(v ?? '');
104
- if (s.length <= room) return s;
105
- const head = Math.ceil((room - 1) / 2);
106
- return `${s.slice(0, head)}…${s.slice(s.length - (room - 1 - head))}`;
107
- };
108
-
109
- const right = [
110
- `ACUVO CODE${version ? ` ${version}` : ''}`,
111
- '',
112
- `workspace ${fit(workspace)}`,
113
- `model ${fit(model)}`,
114
- `billing ${fit(billing)}`,
115
- `can run ${fit(canRun)}`,
116
- '',
117
- ];
118
-
119
- /**
120
- * ⚠️ THE TWO COLUMNS ARE ZIPPED, NOT CONCATENATED, and the row counts are
121
- * allowed to differ whichever is shorter simply runs out. Assuming they
122
- * match would break the layout the first time a row is added to either side.
123
- */
124
- const rows = Math.max(MARK.length, right.length);
125
- const lines = [''];
126
- for (let i = 0; i < rows; i += 1) {
127
- const text = right[i] ?? '';
128
- /**
129
- * ⚠️ PADDED FIRST, PAINTED SECOND — escape codes have no width, so padding a
130
- * coloured string aligns the text against invisible bytes and the whole
131
- * right-hand column drifts.
132
- *
133
- * ⚠️⚠️ AND PADDED ONLY WHEN SOMETHING FOLLOWS. On a mark-only row the
134
- * padding sits INSIDE the colour, before the reset, where the `trimEnd`
135
- * below cannot reach it so the coloured banner carried trailing
136
- * whitespace the plain one did not. Invisible, but it means colour changed
137
- * the layout, which is exactly what the guard forbids.
138
- */
139
- const rawMark = MARK[i] ?? '';
140
- const padded = text ? rawMark.padEnd(MARK_WIDTH + GUTTER) : rawMark;
141
- const mark = MARK[i] ? brand(padded) : padded;
142
- lines.push(`${mark}${text}`.trimEnd());
143
- }
144
- lines.push('');
145
-
146
- /**
147
- * ⚠️ ONLY WHEN A PROMPT ACTUALLY FOLLOWS. Printing "type what you want done"
148
- * above a one-shot run that has already been given its task is an instruction
149
- * for something the user cannot do, on the screen of a thing already working.
150
- */
151
- if (interactive) {
152
- lines.push(' Type what you want done. /help for commands · exit to leave', '');
153
- }
154
- return lines.join('\n');
155
- }
1
+ /**
2
+ * ── ⭐⭐⭐ WHAT YOU SEE WHEN YOU TYPE `acuvo` ────────────────────────────────
3
+ *
4
+ * Roman, 2026-08-22, having typed it: *"it's not opening as a typable terminal,
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"*.
7
+ *
8
+ * So this is the real mark, not letters spelling the name. It is
9
+ * `console/public/brand/acuvo-mark.png` — the angular A — resampled to
10
+ * half-blocks at 14x14 and embedded as text.
11
+ *
12
+ * ── ⚠️ WHY IT IS EMBEDDED RATHER THAN DECODED AT RUNTIME ────────────────────
13
+ *
14
+ * This package has ZERO dependencies, deliberately, and Node cannot decode a
15
+ * PNG without one. Converting at build time and pasting the result keeps that
16
+ * promise and costs nothing at startup. If the mark ever changes, re-run the
17
+ * conversion — the logo is DERIVED from the brand asset, not drawn by hand, so
18
+ * it stays honest to it.
19
+ *
20
+ * ── ⚠️ THE CONSTRAINTS, WHICH ARE NOT PREFERENCES ───────────────────────────
21
+ *
22
+ * · **Half-block glyphs only** (▀ ▄ █). Braille and box-drawing render as tofu
23
+ * in cmd.exe and in many CI log viewers, and a logo that renders as question
24
+ * marks is worse than no logo — on the exact platform this is developed on.
25
+ * · **No colour escapes here.** The caller owns colour and honours NO_COLOR; a
26
+ * module that hard-codes them emits garbage the moment output is piped.
27
+ * · **Every line under 80 columns.** The narrowest terminal in real use is 80,
28
+ * and a wrapped banner does not read as dense, it reads as broken.
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.
35
+ */
36
+
37
+ /**
38
+ * The Acuvo mark, resampled from the brand PNG. Seven rows: tall enough to be
39
+ * the mark rather than a smudge, short enough for something run forty times a
40
+ * day rather than opened once.
41
+ */
42
+ const MARK = [
43
+ ' ▄█',
44
+ ' ▄███',
45
+ ' ▄█▀▀██',
46
+ ' ▄█▀ ▀██',
47
+ ' ▄█▀ ██ ▀██',
48
+ ' ▄████▀▀█████',
49
+ '█▀ █▀ ▀█',
50
+ ];
51
+
52
+ /**
53
+ * ── ⭐⭐⭐ THE SAME MARK, IN CHARACTERS NOTHING CAN STRETCH ─────────────────
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.
58
+ *
59
+ * ⚠️ THIS IS NOT "NO LOGO" — that distinction matters. Roman asked for OUR
60
+ * mark and then asked again when he got letters spelling the name. The fallback
61
+ * for a terminal that cannot draw half-blocks must therefore still BE the
62
+ * angular A, drawn a different way, rather than a wordmark standing in for it.
63
+ * A degraded logo is a logo; a text substitute is a missing one.
64
+ */
65
+ const MARK_ASCII = [
66
+ ' /\\',
67
+ ' / \\',
68
+ ' / /\\ \\',
69
+ ' / / \\ \\',
70
+ ' / /____\\ \\',
71
+ '/_/ \\_\\',
72
+ ];
73
+
74
+ const GUTTER = 2;
75
+
76
+ /**
77
+ * The widest the banner may ever be when the terminal will not say how wide it
78
+ * is. Kept at 80 because that is the narrowest terminal in real use, so a
79
+ * banner built to it fits everywhere.
80
+ */
81
+ export const MAX_BANNER_COLUMNS = 80;
82
+
83
+ /** Below this the two-column layout stops being a layout and starts being a mess. */
84
+ const MIN_TEXT_COLUMNS = 30;
85
+
86
+ /**
87
+ * Build the opening screen: mark on the left, facts on the right.
88
+ *
89
+ * @param {object} o
90
+ * @param {string} o.version
91
+ * @param {string} o.workspace already shortened by the caller
92
+ * @param {string} o.model
93
+ * @param {string} o.billing who this run will charge
94
+ * @param {string} o.canRun what it may execute, or that it may not
95
+ * @param {boolean} [o.interactive] whether a prompt follows
96
+ * @param {'blocks'|'text'} [o.style] which mark to draw see `lib/glyph-width.mjs`
97
+ * @param {number} [o.columns] the real terminal width, when it is known
98
+ * @returns {string}
99
+ */
100
+ export function openingScreen({
101
+ version, workspace, model, billing, canRun,
102
+ interactive = false, paint = null, style = 'blocks', columns = null,
103
+ }) {
104
+ const brand = paint?.brand ?? ((s) => s);
105
+
106
+ /**
107
+ * ── ⚠️⚠️ THE WIDTH IS THE TERMINAL'S, NOT A CONSTANT ──────────────────
108
+ *
109
+ * This used to build to a fixed 80 columns and never ask. Every line it
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.
117
+ */
118
+ const width = Math.max(20, Math.min(MAX_BANNER_COLUMNS, (columns ?? MAX_BANNER_COLUMNS) - 1));
119
+
120
+ const mark = style === 'text' ? MARK_ASCII : MARK;
121
+ const markWidth = Math.max(...mark.map((l) => l.length));
122
+
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
+ const sideBySide = width >= markWidth + GUTTER + MIN_TEXT_COLUMNS;
131
+ const textColumns = sideBySide ? width - markWidth - GUTTER : width;
132
+
133
+ /**
134
+ * ⚠️ ELIDED IN THE MIDDLE, NOT THE END. The informative parts of a path are
135
+ * the drive and the leaf; chopping the tail leaves `C:\Users\somebody\Projects\a-`,
136
+ * which identifies nothing. Same for a model id, where the family is at the
137
+ * front and the variant at the back.
138
+ */
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
+ const right = [
148
+ `ACUVO CODE${version ? ` ${version}` : ''}`,
149
+ '',
150
+ `workspace ${fit(workspace)}`,
151
+ `model ${fit(model)}`,
152
+ `billing ${fit(billing)}`,
153
+ `can run ${fit(canRun)}`,
154
+ '',
155
+ ];
156
+
157
+ const lines = [''];
158
+
159
+ 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
+ const rows = Math.max(mark.length, right.length);
165
+ for (let i = 0; i < rows; i += 1) {
166
+ const text = right[i] ?? '';
167
+ /**
168
+ * ⚠️ PADDED FIRST, PAINTED SECOND — escape codes have no width, so padding
169
+ * a coloured string aligns text against invisible bytes and the whole
170
+ * right-hand column drifts.
171
+ *
172
+ * ⚠️⚠️ AND PADDED ONLY WHEN SOMETHING FOLLOWS. On a mark-only row the
173
+ * padding sits INSIDE the colour, before the reset, where `trimEnd`
174
+ * cannot reach it — so the coloured banner carried trailing whitespace the
175
+ * plain one did not. Invisible, but it means colour changed the layout.
176
+ */
177
+ const raw = mark[i] ?? '';
178
+ const padded = text ? raw.padEnd(markWidth + GUTTER) : raw;
179
+ lines.push(`${mark[i] ? brand(padded) : padded}${text}`.trimEnd());
180
+ }
181
+ } else {
182
+ const roomForMark = width >= markWidth;
183
+ if (roomForMark) {
184
+ for (const row of mark) lines.push(brand(row));
185
+ lines.push('');
186
+ }
187
+ for (const text of right) lines.push(text.trimEnd());
188
+ }
189
+
190
+ lines.push('');
191
+
192
+ /**
193
+ * ⚠️ ONLY WHEN A PROMPT ACTUALLY FOLLOWS. Printing "type what you want done"
194
+ * above a one-shot run that has already been given its task is an instruction
195
+ * for something the user cannot do.
196
+ *
197
+ * ⭐ And it is SHORTENED rather than wrapped when the terminal is narrow. A
198
+ * hint that wraps onto a second line looks like an error message.
199
+ */
200
+ if (interactive) {
201
+ /**
202
+ * ⭐ SHORTENED IN STAGES, NEVER TRUNCATED. A clamp would cut "leave" to
203
+ * "leav", and a hint with a word chopped in half does not read as a
204
+ * compact hint — it reads as a rendering bug, which is precisely the
205
+ * impression this screen keeps making.
206
+ */
207
+ const hints = [
208
+ ' Type what you want done. /help for commands · exit to leave',
209
+ ' /help for commands · exit to leave',
210
+ ' /help · exit',
211
+ ];
212
+ lines.push(hints.find((h) => h.length <= width) ?? hints[hints.length - 1], '');
213
+ }
214
+
215
+ /**
216
+ * ⚠️⚠️ THE LAST WORD ON WIDTH, AND IT IS NOT A BELT-AND-BRACES CHECK.
217
+ * Everything above reasons about `.length`, which counts CODE UNITS, while a
218
+ * terminal counts CELLS — and the whole reason this file was rewritten is that
219
+ * those two disagree. This clamp is measured on the painted string with the
220
+ * escapes discounted, so a mark that turns out wider than advertised is
221
+ * truncated rather than allowed to wrap and tear the layout.
222
+ */
223
+ return lines.map((l) => clampToWidth(l, width)).join('\n');
224
+ }
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}` : out;
255
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * ── ⭐⭐⭐ ASK THE TERMINAL HOW WIDE A GLYPH ACTUALLY IS ─────────────────────
3
+ *
4
+ * Roman, 2026-08-22, third report on the same screen: *"acuvo cli still is
5
+ * structured wrong, I can't even see our logo, and the words etc, like it
6
+ * doesn't fit."*
7
+ *
8
+ * ── ⚠️ WHY THE BANNER CAN BE CORRECT AND STILL SHATTER ──────────────────────
9
+ *
10
+ * The mark is built from half-block glyphs (▀ ▄ █, U+2580–U+2588). Unicode
11
+ * classifies them as **East Asian Ambiguous**, which means their width is not a
12
+ * property of the character — it is a property of the terminal and the font. A
13
+ * terminal that renders them at TWO cells turns a 14-column mark into 28
14
+ * columns, while the SPACES padding each row stay at one. The art does not get
15
+ * uniformly wider; it tears, every row by a different amount, and the right-hand
16
+ * column is shoved off the screen. That is exactly "I can't even see our logo,
17
+ * and the words don't fit", and it is invisible from any machine where the font
18
+ * happens to render them narrow — including mine, where the banner measures a
19
+ * tidy 72 columns and looks perfect.
20
+ *
21
+ * ⭐ SO STOP GUESSING AND MEASURE. Print the glyph, ask the terminal where the
22
+ * cursor ended up (`ESC[6n`, the DSR cursor-position report from ECMA-48), and
23
+ * subtract. The answer is not an inference about fonts or platforms; it is the
24
+ * terminal reporting its own behaviour. This is what every serious TUI does for
25
+ * emoji and CJK, and it is the only instrument that works from here — three
26
+ * previous attempts at this screen were reasoned from byte sequences and all
27
+ * three were wrong on the one machine that mattered.
28
+ *
29
+ * ── ⚠️ AND IT MUST FAIL SAFE, BECAUSE IT TOUCHES THE SCREEN ─────────────────
30
+ *
31
+ * The probe writes a character and reads a reply. If the terminal never answers
32
+ * — a dumb TERM, a pipe, a CI log, an editor's embedded console that swallows
33
+ * DSR — it must give up quickly, erase what it wrote, restore raw mode exactly
34
+ * as it found it, and return `null` for "unknown". A diagnostic that hangs the
35
+ * program it is diagnosing, or that leaves the terminal in raw mode after a
36
+ * failure, is worse than the bug.
37
+ */
38
+
39
+ /** The DSR request: "report the cursor position". */
40
+ const CURSOR_QUERY = '\x1b[6n';
41
+
42
+ /**
43
+ * Parse a cursor-position report, `ESC [ row ; col R`.
44
+ *
45
+ * ⚠️ SCANS FOR THE PATTERN RATHER THAN ANCHORING AT THE START. The reply can
46
+ * arrive glued to whatever else the user typed — a keystroke that landed during
47
+ * the round trip sits in the same chunk — and an anchored match would discard a
48
+ * perfectly good measurement because somebody pressed a key.
49
+ *
50
+ * @param {string} buf
51
+ * @returns {{ row: number, col: number } | null}
52
+ */
53
+ export function parseCursorReport(buf) {
54
+ const m = /\x1b\[(\d+);(\d+)R/.exec(String(buf ?? ''));
55
+ if (!m) return null;
56
+ const row = Number(m[1]);
57
+ const col = Number(m[2]);
58
+ if (!Number.isFinite(row) || !Number.isFinite(col) || col < 1) return null;
59
+ return { row, col };
60
+ }
61
+
62
+ /**
63
+ * Anything left over once the report is removed — the user's keystrokes.
64
+ *
65
+ * ⚠️ THEY MUST BE HANDED BACK, NOT DROPPED. A character typed while the probe
66
+ * was in flight belongs to the program, and eating it makes the very first
67
+ * keystroke of a session vanish at random.
68
+ *
69
+ * @param {string} buf
70
+ * @returns {string}
71
+ */
72
+ export function residualInput(buf) {
73
+ return String(buf ?? '').replace(/\x1b\[\d+;\d+R/, '');
74
+ }
75
+
76
+ /**
77
+ * How many cells the terminal gives `glyph`.
78
+ *
79
+ * @param {object} o
80
+ * @param {string} [o.glyph] the character to measure
81
+ * @param {NodeJS.ReadStream} o.input
82
+ * @param {NodeJS.WriteStream} o.output
83
+ * @param {number} [o.timeoutMs]
84
+ * @returns {Promise<number|null>} 1, 2, … or null when the terminal did not say
85
+ */
86
+ export async function measureCellWidth({ glyph = '█', input, output, timeoutMs = 200 } = {}) {
87
+ if (!input?.isTTY || !output?.isTTY || typeof input.setRawMode !== 'function') return null;
88
+
89
+ const wasRaw = input.isRaw === true;
90
+ let done = false;
91
+ let buffered = '';
92
+
93
+ return new Promise((resolve) => {
94
+ /**
95
+ * ⚠️ ONE EXIT PATH, AND IT ALWAYS CLEANS UP. Every way out of this — a
96
+ * reply, a timeout, a stream error — goes through here, because a probe
97
+ * that returns early on the happy path and leaks a listener on the sad one
98
+ * is how a CLI ends up with a terminal stuck in raw mode after a hiccup.
99
+ */
100
+ const finish = (value) => {
101
+ if (done) return;
102
+ done = true;
103
+ clearTimeout(timer);
104
+ input.removeListener('data', onData);
105
+ try {
106
+ // Erase the probe glyph and put the cursor back at the start of the line.
107
+ output.write('\r\x1b[2K');
108
+ } catch { /* the stream went away; nothing to clean */ }
109
+ try {
110
+ if (!wasRaw) input.setRawMode(false);
111
+ } catch { /* likewise */ }
112
+ // Give any keystrokes that arrived during the probe back to the program.
113
+ const rest = residualInput(buffered);
114
+ if (rest) input.unshift?.(rest);
115
+ resolve(value);
116
+ };
117
+
118
+ const onData = (chunk) => {
119
+ buffered += String(chunk);
120
+ const report = parseCursorReport(buffered);
121
+ if (!report) return;
122
+ /**
123
+ * The cursor started at column 1, so the glyph consumed `col - 1` cells.
124
+ * Guarded because a terminal that answers nonsense should read as unknown
125
+ * rather than as a plausible-looking wrong number.
126
+ */
127
+ const cells = report.col - 1;
128
+ finish(cells >= 1 && cells <= 4 ? cells : null);
129
+ };
130
+
131
+ const timer = setTimeout(() => finish(null), timeoutMs);
132
+
133
+ try {
134
+ if (!wasRaw) input.setRawMode(true);
135
+ input.on('data', onData);
136
+ // `\r` first: measure from a known column, not from wherever we happened to be.
137
+ output.write(`\r${glyph}${CURSOR_QUERY}`);
138
+ } catch {
139
+ finish(null);
140
+ }
141
+ });
142
+ }
143
+
144
+ /**
145
+ * The decision the banner actually needs, with every override that matters.
146
+ *
147
+ * ⚠️ AN EXPLICIT SETTING OUTRANKS THE MEASUREMENT, ALWAYS. Detection is very
148
+ * good and will still be wrong somewhere, and when it is, the user must have a
149
+ * way to say so that does not require them to file a bug and wait for a
150
+ * release. `ACUVO_BANNER=text` (or `blocks`) is that way.
151
+ *
152
+ * ⚠️ AND UNKNOWN FALLS BACK TO **TEXT**, not to blocks. The two errors are not
153
+ * symmetric: choosing text on a terminal that could have drawn the mark costs a
154
+ * little beauty, while choosing blocks on a terminal that cannot costs the user
155
+ * a screen of torn garbage as their first impression of the product. This is
156
+ * the mistake that has now been reported three times, so the default leans away
157
+ * from it.
158
+ *
159
+ * @param {object} o
160
+ * @param {number|null} o.cellWidth measured, or null
161
+ * @param {Record<string,string|undefined>} [o.env]
162
+ * @returns {'blocks'|'text'}
163
+ */
164
+ export function bannerStyle({ cellWidth, env = {} }) {
165
+ const forced = String(env.ACUVO_BANNER ?? '').toLowerCase();
166
+ if (forced === 'text' || forced === 'ascii') return 'text';
167
+ if (forced === 'blocks' || forced === 'art') return 'blocks';
168
+ return cellWidth === 1 ? 'blocks' : 'text';
169
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * ── ⭐⭐⭐ STOP GUESSING AT SOMEBODY ELSE'S SCREEN ───────────────────────────
3
+ *
4
+ * Roman has now reported this screen wrong five times: "not opening as a
5
+ * typable terminal", "you have to scroll down", "now it's just the box,
6
+ * everything else is gone", "I can't even see our logo, it doesn't fit", and
7
+ * "CLI structure is still cooked, it's not loading like our Claude reference."
8
+ *
9
+ * ⚠️ FIVE REPORTS, FOUR FIXES, AND I HAVE NEVER ONCE SEEN THE THING I AM
10
+ * FIXING. Every attempt was reasoned from byte sequences that render perfectly
11
+ * here, and the only instrument that has ever caught a defect was him opening a
12
+ * terminal and describing it in words. That is a slow, lossy channel, and after
13
+ * four rounds it is plainly not converging.
14
+ *
15
+ * ⭐ SO THIS IS THE INSTRUMENT. One command prints every fact that determines
16
+ * how the opening screen draws — the emulator's identity, its real size, the
17
+ * MEASURED cell width of the glyphs the logo is built from, which layout that
18
+ * selects, and then the banner itself under a column ruler. Paste it back and
19
+ * the diagnosis is arithmetic instead of inference.
20
+ *
21
+ * ⚠️ IT PRINTS FACTS, NOT A VERDICT. A report that says "looks fine to me" when
22
+ * the user is staring at a broken screen is worse than no report — it argues
23
+ * with them. Every line here is something observed, and where nothing could be
24
+ * observed it says so.
25
+ */
26
+
27
+ import { openingScreen } from './banner.mjs';
28
+ import { measureCellWidth, bannerStyle } from './glyph-width.mjs';
29
+
30
+ /** The environment variables that actually change how this draws. */
31
+ const RELEVANT_ENV = [
32
+ 'TERM', 'TERM_PROGRAM', 'TERM_PROGRAM_VERSION', 'COLORTERM',
33
+ 'WT_SESSION', 'ConEmuANSI', 'SESSIONNAME',
34
+ 'NO_COLOR', 'FORCE_COLOR', 'CI',
35
+ 'ACUVO_BANNER', 'ACUVO_PIN', 'LANG', 'LC_ALL',
36
+ ];
37
+
38
+ /**
39
+ * Gather everything, measuring what can be measured.
40
+ *
41
+ * @param {object} o
42
+ * @param {NodeJS.ReadStream} o.input
43
+ * @param {NodeJS.WriteStream} o.output
44
+ * @param {Record<string,string|undefined>} [o.env]
45
+ * @param {string} [o.version]
46
+ */
47
+ export async function renderReport({ input, output, env = process.env, version = '' }) {
48
+ /**
49
+ * ⚠️ MEASURED, NOT ASSUMED — and the ASCII fallback glyphs are measured too.
50
+ * If a terminal renders `/` or `\` at anything other than one cell we would
51
+ * want to know, because the "safe" fallback would not be safe either and the
52
+ * whole strategy needs rethinking rather than another patch.
53
+ */
54
+ const block = await measureCellWidth({ glyph: '█', input, output });
55
+ const slash = await measureCellWidth({ glyph: '/', input, output });
56
+
57
+ const style = bannerStyle({ cellWidth: block, env });
58
+
59
+ return {
60
+ columns: output?.columns ?? null,
61
+ rows: output?.rows ?? null,
62
+ isTTY: Boolean(output?.isTTY),
63
+ stdinIsTTY: Boolean(input?.isTTY),
64
+ blockCellWidth: block,
65
+ asciiCellWidth: slash,
66
+ style,
67
+ env: Object.fromEntries(RELEVANT_ENV.map((k) => [k, env[k] ?? null])),
68
+ version,
69
+ };
70
+ }
71
+
72
+ /** A ruler that makes an off-by-N obvious at a glance. */
73
+ export function ruler(width) {
74
+ const w = Math.max(1, Math.min(400, Number(width) || 80));
75
+ let out = '';
76
+ for (let i = 1; i <= w; i += 1) {
77
+ out += i % 10 === 0 ? String((i / 10) % 10) : i % 5 === 0 ? '+' : '.';
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /**
83
+ * Render the report as text safe to paste into a chat.
84
+ *
85
+ * ⚠️ NO COLOUR ANYWHERE IN THIS OUTPUT. It exists to be copied into a message,
86
+ * and escape codes pasted into a chat window arrive as visual noise that hides
87
+ * the very alignment the report is about.
88
+ */
89
+ export function formatRenderReport(r) {
90
+ const say = (v) => (v === null || v === undefined ? 'unknown' : String(v));
91
+ const lines = [];
92
+
93
+ lines.push('ACUVO RENDER REPORT' + (r.version ? ` ${r.version}` : ''));
94
+ lines.push('');
95
+ lines.push(`terminal size ${say(r.columns)} x ${say(r.rows)}`);
96
+ lines.push(`stdout is a TTY ${r.isTTY}`);
97
+ lines.push(`stdin is a TTY ${r.stdinIsTTY}`);
98
+ lines.push('');
99
+
100
+ /**
101
+ * ⭐ THE TWO LINES THE WHOLE INVESTIGATION TURNS ON. A block width of 2 means
102
+ * the half-block logo CANNOT be drawn correctly here, and no amount of layout
103
+ * work will fix it — the answer is the ASCII mark. A width of 1 rules that
104
+ * out entirely and points the search somewhere else.
105
+ */
106
+ lines.push(`block glyph width ${say(r.blockCellWidth)} cell(s) <- 1 = safe, 2 = the logo tears`);
107
+ lines.push(`ascii glyph width ${say(r.asciiCellWidth)} cell(s) <- must be 1, or the fallback is unsafe too`);
108
+ lines.push(`chosen mark ${r.style}`);
109
+ lines.push('');
110
+
111
+ const envLines = Object.entries(r.env).filter(([, v]) => v !== null);
112
+ lines.push(envLines.length ? 'environment' : 'environment (none of the relevant variables are set)');
113
+ for (const [k, v] of envLines) lines.push(` ${k.padEnd(20)} ${v}`);
114
+ lines.push('');
115
+
116
+ const width = r.columns ?? 80;
117
+ lines.push(`ruler (${width} columns) — the banner below must never reach the end of this line:`);
118
+ lines.push(ruler(width));
119
+ lines.push('');
120
+ lines.push(openingScreen({
121
+ version: r.version || '0.0.0',
122
+ workspace: 'C:/example/workspace',
123
+ model: 'Acuvo Flash 1',
124
+ billing: 'Acuvo account',
125
+ canRun: 'read + write + shell',
126
+ interactive: true,
127
+ style: r.style,
128
+ columns: r.columns,
129
+ }));
130
+ lines.push(ruler(width));
131
+ lines.push('');
132
+ lines.push('If the mark above looks torn or the rows do not line up, paste this whole');
133
+ lines.push('report back. Every number needed to diagnose it is in here.');
134
+
135
+ return lines.join('\n');
136
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acuvo-code",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "Acuvo Code — the terminal client for the Acuvo capability registry. Zero dependencies, by design.",
5
5
  "type": "module",
6
6
  "bin": {