acuvo-code 0.5.4 → 0.6.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/lib/input-box.mjs CHANGED
@@ -66,7 +66,14 @@ export function visibleWidth(s) {
66
66
  *
67
67
  * @returns {{lines: string[], cursorColumn: number}} 1-based cursor column
68
68
  */
69
- export function renderBox({ value = '', cursor = 0, columns = 80, prompt = '› ' } = {}) {
69
+ /**
70
+ * The names offered by the slash menu. Passed in rather than imported so this
71
+ * module keeps no opinion about what commands exist — `slash.mjs` owns that
72
+ * list, and a second copy here would drift the first time one is added.
73
+ */
74
+ export const DEFAULT_COMMANDS = Object.freeze(['help', 'skills', 'mcp', 'cost', 'model', 'clear']);
75
+
76
+ export function renderBox({ value = '', cursor = 0, columns = 80, prompt = '› ', commands = DEFAULT_COMMANDS } = {}) {
70
77
  /**
71
78
  * ── ⚠️ FULL WIDTH. THE 100-COLUMN CAP WAS WRONG AND IT LOOKED WRONG ────────
72
79
  *
@@ -113,13 +120,48 @@ export function renderBox({ value = '', cursor = 0, columns = 80, prompt = '›
113
120
  * ⚠️ Two rows, not three — so the reserved region shrinks with it and the
114
121
  * transcript gets a row back.
115
122
  */
123
+ /**
124
+ * ── ⭐⭐⭐ THE SLASH MENU — WHAT YOU CAN DO, AT THE MOMENT YOU ASK ───────────
125
+ *
126
+ * Roman: *"when users do a special Acuvo command it changes the colour and
127
+ * also shows you your options in a dropdown."*
128
+ *
129
+ * ⭐ THIS IS THE DISCOVERABILITY FIX, NOT DECORATION. 28 skills, 7 commands,
130
+ * MCP in both directions — all built, and nothing on screen ever mentioned
131
+ * any of it. A capability nobody is shown is worth zero, which is the same
132
+ * defect as the toolbox and the whiteboard. Typing one character is the
133
+ * cheapest possible moment to answer "what can this thing do".
134
+ *
135
+ * ⚠️ IT REPLACES THE RULE RATHER THAN ADDING A ROW. The input lives in a
136
+ * reserved region of fixed height; growing it would mean resizing the scroll
137
+ * region mid-keystroke, and a region that changes size while a transcript is
138
+ * scrolling through it is how the display gets corrupted. The rule is already
139
+ * a full-width line doing nothing — so it becomes the menu while a command is
140
+ * being typed, and goes back to being a rule the moment it is not.
141
+ */
142
+ const slash = /^\/(\S*)$/.exec(value);
143
+ let rule = '─'.repeat(width);
144
+ if (slash) {
145
+ const typed = slash[1].toLowerCase();
146
+ const matches = commands.filter((c) => c.startsWith(typed));
147
+ /**
148
+ * ⚠️ NO MATCHES IS ITS OWN ANSWER. Falling back to the full list would tell
149
+ * somebody who mistyped that everything is fine; saying so is what lets them
150
+ * fix it.
151
+ */
152
+ const shown = matches.length ? matches.map((c) => `/${c}`).join(' ') : 'no command starts with that';
153
+ const label = ` ${shown} `;
154
+ rule = visibleWidth(label) >= width
155
+ ? label.slice(0, width)
156
+ : `${label}${'─'.repeat(width - visibleWidth(label))}`;
157
+ }
158
+
116
159
  return {
117
- lines: [
118
- '─'.repeat(width),
119
- `${body}${pad}`,
120
- ],
160
+ lines: [rule, `${body}${pad}`],
121
161
  // 1-based, and there is no left border to skip any more.
122
162
  cursorColumn: 1 + promptWidth + (cursor - start),
163
+ /** True while a slash command is being typed — the caller paints it. */
164
+ isCommand: Boolean(slash),
123
165
  };
124
166
  }
125
167
 
@@ -238,8 +280,23 @@ export function splitKeys(chunk) {
238
280
  * to the end of each border as it is written — which reads as a flicker and is
239
281
  * the difference between a rendered box and a drawn one.
240
282
  */
241
- export function paint(output, state, { first = false, atRow = 0 } = {}) {
242
- const { lines, cursorColumn } = renderBox(state);
283
+ export function paint(output, state, { first = false, atRow = 0, paintFn = null } = {}) {
284
+ const r = renderBox(state);
285
+ const { cursorColumn } = r;
286
+ /**
287
+ * ── ⭐ THE INPUT TURNS BRAND GREEN WHILE A COMMAND IS BEING TYPED ──────────
288
+ *
289
+ * Roman: *"it changes the colour and also shows you your options."* The colour
290
+ * is the faster half of that — it says "you are in a different mode" before
291
+ * anyone has read a single menu entry.
292
+ *
293
+ * ⚠️ THE PAINTER IS INJECTED and colour is applied AFTER layout, never before:
294
+ * escape codes have no width, so colouring a string and then padding it aligns
295
+ * the text against invisible bytes. `renderBox` stays pure and returns plain
296
+ * text; this is the only place that knows about colour.
297
+ */
298
+ const brand = (paintFn && r.isCommand) ? paintFn : null;
299
+ const lines = brand ? r.lines.map((l) => brand(l)) : r.lines;
243
300
 
244
301
  /**
245
302
  * ── ⭐⭐⭐ PINNED: DRAW AT AN ABSOLUTE ROW, NOT WHEREVER THE CURSOR IS ───────
@@ -311,7 +368,7 @@ export function paint(output, state, { first = false, atRow = 0 } = {}) {
311
368
  *
312
369
  * @returns {Promise<{value: string|null, reason: 'submit'|'cancel'|'eof'}>}
313
370
  */
314
- export function readBoxedLine({ input, output, history = [], onInterrupt = null, prompt = '› ', atRow = 0 }) {
371
+ export function readBoxedLine({ input, output, history = [], onInterrupt = null, prompt = '› ', atRow = 0, commands = DEFAULT_COMMANDS, paintFn = null }) {
315
372
  return new Promise((resolve) => {
316
373
  let state = { value: '', cursor: 0, history, historyIndex: history.length, draft: '' };
317
374
  const columns = () => output.columns ?? process.stdout.columns ?? 80;
@@ -346,7 +403,7 @@ export function readBoxedLine({ input, output, history = [], onInterrupt = null,
346
403
  * instead of on top of it.
347
404
  */
348
405
  output.write(`${CSI}${atRow - 1};1H\n${prompt}${value ?? ''}\n`);
349
- paint(output, { value: '', cursor: 0, columns: output.columns ?? 80 }, { atRow });
406
+ paint(output, { value: '', cursor: 0, columns: output.columns ?? 80, commands }, { atRow, paintFn });
350
407
  output.write(`${CSI}${atRow - 1};1H`);
351
408
  } else {
352
409
  /**
@@ -370,7 +427,7 @@ export function readBoxedLine({ input, output, history = [], onInterrupt = null,
370
427
  if (next.done === 'submit') {
371
428
  state = next;
372
429
  // Repaint once so the committed line is what stays on screen.
373
- paint(output, { ...state, columns: columns() }, { atRow });
430
+ paint(output, { ...state, columns: columns(), commands }, { atRow, paintFn });
374
431
  return finish(state.value, 'submit');
375
432
  }
376
433
  if (next.done === 'cancel') {
@@ -392,18 +449,18 @@ export function readBoxedLine({ input, output, history = [], onInterrupt = null,
392
449
  */
393
450
  state = { ...state, value: '', cursor: 0, historyIndex: history.length, draft: '' };
394
451
  onInterrupt?.();
395
- paint(output, { ...state, columns: columns() }, { atRow });
452
+ paint(output, { ...state, columns: columns(), commands }, { atRow, paintFn });
396
453
  continue;
397
454
  }
398
455
  if (next.done === 'eof') return finish(null, 'eof');
399
456
  state = next;
400
- paint(output, { ...state, columns: columns() }, { atRow });
457
+ paint(output, { ...state, columns: columns(), commands }, { atRow, paintFn });
401
458
  }
402
459
  };
403
460
 
404
461
  try { input.setRawMode?.(true); } catch { /* not a TTY */ }
405
462
  input.resume?.();
406
- paint(output, { ...state, columns: columns() }, { first: true, atRow });
463
+ paint(output, { ...state, columns: columns(), commands }, { first: true, atRow, paintFn });
407
464
  input.on('data', onData);
408
465
  input.once('end', onEnd);
409
466
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acuvo-code",
3
- "version": "0.5.4",
3
+ "version": "0.6.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": {