acuvo-code 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ENTERPRISE.md CHANGED
@@ -188,8 +188,8 @@ copy, but it *is* a place a process starts, and this is a list of those. Six and
188
188
  are the numbers to quote. Counting is the first thing a reviewer does.
189
189
 
190
190
  ⚠️ **This said "18 shipped files", then "41", then "90", then "101", then "108", and every
191
- one went stale in turn.** The package ships **119 files — 117 in `lib/`, 2 in
192
- `bin/` — about 77604 lines**, with **235 test files** beside them (counted 2026-08-22).
191
+ one went stale in turn.** The package ships **120 files — 118 in `lib/`, 2 in
192
+ `bin/` — about 77954 lines**, with **236 test files** beside them (counted 2026-08-22).
193
193
 
194
194
  ⭐ **AND THE 108 WENT STALE IN THE MOST INSTRUCTIVE WAY POSSIBLE: THREE OF THE FILES IT
195
195
  MISSED WERE REACHABLE FROM NOTHING.** `wiring-reach.test.mjs` was naming
package/bin/acuvo.mjs CHANGED
@@ -1489,10 +1489,25 @@ ${formatBoard(listed)}
1489
1489
  * chat loop owns the invitation, and neither should own both.
1490
1490
  */
1491
1491
  const { openingScreen } = await import('../lib/banner.mjs');
1492
+ /**
1493
+ * ── ⭐⭐ THE BRAND NAME, NOT THE VENDOR'S ────────────────────────────────────
1494
+ *
1495
+ * Roman: *"the model shouldn't say deepseek, it should say like Acuvo."*
1496
+ *
1497
+ * ⭐ AND THE WHOLE MAPPING ALREADY EXISTED. `lib/acuvo-models.mjs` has shipped
1498
+ * `Acuvo Flash`, `Acuvo Pro`, `Acuvo Review` and `Acuvo Vision` for days, with
1499
+ * `labelForModelId` written specifically for this — and the banner, the first
1500
+ * thing every user reads, asked nothing and printed the raw vendor slug. One
1501
+ * caller in the whole package used it. Built, correct, and unreached.
1502
+ *
1503
+ * ⚠️ It falls back to the raw id for a model we did not ship, deliberately:
1504
+ * printing "Acuvo Something" over a model somebody chose themselves would be
1505
+ * a lie in the one place that has to be true.
1506
+ */
1492
1507
  const banner = openingScreen({
1493
1508
  version: pkgVersion,
1494
1509
  workspace: shortenRoot(executor.root),
1495
- model: config.model,
1510
+ model: labelForModelId(config.model),
1496
1511
  billing,
1497
1512
  canRun: mode,
1498
1513
  interactive: false,
@@ -1,141 +1,141 @@
1
- /**
2
- * ── ⭐⭐ OUR MODELS HAVE OUR NAMES ───────────────────────────────────────────
3
- *
4
- * Everywhere a user could see a model, they saw `deepseek/deepseek-v4-flash-0731`.
5
- * That is somebody else's product name in the middle of ours, and it is wrong
6
- * for three separate reasons:
7
- *
8
- * 1. **It sells the wrong thing.** A buyer comparing us to Claude Code sees
9
- * "Sonnet vs Opus" against "deepseek-v4-flash-0731", and concludes we are
10
- * a wrapper. The work that makes this good — the harness, the pin, the
11
- * independent reviewer, the budget — is ours and is invisible in that name.
12
- * 2. **It leaks a decision we must be free to change.** The day a better or
13
- * cheaper model appears, `--model deepseek/...` is in scripts, CI configs
14
- * and muscle memory. A name we own is a name we can re-point.
15
- * 3. **It exposes a supplier to be approached directly.** Our margin comes
16
- * from the harness and the routing, not from secrecy — but there is no
17
- * reason to print the supplier list on the product.
18
- *
19
- * ⚠️ AND IT IS NOT A LIE. The underlying id is always one command away
20
- * (`acuvo doctor` prints it, the audit log records it, `--json` carries it), and
21
- * `resolveModelName` accepts a raw vendor id unchanged so nothing existing
22
- * breaks. Renaming is branding; hiding would be dishonesty, and this package
23
- * does not get to have a `refusedCommitPath` and also a secret supplier.
24
- *
25
- * ── ⚠️ TWO OF THESE ARE NOT USER-SELECTABLE, ON PURPOSE ─────────────────────
26
- *
27
- * The reviewer and the vision model are INFRASTRUCTURE. A user choosing the
28
- * model that reviews their work can (accidentally or otherwise) pick the same
29
- * one that wrote it, which turns an independent check into self-review — the
30
- * exact defect `chooseRefuteModel` exists to prevent. They are listed here so
31
- * the catalogue is complete and honest, and marked `internal` so no menu offers
32
- * them.
33
- */
34
-
35
- /**
36
- * ⭐ THE ONE MAPPING. Every other module asks this file rather than embedding a
37
- * vendor id, so re-pointing a name is a one-line change here.
38
- */
39
- export const ACUVO_MODELS = Object.freeze({
40
- 'acuvo-flash': Object.freeze({
41
- name: 'acuvo-flash',
42
- label: 'Acuvo Flash',
43
- id: 'deepseek/deepseek-v4-flash-0731',
44
- role: 'build',
45
- internal: false,
46
- /** Measured on our own 13-task bench, 2026-08-15. */
47
- blurb: 'The default. 12 of 13 on our bench for 1.5 cents. Fast, and cheap enough that verifying everything is affordable.',
48
- }),
49
- 'acuvo-pro': Object.freeze({
50
- name: 'acuvo-pro',
51
- label: 'Acuvo Pro',
52
- id: 'deepseek/deepseek-v4-pro-0813',
53
- role: 'build',
54
- internal: false,
55
- blurb: 'The strong model. Costs 1.2x Flash on a long warm session and 6x on a short cold one — worth it for hard, sustained work.',
56
- }),
57
- /**
58
- * ⚠️ INTERNAL. See the header: a user who could point the reviewer at their
59
- * own builder would silently convert an independent check into self-review.
60
- */
61
- 'acuvo-review': Object.freeze({
62
- name: 'acuvo-review',
63
- label: 'Acuvo Review',
64
- id: 'qwen/qwen3.7-flash',
65
- role: 'review',
66
- internal: true,
67
- blurb: 'Reviews the builder\'s work from a different model family, so its blind spots are not the same ones.',
68
- }),
69
- 'acuvo-vision': Object.freeze({
70
- name: 'acuvo-vision',
71
- label: 'Acuvo Vision',
72
- id: 'qwen/qwen3.7-flash',
73
- role: 'vision',
74
- internal: true,
75
- blurb: 'Looks at rendered pages and images.',
76
- }),
77
- });
78
-
79
- /** The names a user may choose between — the `/model` menu. */
80
- export function selectableModels() {
81
- return Object.values(ACUVO_MODELS).filter((m) => !m.internal);
82
- }
83
-
84
- /**
85
- * Resolve whatever the user typed into a provider model id.
86
- *
87
- * ⚠️ A RAW VENDOR ID PASSES THROUGH UNCHANGED, and that is deliberate rather
88
- * than lazy: every existing script, CI file and test that names
89
- * `deepseek/deepseek-v4-flash-0731` keeps working, and anyone who wants a model
90
- * we have never heard of can still use it. Renaming must not become a gate.
91
- *
92
- * @param {string} input `acuvo-pro`, `Acuvo Pro`, or a raw vendor id
93
- * @returns {{ ok: true, id: string, model: object|null } | { ok: false, error: string }}
94
- */
95
- export function resolveModelName(input) {
96
- const raw = String(input ?? '').trim();
97
- if (!raw) return { ok: false, error: 'no model named' };
98
-
99
- const key = raw.toLowerCase().replace(/\s+/g, '-');
100
- const hit = ACUVO_MODELS[key];
101
- if (hit) {
102
- if (hit.internal) {
103
- return {
104
- ok: false,
105
- error: `${hit.label} is chosen for you — it ${hit.role === 'review' ? 'reviews the builder\'s work, and letting you point it at the builder\'s own model would turn an independent check into self-review' : 'is used internally'}. Pick from: ${selectableModels().map((m) => m.name).join(', ')}.`,
106
- };
107
- }
108
- return { ok: true, id: hit.id, model: hit };
109
- }
110
-
111
- /**
112
- * ⚠️ A vendor id is recognised by its slash. Anything else that is not a
113
- * known name is a TYPO, and a typo must not be silently posted to a provider
114
- * as a model id — that costs a round trip to learn "no endpoints found".
115
- */
116
- if (raw.includes('/')) {
117
- const known = Object.values(ACUVO_MODELS).find((m) => m.id === raw);
118
- return { ok: true, id: raw, model: known ?? null };
119
- }
120
- return {
121
- ok: false,
122
- error: `"${raw}" is not a model this understands. Choose ${selectableModels().map((m) => m.name).join(' or ')}, or give a full provider id like deepseek/deepseek-v4-flash-0731.`,
123
- };
124
- }
125
-
126
- /**
127
- * The Acuvo name for a provider id, for anything a user reads.
128
- *
129
- * ⚠️ FALLS BACK TO THE RAW ID rather than inventing a name. A model we did not
130
- * ship is still a model somebody is running, and printing "Acuvo Something" over
131
- * it would be a lie in the one place — the receipt — that has to be true.
132
- */
133
- export function labelForModelId(id) {
134
- const hit = Object.values(ACUVO_MODELS).find((m) => m.id === String(id ?? ''));
135
- return hit ? hit.label : String(id ?? '');
136
- }
137
-
138
- /** One line per selectable model, for `--help` and the `/model` menu. */
139
- export function formatModelMenu() {
140
- return selectableModels().map((m) => ` ${m.name.padEnd(12)} ${m.label.padEnd(12)} ${m.blurb}`);
141
- }
1
+ /**
2
+ * ── ⭐⭐ OUR MODELS HAVE OUR NAMES ───────────────────────────────────────────
3
+ *
4
+ * Everywhere a user could see a model, they saw `deepseek/deepseek-v4-flash-0731`.
5
+ * That is somebody else's product name in the middle of ours, and it is wrong
6
+ * for three separate reasons:
7
+ *
8
+ * 1. **It sells the wrong thing.** A buyer comparing us to Claude Code sees
9
+ * "Sonnet vs Opus" against "deepseek-v4-flash-0731", and concludes we are
10
+ * a wrapper. The work that makes this good — the harness, the pin, the
11
+ * independent reviewer, the budget — is ours and is invisible in that name.
12
+ * 2. **It leaks a decision we must be free to change.** The day a better or
13
+ * cheaper model appears, `--model deepseek/...` is in scripts, CI configs
14
+ * and muscle memory. A name we own is a name we can re-point.
15
+ * 3. **It exposes a supplier to be approached directly.** Our margin comes
16
+ * from the harness and the routing, not from secrecy — but there is no
17
+ * reason to print the supplier list on the product.
18
+ *
19
+ * ⚠️ AND IT IS NOT A LIE. The underlying id is always one command away
20
+ * (`acuvo doctor` prints it, the audit log records it, `--json` carries it), and
21
+ * `resolveModelName` accepts a raw vendor id unchanged so nothing existing
22
+ * breaks. Renaming is branding; hiding would be dishonesty, and this package
23
+ * does not get to have a `refusedCommitPath` and also a secret supplier.
24
+ *
25
+ * ── ⚠️ TWO OF THESE ARE NOT USER-SELECTABLE, ON PURPOSE ─────────────────────
26
+ *
27
+ * The reviewer and the vision model are INFRASTRUCTURE. A user choosing the
28
+ * model that reviews their work can (accidentally or otherwise) pick the same
29
+ * one that wrote it, which turns an independent check into self-review — the
30
+ * exact defect `chooseRefuteModel` exists to prevent. They are listed here so
31
+ * the catalogue is complete and honest, and marked `internal` so no menu offers
32
+ * them.
33
+ */
34
+
35
+ /**
36
+ * ⭐ THE ONE MAPPING. Every other module asks this file rather than embedding a
37
+ * vendor id, so re-pointing a name is a one-line change here.
38
+ */
39
+ export const ACUVO_MODELS = Object.freeze({
40
+ 'acuvo-flash': Object.freeze({
41
+ name: 'acuvo-flash',
42
+ label: 'Acuvo Flash 1',
43
+ id: 'deepseek/deepseek-v4-flash-0731',
44
+ role: 'build',
45
+ internal: false,
46
+ /** Measured on our own 13-task bench, 2026-08-15. */
47
+ blurb: 'The default. 12 of 13 on our bench for 1.5 cents. Fast, and cheap enough that verifying everything is affordable.',
48
+ }),
49
+ 'acuvo-pro': Object.freeze({
50
+ name: 'acuvo-pro',
51
+ label: 'Acuvo Pro 1',
52
+ id: 'deepseek/deepseek-v4-pro-0813',
53
+ role: 'build',
54
+ internal: false,
55
+ blurb: 'The strong model. Costs 1.2x Flash on a long warm session and 6x on a short cold one — worth it for hard, sustained work.',
56
+ }),
57
+ /**
58
+ * ⚠️ INTERNAL. See the header: a user who could point the reviewer at their
59
+ * own builder would silently convert an independent check into self-review.
60
+ */
61
+ 'acuvo-review': Object.freeze({
62
+ name: 'acuvo-review',
63
+ label: 'Acuvo Review 1',
64
+ id: 'qwen/qwen3.7-flash',
65
+ role: 'review',
66
+ internal: true,
67
+ blurb: 'Reviews the builder\'s work from a different model family, so its blind spots are not the same ones.',
68
+ }),
69
+ 'acuvo-vision': Object.freeze({
70
+ name: 'acuvo-vision',
71
+ label: 'Acuvo Vision 1',
72
+ id: 'qwen/qwen3.7-flash',
73
+ role: 'vision',
74
+ internal: true,
75
+ blurb: 'Looks at rendered pages and images.',
76
+ }),
77
+ });
78
+
79
+ /** The names a user may choose between — the `/model` menu. */
80
+ export function selectableModels() {
81
+ return Object.values(ACUVO_MODELS).filter((m) => !m.internal);
82
+ }
83
+
84
+ /**
85
+ * Resolve whatever the user typed into a provider model id.
86
+ *
87
+ * ⚠️ A RAW VENDOR ID PASSES THROUGH UNCHANGED, and that is deliberate rather
88
+ * than lazy: every existing script, CI file and test that names
89
+ * `deepseek/deepseek-v4-flash-0731` keeps working, and anyone who wants a model
90
+ * we have never heard of can still use it. Renaming must not become a gate.
91
+ *
92
+ * @param {string} input `acuvo-pro`, `Acuvo Pro`, or a raw vendor id
93
+ * @returns {{ ok: true, id: string, model: object|null } | { ok: false, error: string }}
94
+ */
95
+ export function resolveModelName(input) {
96
+ const raw = String(input ?? '').trim();
97
+ if (!raw) return { ok: false, error: 'no model named' };
98
+
99
+ const key = raw.toLowerCase().replace(/\s+/g, '-');
100
+ const hit = ACUVO_MODELS[key];
101
+ if (hit) {
102
+ if (hit.internal) {
103
+ return {
104
+ ok: false,
105
+ error: `${hit.label} is chosen for you — it ${hit.role === 'review' ? 'reviews the builder\'s work, and letting you point it at the builder\'s own model would turn an independent check into self-review' : 'is used internally'}. Pick from: ${selectableModels().map((m) => m.name).join(', ')}.`,
106
+ };
107
+ }
108
+ return { ok: true, id: hit.id, model: hit };
109
+ }
110
+
111
+ /**
112
+ * ⚠️ A vendor id is recognised by its slash. Anything else that is not a
113
+ * known name is a TYPO, and a typo must not be silently posted to a provider
114
+ * as a model id — that costs a round trip to learn "no endpoints found".
115
+ */
116
+ if (raw.includes('/')) {
117
+ const known = Object.values(ACUVO_MODELS).find((m) => m.id === raw);
118
+ return { ok: true, id: raw, model: known ?? null };
119
+ }
120
+ return {
121
+ ok: false,
122
+ error: `"${raw}" is not a model this understands. Choose ${selectableModels().map((m) => m.name).join(' or ')}, or give a full provider id like deepseek/deepseek-v4-flash-0731.`,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * The Acuvo name for a provider id, for anything a user reads.
128
+ *
129
+ * ⚠️ FALLS BACK TO THE RAW ID rather than inventing a name. A model we did not
130
+ * ship is still a model somebody is running, and printing "Acuvo Something" over
131
+ * it would be a lie in the one place — the receipt — that has to be true.
132
+ */
133
+ export function labelForModelId(id) {
134
+ const hit = Object.values(ACUVO_MODELS).find((m) => m.id === String(id ?? ''));
135
+ return hit ? hit.label : String(id ?? '');
136
+ }
137
+
138
+ /** One line per selectable model, for `--help` and the `/model` menu. */
139
+ export function formatModelMenu() {
140
+ return selectableModels().map((m) => ` ${m.name.padEnd(12)} ${m.label.padEnd(12)} ${m.blurb}`);
141
+ }
package/lib/chat.mjs CHANGED
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import { createInterface } from 'node:readline';
25
+ import { readBoxedLine } from './input-box.mjs';
25
26
  import { EXIT_INTERRUPTED } from './interrupt.mjs';
26
27
  import { parseSlash, runSlashCommand } from './slash.mjs';
27
28
  import { estimateMessagesTokens } from './compact.mjs';
@@ -319,7 +320,23 @@ export async function runChat({
319
320
  const queued = interactive ? null : await readAllLines(input);
320
321
  let queueIndex = 0;
321
322
 
322
- const rl = interactive ? createInterface({ input, output, terminal: true }) : null;
323
+ /**
324
+ * ── ⚠️⚠️ READLINE IS GONE FROM THE INTERACTIVE PATH, AND IT HAD TO GO ──────
325
+ *
326
+ * `input-box.mjs` now owns the keyboard. Leaving the readline interface
327
+ * attached to the SAME stream was not merely redundant — both it and the box
328
+ * saw every Ctrl-C, so `onInterrupt` fired TWICE for one keypress. Measured,
329
+ * not theorised: a single `\x03` produced `['interrupt', 'interrupt']`.
330
+ *
331
+ * ⭐ A DOUBLE INTERRUPT IS NOT A COSMETIC BUG. The second Ctrl-C is the one
332
+ * that QUITS — so one press would have armed and fired the escape hatch in the
333
+ * same instant, ending a session the user meant only to nudge.
334
+ *
335
+ * The Node-internals note below about `question()` swallowing Ctrl-C is kept
336
+ * because it is why the box reads raw keys itself rather than asking readline
337
+ * for a line. It is history now, not a live constraint.
338
+ */
339
+ const rl = null;
323
340
  const state = { closed: false };
324
341
  /**
325
342
  * ⚠️ ATTACHED ONCE, NOT PER QUESTION — and the per-question version is why the
@@ -350,6 +367,8 @@ export async function runChat({
350
367
  * to the model; this is the variable that makes the verb real.
351
368
  */
352
369
  let pendingInject = null;
370
+ /** Lines the user has submitted this session — the box's Up/Down history. */
371
+ const typed = [];
353
372
 
354
373
  try {
355
374
  for (;;) {
@@ -374,12 +393,19 @@ export async function runChat({
374
393
  * because `columns` is `undefined` when stdout is not a TTY and enormous
375
394
  * when someone maximises on an ultrawide.
376
395
  */
377
- const width = Math.max(40, Math.min(100, (output.columns ?? process.stdout.columns ?? 80) - 1));
378
- if (interactive) output.write(`\n╭${'─'.repeat(width - 1)}\n`);
379
- const line = interactive
380
- ? await ask(rl, '│ › ', state)
381
- : (queueIndex < queued.length ? queued[queueIndex++] : null);
382
- if (interactive && line !== null) output.write(`╰${'─'.repeat(width - 1)}\n`);
396
+ let line;
397
+ if (interactive) {
398
+ output.write('\n');
399
+ const got = await readBoxedLine({
400
+ input,
401
+ output,
402
+ history: typed,
403
+ onInterrupt,
404
+ });
405
+ line = got.value;
406
+ } else {
407
+ line = queueIndex < queued.length ? queued[queueIndex++] : null;
408
+ }
383
409
  // Echo a piped prompt so a scripted transcript reads like a session.
384
410
  if (!interactive && line !== null && line.trim()) output.write(`› ${line.trim()}
385
411
  `);
@@ -389,6 +415,9 @@ export async function runChat({
389
415
  if (line === null) break;
390
416
  const task = line.trim();
391
417
  if (!task) continue;
418
+ // ⚠️ Deduped against the PREVIOUS entry only: pressing Up should walk
419
+ // distinct instructions, not scroll through five copies of `npm test`.
420
+ if (typed[typed.length - 1] !== task) typed.push(task);
392
421
  if (QUIT.has(task.toLowerCase())) break;
393
422
 
394
423
  /**
@@ -0,0 +1,299 @@
1
+ /**
2
+ * ── ⭐⭐⭐ A PERSISTENT INPUT BOX, BECAUSE READLINE CANNOT DRAW ONE ──────────
3
+ *
4
+ * Roman, repeatedly: *"the box that I am typing in right now needs to be real."*
5
+ *
6
+ * ── ⚠️⚠️ WHY READLINE WAS NEVER GOING TO WORK, MEASURED ─────────────────────
7
+ *
8
+ * Pre-drawing a four-sided box and asking `readline.question()` to type inside
9
+ * it produces this on the very first keystroke:
10
+ *
11
+ * \x1b[1G\x1b[0J
12
+ *
13
+ * Column 1, then **clear to end of screen**. Readline owns everything from the
14
+ * cursor down and erases it to redraw the line — so the bottom border and the
15
+ * right edge are gone before the user has typed a second character. No amount of
16
+ * re-drawing wins that fight; it repaints on every key.
17
+ *
18
+ * ⭐ SO THE ANSWER IS TO OWN THE RENDER. This is a small raw-mode line editor:
19
+ * it reads keys, keeps the buffer, and paints three lines itself. That is what
20
+ * every terminal app with a real input box does, and it is why they can have one.
21
+ *
22
+ * ── ⚠️ WHAT IT MUST NOT LOSE ────────────────────────────────────────────────
23
+ *
24
+ * A half-built line editor is WORSE than a plain prompt: backspace that does
25
+ * nothing, or an arrow key that prints `^[[D`, makes the tool feel broken in a
26
+ * way `› ` never did. So the keys people actually use are all handled —
27
+ * backspace, delete, left/right, home/end, word-left/right, history up/down,
28
+ * Ctrl-C, Ctrl-D, Ctrl-U/K/W — and each is tested.
29
+ */
30
+
31
+ const ESC = '\x1b';
32
+ const CSI = `${ESC}[`;
33
+
34
+ /** Keys that are not text. Kept as one table so the handler stays readable. */
35
+ const KEY = Object.freeze({
36
+ ENTER: '\r',
37
+ NEWLINE: '\n',
38
+ BACKSPACE: '\x7f',
39
+ BACKSPACE_ALT: '\b',
40
+ CTRL_C: '\x03',
41
+ CTRL_D: '\x04',
42
+ CTRL_U: '\x15',
43
+ CTRL_K: '\x0b',
44
+ CTRL_W: '\x17',
45
+ CTRL_A: '\x01',
46
+ CTRL_E: '\x05',
47
+ });
48
+
49
+ /**
50
+ * Visible width of a string, ignoring ANSI. Deliberately simple: this package
51
+ * has no dependencies and a full grapheme/east-asian-width implementation is a
52
+ * library. It is correct for the ASCII and box characters we draw, and errs by
53
+ * over-counting a wide glyph rather than under — which wraps early rather than
54
+ * overflowing the border.
55
+ */
56
+ export function visibleWidth(s) {
57
+ return String(s ?? '').replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').length;
58
+ }
59
+
60
+ /**
61
+ * Render the three lines of the box for a given buffer.
62
+ *
63
+ * Exported so the layout can be asserted without a terminal — the render and
64
+ * the key handling are separately testable, which is the only way a thing like
65
+ * this stays correct.
66
+ *
67
+ * @returns {{lines: string[], cursorColumn: number}} 1-based cursor column
68
+ */
69
+ export function renderBox({ value = '', cursor = 0, columns = 80, prompt = '› ' } = {}) {
70
+ const width = Math.max(20, Math.min(100, columns - 1));
71
+ const inner = width - 2;
72
+ const promptWidth = visibleWidth(prompt);
73
+
74
+ /**
75
+ * ⚠️ THE VIEW SCROLLS, THE BUFFER DOES NOT. A long line must not wrap — a
76
+ * wrapped line pushes the bottom border down and the box stops being a box.
77
+ * So the buffer is windowed around the cursor and the border stays put, which
78
+ * is what every real input does.
79
+ */
80
+ const room = inner - promptWidth - 1;
81
+ let start = 0;
82
+ if (cursor > room) start = cursor - room;
83
+ const shown = value.slice(start, start + room);
84
+
85
+ const body = `${prompt}${shown}`;
86
+ const pad = ' '.repeat(Math.max(0, inner - visibleWidth(body)));
87
+
88
+ return {
89
+ lines: [
90
+ `╭${'─'.repeat(width - 2)}╮`,
91
+ `│${body}${pad}│`,
92
+ `╰${'─'.repeat(width - 2)}╯`,
93
+ ],
94
+ // 1-based: the │, then the prompt, then however far into the shown text.
95
+ cursorColumn: 1 + 1 + promptWidth + (cursor - start),
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Apply one keypress to the editor state.
101
+ *
102
+ * ⚠️ PURE, AND THAT IS THE WHOLE POINT. Every key can be tested without a TTY,
103
+ * without timing, and without a terminal to inspect afterwards. The half of this
104
+ * module that touches the terminal does nothing but paint what this returns.
105
+ *
106
+ * @returns {{value, cursor, historyIndex, done?: 'submit'|'cancel'|'eof'}}
107
+ */
108
+ export function applyKey(state, key) {
109
+ const { value, cursor, history = [], historyIndex = history.length } = state;
110
+ /**
111
+ * ⚠️ `history` IS CARRIED THROUGH, AND ITS ABSENCE WAS A REAL BUG. The first
112
+ * version returned only `{value, cursor, historyIndex}` — so the history array
113
+ * was dropped by the FIRST keystroke, and pressing Up afterwards silently did
114
+ * nothing. It looked like the history feature was unimplemented rather than
115
+ * like state was being lost, which is exactly the kind of bug a pure function
116
+ * makes visible and a stateful one hides.
117
+ */
118
+ const keep = (over = {}) => ({ value, cursor, history, historyIndex, draft: state.draft, ...over });
119
+
120
+ if (key === KEY.ENTER || key === KEY.NEWLINE) return keep({ done: 'submit' });
121
+ if (key === KEY.CTRL_C) return keep({ done: 'cancel' });
122
+ /**
123
+ * ⚠️ Ctrl-D IS EOF ONLY ON AN EMPTY LINE. On a line with text it is
124
+ * forward-delete — collapsing the two would exit the session when someone
125
+ * meant to delete a character, which is a data-loss-shaped surprise.
126
+ */
127
+ if (key === KEY.CTRL_D) {
128
+ if (value.length === 0) return keep({ done: 'eof' });
129
+ return keep({ value: value.slice(0, cursor) + value.slice(cursor + 1) });
130
+ }
131
+
132
+ if (key === KEY.BACKSPACE || key === KEY.BACKSPACE_ALT) {
133
+ if (cursor === 0) return keep();
134
+ return keep({ value: value.slice(0, cursor - 1) + value.slice(cursor), cursor: cursor - 1 });
135
+ }
136
+
137
+ if (key === KEY.CTRL_U) return keep({ value: value.slice(cursor), cursor: 0 });
138
+ if (key === KEY.CTRL_K) return keep({ value: value.slice(0, cursor) });
139
+ if (key === KEY.CTRL_A) return keep({ cursor: 0 });
140
+ if (key === KEY.CTRL_E) return keep({ cursor: value.length });
141
+
142
+ if (key === KEY.CTRL_W) {
143
+ const upto = value.slice(0, cursor);
144
+ const cut = upto.replace(/\s*\S+$/, '');
145
+ return keep({ value: cut + value.slice(cursor), cursor: cut.length });
146
+ }
147
+
148
+ // Arrows and Home/End arrive as escape sequences.
149
+ if (key === `${CSI}D`) return keep({ cursor: Math.max(0, cursor - 1) });
150
+ if (key === `${CSI}C`) return keep({ cursor: Math.min(value.length, cursor + 1) });
151
+ if (key === `${CSI}H` || key === `${CSI}1~`) return keep({ cursor: 0 });
152
+ if (key === `${CSI}F` || key === `${CSI}4~`) return keep({ cursor: value.length });
153
+ if (key === `${CSI}3~`) return keep({ value: value.slice(0, cursor) + value.slice(cursor + 1) });
154
+
155
+ /**
156
+ * History. ⚠️ The index may sit one PAST the end — that position is "the line
157
+ * I was typing", so walking up and back down returns what you had rather than
158
+ * silently eating it.
159
+ */
160
+ if (key === `${CSI}A`) {
161
+ if (historyIndex === 0 || history.length === 0) return keep();
162
+ const i = historyIndex - 1;
163
+ /**
164
+ * ⚠️ THE DRAFT IS SAVED ON THE WAY UP. Leaving it behind means a half-typed
165
+ * line is destroyed by a single Up press — the user glances at what they ran
166
+ * before, comes back, and their sentence is gone. Losing typed input to a
167
+ * navigation key is the least forgivable bug a line editor can have.
168
+ */
169
+ const draft = historyIndex === history.length ? value : state.draft;
170
+ return keep({ value: history[i], cursor: history[i].length, historyIndex: i, draft });
171
+ }
172
+ if (key === `${CSI}B`) {
173
+ if (historyIndex >= history.length) return keep({ draft: state.draft });
174
+ const i = historyIndex + 1;
175
+ const next = i === history.length ? (state.draft ?? '') : history[i];
176
+ return keep({ value: next, cursor: next.length, historyIndex: i, draft: state.draft });
177
+ }
178
+
179
+ /**
180
+ * ⚠️ EVERYTHING ELSE CONTROL-SHAPED IS DROPPED, NOT INSERTED. An unhandled
181
+ * escape sequence typed into the buffer shows the user `^[[5~` and looks like
182
+ * the tool is broken — the one impression a new line editor cannot afford.
183
+ */
184
+ if (key.startsWith(ESC)) return keep();
185
+ if (key.length === 1 && key < ' ') return keep();
186
+
187
+ return keep({ value: value.slice(0, cursor) + key + value.slice(cursor), cursor: cursor + key.length });
188
+ }
189
+
190
+ /**
191
+ * Split a raw chunk into keys. A paste arrives as one chunk and an arrow key as
192
+ * three bytes, so neither "one byte per key" nor "one chunk per key" is right.
193
+ */
194
+ export function splitKeys(chunk) {
195
+ const s = String(chunk);
196
+ const keys = [];
197
+ for (let i = 0; i < s.length; i += 1) {
198
+ if (s[i] === ESC) {
199
+ const m = /^\x1b\[[0-9;]*[A-Za-z~]/.exec(s.slice(i));
200
+ if (m) { keys.push(m[0]); i += m[0].length - 1; continue; }
201
+ }
202
+ keys.push(s[i]);
203
+ }
204
+ return keys;
205
+ }
206
+
207
+ /**
208
+ * Draw the box and park the cursor inside it.
209
+ *
210
+ * ⚠️ THE CURSOR IS HIDDEN WHILE PAINTING. Without it, the cursor visibly darts
211
+ * to the end of each border as it is written — which reads as a flicker and is
212
+ * the difference between a rendered box and a drawn one.
213
+ */
214
+ export function paint(output, state, { first = false } = {}) {
215
+ const { lines, cursorColumn } = renderBox(state);
216
+ const up = first ? '' : `${CSI}3A`;
217
+ output.write(
218
+ `${CSI}?25l${up}\r${CSI}0J${lines.join('\n')}\n` +
219
+ `${CSI}2A\r${CSI}${cursorColumn}G${CSI}?25h`,
220
+ );
221
+ }
222
+
223
+ /**
224
+ * ── ⭐⭐⭐ THE ONLY PART THAT TOUCHES A TERMINAL ─────────────────────────────
225
+ *
226
+ * Reads one line inside the box. Everything decision-shaped lives in `applyKey`
227
+ * and `renderBox`, which are pure and fully tested; this function does nothing
228
+ * but move bytes and paint what they return.
229
+ *
230
+ * @returns {Promise<{value: string|null, reason: 'submit'|'cancel'|'eof'}>}
231
+ */
232
+ export function readBoxedLine({ input, output, history = [], onInterrupt = null, prompt = '› ' }) {
233
+ return new Promise((resolve) => {
234
+ let state = { value: '', cursor: 0, history, historyIndex: history.length, draft: '' };
235
+ const columns = () => output.columns ?? process.stdout.columns ?? 80;
236
+
237
+ /**
238
+ * ⚠️ RAW MODE IS RESTORED ON EVERY EXIT PATH, INCLUDING THE UNHAPPY ONES.
239
+ * A process that leaves the terminal in raw mode hands the user a shell with
240
+ * no echo and no line editing — they have to type `reset` blind. That is the
241
+ * single worst thing a CLI can do to somebody's session.
242
+ */
243
+ let finished = false;
244
+ const finish = (value, reason) => {
245
+ if (finished) return;
246
+ finished = true;
247
+ input.off('data', onData);
248
+ input.off('end', onEnd);
249
+ try { input.setRawMode?.(false); } catch { /* not a TTY any more */ }
250
+ output.write('\n');
251
+ resolve({ value, reason });
252
+ };
253
+
254
+ const onEnd = () => finish(null, 'eof');
255
+
256
+ const onData = (chunk) => {
257
+ for (const key of splitKeys(chunk)) {
258
+ const next = applyKey(state, key);
259
+ if (next.done === 'submit') {
260
+ state = next;
261
+ // Repaint once so the committed line is what stays on screen.
262
+ paint(output, { ...state, columns: columns() });
263
+ return finish(state.value, 'submit');
264
+ }
265
+ if (next.done === 'cancel') {
266
+ /**
267
+ * ⚠️ Ctrl-C ON A NON-EMPTY LINE CLEARS IT; on an empty one it means
268
+ * "stop". Anything else makes the first Ctrl-C — the one people press
269
+ * to abandon a sentence — quit the whole session.
270
+ */
271
+ /**
272
+ * ⚠️⚠️ Ctrl-C NEVER ENDS THE READ — it clears the line and hands the
273
+ * event on. The first version finished with `cancel` on an empty line,
274
+ * which ended the session on the FIRST press: there was then no prompt
275
+ * for a second Ctrl-C to arrive at, so the "press again to quit"
276
+ * escape hatch could never fire. `onInterrupt` owns that decision (see
277
+ * `interrupt.mjs`), and it can only own it if it keeps being called.
278
+ *
279
+ * ⭐ It is also what every shell does: Ctrl-C gives you a fresh line.
280
+ * Quitting is `exit`, or Ctrl-D on an empty one.
281
+ */
282
+ state = { ...state, value: '', cursor: 0, historyIndex: history.length, draft: '' };
283
+ onInterrupt?.();
284
+ paint(output, { ...state, columns: columns() });
285
+ continue;
286
+ }
287
+ if (next.done === 'eof') return finish(null, 'eof');
288
+ state = next;
289
+ paint(output, { ...state, columns: columns() });
290
+ }
291
+ };
292
+
293
+ try { input.setRawMode?.(true); } catch { /* not a TTY */ }
294
+ input.resume?.();
295
+ paint(output, { ...state, columns: columns() }, { first: true });
296
+ input.on('data', onData);
297
+ input.once('end', onEnd);
298
+ });
299
+ }
package/lib/plan.mjs CHANGED
@@ -33,6 +33,8 @@
33
33
  * context is coldest, and it is measured rather than reasoned.
34
34
  */
35
35
 
36
+ import { labelForModelId } from './acuvo-models.mjs';
37
+
36
38
  /**
37
39
  * Per-million prices, USD, from OpenRouter's per-model endpoint feed on
38
40
  * 2026-08-15, for the endpoint each model is PINNED to.
@@ -422,12 +424,25 @@ export function planGate({ plan, usedByModel = {}, model, projectedTokens = 0, l
422
424
  return { allowed: true, reason: 'ok', remaining, message: null };
423
425
  }
424
426
 
425
- /** Acuvo's name for a provider id, without importing the catalogue into the math. */
426
- function labelFor(id) {
427
- if (id === 'deepseek/deepseek-v4-flash-0731') return 'Acuvo Flash';
428
- if (id === 'deepseek/deepseek-v4-pro-0813') return 'Acuvo Pro';
429
- return id;
430
- }
427
+ /**
428
+ * ── ⚠️⚠️ THIS WAS A SECOND COPY OF THE BRAND MAP, AND IT DRIFTED ────────────
429
+ *
430
+ * It read: *"Acuvo's name for a provider id, without importing the catalogue
431
+ * into the math"* — and hardcoded `Acuvo Flash` / `Acuvo Pro`. The moment the
432
+ * labels were versioned in `acuvo-models.mjs`, this file kept printing the old
433
+ * names, in the messages a user sees when they run out of allowance. Two places
434
+ * naming the same product, one of them silently a version behind.
435
+ *
436
+ * ⭐ THE STATED REASON DID NOT HOLD. `acuvo-models.mjs` is a LEAF — it imports
437
+ * nothing — so pulling it in adds no dependency chain to "the math"; it is a
438
+ * frozen object. Avoiding the import bought nothing and cost a divergence.
439
+ *
440
+ * This is the fourth instance of the same shape found today: a mapping copied
441
+ * rather than imported, correct on the day it was written, wrong the first time
442
+ * the original changed.
443
+ */
444
+
445
+ const labelFor = labelForModelId;
431
446
 
432
447
  /**
433
448
  * Tokens used per PROVIDER id, from audit records.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acuvo-code",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "description": "Acuvo Code — the terminal client for the Acuvo capability registry. Zero dependencies, by design.",
5
5
  "type": "module",
6
6
  "bin": {