opencode-auto-agent 1.3.1 → 1.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/bin/cli.js CHANGED
@@ -15,6 +15,9 @@
15
15
 
16
16
  import { resolve } from "node:path";
17
17
  import { argv, exit, cwd } from "node:process";
18
+ import { existsSync, readFileSync } from "node:fs";
19
+
20
+ import { configureUI } from "../src/lib/ui.js";
18
21
 
19
22
  // ── Arg parsing (zero-dep) ─────────────────────────────────────────────────
20
23
  const args = argv.slice(2);
@@ -31,6 +34,8 @@ const flags = Object.fromEntries(
31
34
 
32
35
  const TARGET_DIR = resolve(flags.dir || cwd());
33
36
 
37
+ configureUI(loadUIOptions(TARGET_DIR, flags));
38
+
34
39
  // ── Command router ─────────────────────────────────────────────────────────
35
40
  async function main() {
36
41
  switch (command) {
@@ -183,6 +188,9 @@ async function showConfig(targetDir) {
183
188
  kv("Tasks path", config.tasksPath || "PRD.md");
184
189
  kv("Docs path", config.docsPath || "Docs");
185
190
  kv("Output path", config.outputPath || "./");
191
+ kv("UI theme", config.ui?.theme || "rich");
192
+ kv("UI compact", config.ui?.compact || "auto");
193
+ kv("UI animations", String(config.ui?.animations ?? true));
186
194
 
187
195
  if (config.models) {
188
196
  section("Model Assignments");
@@ -277,6 +285,9 @@ async function printHelp() {
277
285
  console.log(cmd("--dry-run", "Assemble context without launching Ralphy"));
278
286
  console.log(cmd("--models", "Reconfigure model assignments (with setup)"));
279
287
  console.log(cmd("--verbose", "Show extra detail (doctor, run)"));
288
+ console.log(cmd("--ui-theme=<rich|minimal>", "UI theme style"));
289
+ console.log(cmd("--ui-compact=<auto|on|off>", "Compact layout behavior"));
290
+ console.log(cmd("--ui-animations=<true|false>", "Enable animated UI cues"));
280
291
  console.log(cmd("--version, -v", "Show version"));
281
292
  console.log();
282
293
 
@@ -316,6 +327,49 @@ async function loadPkg() {
316
327
  }
317
328
  }
318
329
 
330
+ function loadUIOptions(targetDir, cliFlags) {
331
+ const configUi = readProjectUiConfig(targetDir);
332
+ const flagUi = parseUiFlags(cliFlags);
333
+ return { ...configUi, ...flagUi };
334
+ }
335
+
336
+ function readProjectUiConfig(targetDir) {
337
+ try {
338
+ const configPath = resolve(targetDir, ".opencode", "config.json");
339
+ if (!existsSync(configPath)) return {};
340
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
341
+ if (!parsed?.ui || typeof parsed.ui !== "object") return {};
342
+ return {
343
+ theme: parsed.ui.theme,
344
+ compact: parsed.ui.compact,
345
+ animations: parsed.ui.animations,
346
+ };
347
+ } catch {
348
+ return {};
349
+ }
350
+ }
351
+
352
+ function parseUiFlags(cliFlags) {
353
+ const out = {};
354
+
355
+ if (cliFlags["ui-theme"]) out.theme = String(cliFlags["ui-theme"]);
356
+ if (cliFlags["ui-compact"]) out.compact = String(cliFlags["ui-compact"]);
357
+ if (cliFlags["ui-animations"] !== undefined) {
358
+ const val = parseBoolFlag(cliFlags["ui-animations"]);
359
+ if (val !== null) out.animations = val;
360
+ }
361
+
362
+ return out;
363
+ }
364
+
365
+ function parseBoolFlag(value) {
366
+ if (typeof value === "boolean") return value;
367
+ const v = String(value).trim().toLowerCase();
368
+ if (["1", "true", "yes", "y", "on"].includes(v)) return true;
369
+ if (["0", "false", "no", "n", "off"].includes(v)) return false;
370
+ return null;
371
+ }
372
+
319
373
  // ── Run ────────────────────────────────────────────────────────────────────
320
374
  main().catch((err) => {
321
375
  import("../src/lib/ui.js").then(({ error: uiError }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-auto-agent",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "Scaffold and run an AI agent team powered by Ralphy + OpenCode. Presets for Java, Spring Boot, Next.js, and more.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -146,6 +146,13 @@ export async function doctor(targetDir, { verbose = false } = {}) {
146
146
  } else {
147
147
  softWarn("Docs directory", `${config.docsPath || "Docs"} not found (optional)`);
148
148
  }
149
+
150
+ if (config.ui && typeof config.ui === "object") {
151
+ const theme = config.ui.theme || "rich";
152
+ const compact = config.ui.compact || "auto";
153
+ const animations = config.ui.animations ?? true;
154
+ pass("UI config", `theme=${theme}, compact=${compact}, animations=${String(animations)}`);
155
+ }
149
156
  }
150
157
 
151
158
  // ── 4. Model validation against live discovery ────────────────────────────
@@ -272,6 +272,11 @@ function buildProjectConfig(preset, modelMapping) {
272
272
  workflowSteps: WORKFLOW_STEPS,
273
273
  requirePlanApproval: false,
274
274
  },
275
+ ui: {
276
+ theme: "rich",
277
+ compact: "auto",
278
+ animations: true,
279
+ },
275
280
  };
276
281
  }
277
282
 
@@ -196,6 +196,11 @@ export async function setup(targetDir, preset, { models: reconfigModels, nonInte
196
196
  config.preset = chosenPreset;
197
197
  config.models = modelMapping;
198
198
  config.version = SCHEMA_VERSION;
199
+ config.ui = config.ui || {
200
+ theme: "rich",
201
+ compact: "auto",
202
+ animations: true,
203
+ };
199
204
 
200
205
  // Update agents section
201
206
  if (!config.agents) config.agents = {};
package/src/lib/ui.js CHANGED
@@ -54,6 +54,22 @@ const BOX = {
54
54
  tee_r: "\u251c", tee_l: "\u2524",
55
55
  };
56
56
 
57
+ const UI_DEFAULTS = {
58
+ theme: "rich",
59
+ compact: "auto",
60
+ animations: true,
61
+ };
62
+
63
+ const uiState = { ...UI_DEFAULTS };
64
+ let sectionCounter = 0;
65
+
66
+ export function configureUI(options = {}) {
67
+ const next = { ...uiState, ...options };
68
+ uiState.theme = next.theme === "minimal" ? "minimal" : "rich";
69
+ uiState.compact = ["on", "off", "auto"].includes(next.compact) ? next.compact : "auto";
70
+ uiState.animations = Boolean(next.animations);
71
+ }
72
+
57
73
  // ── Layout helpers ─────────────────────────────────────────────────────────
58
74
 
59
75
  /**
@@ -75,19 +91,24 @@ export function hr(char = BOX.h, width) {
75
91
  * Print a styled header banner (OpenCode-style).
76
92
  */
77
93
  export function banner(title, subtitle) {
78
- const w = Math.min(termWidth(), 72);
79
- const pad = (s, len) => {
80
- const stripped = stripAnsi(s);
81
- return s + " ".repeat(Math.max(0, len - stripped.length));
82
- };
94
+ const indent = uiIndent();
95
+ if (uiState.theme === "minimal") {
96
+ console.log();
97
+ console.log(`${indent}${c.bold(title)}`);
98
+ if (subtitle) console.log(`${indent}${c.dim(subtitle)}`);
99
+ console.log();
100
+ return;
101
+ }
102
+
103
+ const innerWidth = Math.max(32, Math.min(termWidth() - 8, 72));
83
104
 
84
105
  console.log();
85
- console.log(c.cyan(` ${BOX.tl}${BOX.h.repeat(w - 4)}${BOX.tr}`));
86
- console.log(c.cyan(` ${BOX.v}`) + " " + c.bold(c.brightCyan(pad(title, w - 6))) + c.cyan(BOX.v));
106
+ console.log(c.cyan(`${indent}${BOX.tl}${BOX.h.repeat(innerWidth + 2)}${BOX.tr}`));
107
+ console.log(c.cyan(`${indent}${BOX.v}`) + ` ${c.bold(c.brightCyan(centerAnsi(title, innerWidth)))} ` + c.cyan(BOX.v));
87
108
  if (subtitle) {
88
- console.log(c.cyan(` ${BOX.v}`) + " " + c.gray(pad(subtitle, w - 6)) + c.cyan(BOX.v));
109
+ console.log(c.cyan(`${indent}${BOX.v}`) + ` ${c.gray(centerAnsi(subtitle, innerWidth))} ` + c.cyan(BOX.v));
89
110
  }
90
- console.log(c.cyan(` ${BOX.bl}${BOX.h.repeat(w - 4)}${BOX.br}`));
111
+ console.log(c.cyan(`${indent}${BOX.bl}${BOX.h.repeat(innerWidth + 2)}${BOX.br}`));
91
112
  console.log();
92
113
  }
93
114
 
@@ -95,9 +116,22 @@ export function banner(title, subtitle) {
95
116
  * Print a section header.
96
117
  */
97
118
  export function section(title) {
119
+ const indent = uiIndent();
120
+ if (uiState.theme === "minimal") {
121
+ console.log();
122
+ console.log(`${indent}${c.bold(title)}`);
123
+ console.log(`${indent}${"-".repeat(Math.max(4, stripAnsi(title).length))}`);
124
+ return;
125
+ }
126
+
127
+ const frame = ["◐", "◓", "◑", "◒"][sectionCounter++ % 4];
98
128
  console.log();
99
- console.log(` ${c.bold(c.cyan(title))}`);
100
- console.log(` ${c.gray(BOX.h.repeat(stripAnsi(title).length + 2))}`);
129
+ if (supportsAnimatedUi()) {
130
+ stdout.write(`\x1b[2K${indent}${c.gray(frame)} ${c.gray(title)}\r`);
131
+ }
132
+ const heading = `${c.brightCyan(frame)} ${c.bold(c.cyan(title))}`;
133
+ console.log(`${indent}${heading}`);
134
+ console.log(`${indent}${c.gray(BOX.h.repeat(stripAnsi(title).length + 2))}`);
101
135
  }
102
136
 
103
137
  /**
@@ -114,25 +148,29 @@ export function status(icon, label, detail) {
114
148
  star: c.yellow("\u2605"),
115
149
  };
116
150
  const i = icons[icon] || icon;
151
+ const indent = uiIndent();
152
+ const maxLabel = Math.max(12, Math.min(isCompactMode() ? 36 : 46, termWidth() - (isCompactMode() ? 18 : 26)));
153
+ const labelText = padAnsiEnd(truncateAnsi(label, maxLabel), maxLabel);
117
154
  const d = detail ? c.gray(` ${detail}`) : "";
118
- console.log(` ${i} ${label}${d}`);
155
+ console.log(`${indent}${i} ${labelText}${d}`);
119
156
  }
120
157
 
121
158
  /**
122
159
  * Print a key-value pair.
123
160
  */
124
161
  export function kv(key, value) {
125
- console.log(` ${c.gray(key + ":")} ${c.white(String(value))}`);
162
+ console.log(`${uiIndent()}${c.gray(key + ":")} ${c.white(String(value))}`);
126
163
  }
127
164
 
128
165
  /**
129
166
  * Print an error block.
130
167
  */
131
168
  export function error(message, hint) {
169
+ const indent = uiIndent();
132
170
  console.log();
133
- console.log(` ${c.bgRed(" ERROR ")} ${c.red(message)}`);
171
+ console.log(`${indent}${c.bgRed(" ERROR ")} ${c.red(message)}`);
134
172
  if (hint) {
135
- console.log(` ${c.gray(hint)}`);
173
+ console.log(`${indent}${" ".repeat(8)}${c.gray(hint)}`);
136
174
  }
137
175
  console.log();
138
176
  }
@@ -141,9 +179,10 @@ export function error(message, hint) {
141
179
  * Print a warning block.
142
180
  */
143
181
  export function warn(message, hint) {
144
- console.log(` ${c.bgYellow(" WARN ")} ${c.yellow(message)}`);
182
+ const indent = uiIndent();
183
+ console.log(`${indent}${c.bgYellow(" WARN ")} ${c.yellow(message)}`);
145
184
  if (hint) {
146
- console.log(` ${c.gray(hint)}`);
185
+ console.log(`${indent}${" ".repeat(7)}${c.gray(hint)}`);
147
186
  }
148
187
  }
149
188
 
@@ -151,8 +190,9 @@ export function warn(message, hint) {
151
190
  * Print a success block.
152
191
  */
153
192
  export function success(message) {
193
+ const indent = uiIndent();
154
194
  console.log();
155
- console.log(` ${c.bgGreen(" OK ")} ${c.green(message)}`);
195
+ console.log(`${indent}${c.bgGreen(" OK ")} ${c.green(message)}`);
156
196
  console.log();
157
197
  }
158
198
 
@@ -160,14 +200,26 @@ export function success(message) {
160
200
  * Print a boxed list of items.
161
201
  */
162
202
  export function boxList(title, items) {
163
- const w = Math.min(termWidth(), 68);
203
+ const indent = uiIndent();
204
+ if (uiState.theme === "minimal") {
205
+ console.log();
206
+ console.log(`${indent}${c.bold(title)}`);
207
+ for (const item of items) {
208
+ console.log(`${indent}- ${truncateAnsi(item, Math.max(20, termWidth() - indent.length - 4))}`);
209
+ }
210
+ return;
211
+ }
212
+
213
+ const innerWidth = Math.max(26, Math.min(termWidth() - 8, 68));
164
214
  console.log();
165
- console.log(c.gray(` ${BOX.tl}${BOX.h} ${c.bold(c.white(title))} ${c.gray(BOX.h.repeat(Math.max(0, w - stripAnsi(title).length - 6)))}${c.gray(BOX.tr)}`));
215
+ console.log(c.gray(`${indent}${BOX.tl}${BOX.h.repeat(innerWidth + 2)}${BOX.tr}`));
216
+ console.log(c.gray(`${indent}${BOX.v}`) + ` ${c.bold(c.white(centerAnsi(title, innerWidth)))} ` + c.gray(BOX.v));
217
+ console.log(c.gray(`${indent}${BOX.tee_r}${BOX.h.repeat(innerWidth + 2)}${BOX.tee_l}`));
166
218
  for (const item of items) {
167
- const line = ` ${c.gray(BOX.v)} ${item}`;
168
- console.log(line + " ".repeat(Math.max(0, w - stripAnsi(line).length + 2)) + c.gray(BOX.v));
219
+ const line = padAnsiEnd(truncateAnsi(item, innerWidth), innerWidth);
220
+ console.log(c.gray(`${indent}${BOX.v}`) + ` ${line} ` + c.gray(BOX.v));
169
221
  }
170
- console.log(c.gray(` ${BOX.bl}${BOX.h.repeat(w - 2)}${BOX.br}`));
222
+ console.log(c.gray(`${indent}${BOX.bl}${BOX.h.repeat(innerWidth + 2)}${BOX.br}`));
171
223
  }
172
224
 
173
225
  // ── Spinner ────────────────────────────────────────────────────────────────
@@ -177,22 +229,27 @@ const SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u283
177
229
  export function createSpinner(text) {
178
230
  let i = 0;
179
231
  let interval = null;
232
+ const colors = uiState.theme === "minimal"
233
+ ? [c.gray]
234
+ : [c.cyan, c.brightCyan, c.blue, c.magenta];
180
235
 
181
236
  return {
182
237
  start() {
183
238
  interval = setInterval(() => {
184
- const frame = c.cyan(SPINNER_FRAMES[i % SPINNER_FRAMES.length]);
185
- stdout.write(`\r ${frame} ${text}`);
239
+ const colorFn = colors[i % colors.length];
240
+ const frame = colorFn(SPINNER_FRAMES[i % SPINNER_FRAMES.length]);
241
+ const trail = supportsAnimatedUi() && uiState.theme !== "minimal" ? c.gray(" •") : "";
242
+ stdout.write(`\r${uiIndent()}${frame} ${text}${trail}`);
186
243
  i++;
187
244
  }, 80);
188
245
  },
189
246
  stop(finalText) {
190
247
  if (interval) clearInterval(interval);
191
- stdout.write(`\r ${c.green("\u2713")} ${finalText || text}\n`);
248
+ stdout.write(`\r${uiIndent()}${c.green("\u2713")} ${finalText || text}\n`);
192
249
  },
193
250
  fail(finalText) {
194
251
  if (interval) clearInterval(interval);
195
- stdout.write(`\r ${c.red("\u2717")} ${finalText || text}\n`);
252
+ stdout.write(`\r${uiIndent()}${c.red("\u2717")} ${finalText || text}\n`);
196
253
  },
197
254
  };
198
255
  }
@@ -206,7 +263,7 @@ export async function confirm(question, defaultYes = true) {
206
263
  const rl = createInterface({ input: stdin, output: stdout });
207
264
  const hint = defaultYes ? "Y/n" : "y/N";
208
265
  return new Promise((resolve) => {
209
- rl.question(` ${c.cyan("?")} ${question} ${c.gray(`(${hint})`)} `, (answer) => {
266
+ rl.question(`${uiIndent()}${c.cyan("?")} ${question} ${c.gray(`(${hint})`)} `, (answer) => {
210
267
  rl.close();
211
268
  const a = answer.trim().toLowerCase();
212
269
  if (a === "") resolve(defaultYes);
@@ -222,7 +279,7 @@ export async function input(question, defaultValue = "") {
222
279
  const rl = createInterface({ input: stdin, output: stdout });
223
280
  const hint = defaultValue ? c.gray(` (${defaultValue})`) : "";
224
281
  return new Promise((resolve) => {
225
- rl.question(` ${c.cyan("?")} ${question}${hint} `, (answer) => {
282
+ rl.question(`${uiIndent()}${c.cyan("?")} ${question}${hint} `, (answer) => {
226
283
  rl.close();
227
284
  resolve(answer.trim() || defaultValue);
228
285
  });
@@ -258,7 +315,7 @@ export async function select(title, items) {
258
315
  stdout.write(`\x1b[${totalLines}A`);
259
316
  }
260
317
 
261
- stdout.write(`\x1b[2K ${c.cyan("?")} ${c.bold(title)}\n`);
318
+ stdout.write(`\x1b[2K${uiIndent()}${c.cyan("?")} ${c.bold(title)}\n`);
262
319
  for (let i = 0; i < visible.length; i++) {
263
320
  const itemIndex = start + i;
264
321
  const item = visible[i];
@@ -266,13 +323,13 @@ export async function select(title, items) {
266
323
  const pointer = isSelected ? c.cyan("\u25B6") : " ";
267
324
  const label = isSelected ? c.cyan(item.label) : c.white(item.label);
268
325
  const hint = item.hint ? c.gray(` ${item.hint}`) : "";
269
- stdout.write(`\x1b[2K ${pointer} ${label}${hint}\n`);
326
+ stdout.write(`\x1b[2K${uiIndent()}${pointer} ${label}${hint}\n`);
270
327
  }
271
328
 
272
329
  const range = `${start + 1}-${end}`;
273
330
  const moreAbove = start > 0 ? c.gray("↑") : c.gray(" ");
274
331
  const moreBelow = end < items.length ? c.gray("↓") : c.gray(" ");
275
- stdout.write(`\x1b[2K ${c.gray(`[${range}/${items.length}] ${moreAbove}${moreBelow} arrows/jk + enter`)}` + "\n");
332
+ stdout.write(`\x1b[2K${uiIndent()}${c.gray(`[${range}/${items.length}] ${moreAbove}${moreBelow} arrows/jk + enter`)}` + "\n");
276
333
  hasRendered = true;
277
334
  };
278
335
 
@@ -300,7 +357,7 @@ export async function select(title, items) {
300
357
  stdout.write("\x1b[2K\n");
301
358
  }
302
359
  stdout.write(`\x1b[${totalLines}A`);
303
- stdout.write(`\x1b[2K ${c.green("\u2713")} ${c.bold(title)} ${c.cyan(items[cursor].label)}\n`);
360
+ stdout.write(`\x1b[2K${uiIndent()}${c.green("\u2713")} ${c.bold(title)} ${c.cyan(items[cursor].label)}\n`);
304
361
  resolve(items[cursor]);
305
362
  return;
306
363
  }
@@ -350,7 +407,7 @@ export async function multiSelect(title, items) {
350
407
  stdout.write(`\x1b[${totalLines}A`);
351
408
  }
352
409
 
353
- stdout.write(`\x1b[2K ${c.cyan("?")} ${c.bold(title)} ${c.gray("(space to toggle)")}\n`);
410
+ stdout.write(`\x1b[2K${uiIndent()}${c.cyan("?")} ${c.bold(title)} ${c.gray("(space to toggle)")}\n`);
354
411
  for (let i = 0; i < visible.length; i++) {
355
412
  const itemIndex = start + i;
356
413
  const item = visible[i];
@@ -360,13 +417,13 @@ export async function multiSelect(title, items) {
360
417
  const check = isSelected ? c.green("\u25C9") : c.gray("\u25CB");
361
418
  const label = isCursor ? c.cyan(item.label) : c.white(item.label);
362
419
  const hint = item.hint ? c.gray(` ${item.hint}`) : "";
363
- stdout.write(`\x1b[2K ${pointer} ${check} ${label}${hint}\n`);
420
+ stdout.write(`\x1b[2K${uiIndent()}${pointer} ${check} ${label}${hint}\n`);
364
421
  }
365
422
 
366
423
  const range = `${start + 1}-${end}`;
367
424
  const moreAbove = start > 0 ? c.gray("↑") : c.gray(" ");
368
425
  const moreBelow = end < items.length ? c.gray("↓") : c.gray(" ");
369
- stdout.write(`\x1b[2K ${c.gray(`[${range}/${items.length}] ${moreAbove}${moreBelow} arrows/jk + space + enter`)}` + "\n");
426
+ stdout.write(`\x1b[2K${uiIndent()}${c.gray(`[${range}/${items.length}] ${moreAbove}${moreBelow} arrows/jk + space + enter`)}` + "\n");
370
427
  hasRendered = true;
371
428
  };
372
429
 
@@ -394,7 +451,7 @@ export async function multiSelect(title, items) {
394
451
  stdout.write("\x1b[2K\n");
395
452
  }
396
453
  stdout.write(`\x1b[${totalLines}A`);
397
- stdout.write(`\x1b[2K ${c.green("\u2713")} ${c.bold(title)} ${c.cyan(result.map((r) => r.label).join(", "))}\n`);
454
+ stdout.write(`\x1b[2K${uiIndent()}${c.green("\u2713")} ${c.bold(title)} ${c.cyan(result.map((r) => r.label).join(", "))}\n`);
398
455
  resolve(result);
399
456
  return;
400
457
  }
@@ -423,18 +480,27 @@ export async function multiSelect(title, items) {
423
480
  * @param {string[][]} rows
424
481
  */
425
482
  export function table(headers, rows) {
483
+ const indent = uiIndent();
484
+ const compact = isCompactMode();
426
485
  const colWidths = headers.map((h, i) => {
427
486
  const maxData = rows.reduce((max, row) => Math.max(max, stripAnsi(String(row[i] || "")).length), 0);
428
- return Math.max(stripAnsi(h).length, maxData) + 2;
487
+ return Math.max(stripAnsi(h).length, maxData) + (compact ? 1 : 2);
429
488
  });
430
489
 
431
- const headerLine = headers.map((h, i) => c.bold(h.padEnd(colWidths[i]))).join(c.gray(" \u2502 "));
432
- console.log(` ${headerLine}`);
433
- console.log(` ${colWidths.map((w) => c.gray(BOX.h.repeat(w))).join(c.gray("\u2500\u253c\u2500"))}`);
490
+ fitColumnsToViewport(colWidths, headers, rows, compact);
491
+
492
+ const colSep = compact ? c.gray("\u2502") : c.gray(" \u2502 ");
493
+ const ruleSep = compact ? c.gray("\u253c") : c.gray("\u2500\u253c\u2500");
494
+
495
+ const headerLine = headers.map((h, i) => c.bold(padAnsiEnd(truncateAnsi(h, colWidths[i]), colWidths[i]))).join(colSep);
496
+ console.log(`${indent}${headerLine}`);
497
+ console.log(`${indent}${colWidths.map((w) => c.gray(BOX.h.repeat(w))).join(ruleSep)}`);
434
498
 
435
499
  for (const row of rows) {
436
- const line = row.map((cell, i) => String(cell || "").padEnd(colWidths[i])).join(c.gray(" \u2502 "));
437
- console.log(` ${line}`);
500
+ const line = row
501
+ .map((cell, i) => padAnsiEnd(truncateAnsi(cell, colWidths[i]), colWidths[i]))
502
+ .join(colSep);
503
+ console.log(`${indent}${line}`);
438
504
  }
439
505
  }
440
506
 
@@ -448,6 +514,76 @@ export function stripAnsi(str) {
448
514
  return str.replace(/\x1b\[[0-9;]*m/g, "");
449
515
  }
450
516
 
517
+ function centerAnsi(value, width) {
518
+ const text = String(value || "");
519
+ const visible = stripAnsi(text).length;
520
+ if (visible >= width) return text;
521
+ const left = Math.floor((width - visible) / 2);
522
+ const right = width - visible - left;
523
+ return " ".repeat(left) + text + " ".repeat(right);
524
+ }
525
+
526
+ function truncateAnsi(value, maxLen = 40) {
527
+ const text = String(value || "");
528
+ const plain = stripAnsi(text);
529
+ if (plain.length <= maxLen) return text;
530
+ return `${plain.slice(0, maxLen - 1)}…`;
531
+ }
532
+
533
+ function padAnsiEnd(value, width) {
534
+ const text = String(value || "");
535
+ const visible = stripAnsi(text).length;
536
+ if (visible >= width) return text;
537
+ return text + " ".repeat(width - visible);
538
+ }
539
+
540
+ function fitColumnsToViewport(colWidths, headers, rows, compact) {
541
+ const sepWidth = compact ? 1 : 3;
542
+ const indentWidth = uiIndent().length;
543
+ const minCol = compact ? 6 : 8;
544
+ const maxLine = Math.max(32, termWidth() - indentWidth - 1);
545
+
546
+ const total = () => colWidths.reduce((sum, w) => sum + w, 0) + sepWidth * Math.max(0, colWidths.length - 1);
547
+ if (total() <= maxLine) return;
548
+
549
+ const priorities = headers.map((_, i) => i).sort((a, b) => {
550
+ const wa = colWidths[a];
551
+ const wb = colWidths[b];
552
+ return wb - wa;
553
+ });
554
+
555
+ while (total() > maxLine) {
556
+ let changed = false;
557
+ for (const idx of priorities) {
558
+ if (colWidths[idx] > minCol) {
559
+ colWidths[idx]--;
560
+ changed = true;
561
+ }
562
+ if (total() <= maxLine) break;
563
+ }
564
+ if (!changed) break;
565
+ }
566
+
567
+ if (total() > maxLine && rows.length > 0) {
568
+ const last = colWidths.length - 1;
569
+ colWidths[last] = Math.max(minCol, maxLine - (total() - colWidths[last]));
570
+ }
571
+ }
572
+
573
+ function isCompactMode() {
574
+ if (uiState.compact === "on") return true;
575
+ if (uiState.compact === "off") return false;
576
+ return termWidth() < 96;
577
+ }
578
+
579
+ function uiIndent() {
580
+ return isCompactMode() ? " " : " ";
581
+ }
582
+
583
+ function supportsAnimatedUi() {
584
+ return Boolean(uiState.animations && stdin.isTTY && stdout.isTTY);
585
+ }
586
+
451
587
  /**
452
588
  * Truncate a string to maxLen, adding ellipsis if needed.
453
589
  */
@@ -510,30 +646,30 @@ function supportsInteractiveMenu() {
510
646
  }
511
647
 
512
648
  async function selectFallback(title, items) {
513
- console.log(` ${c.cyan("?")} ${c.bold(title)}`);
649
+ console.log(`${uiIndent()}${c.cyan("?")} ${c.bold(title)}`);
514
650
  for (let i = 0; i < items.length; i++) {
515
651
  const item = items[i];
516
652
  const hint = item.hint ? ` ${c.gray(item.hint)}` : "";
517
- console.log(` ${String(i + 1).padStart(2, " ")}) ${item.label}${hint}`);
653
+ console.log(`${uiIndent()} ${String(i + 1).padStart(2, " ")}) ${item.label}${hint}`);
518
654
  }
519
655
  const answer = await input("Choose an option number", "1");
520
656
  const idx = clampIndex(parseInt(answer, 10) - 1, items.length);
521
- console.log(` ${c.green("\u2713")} ${c.bold(title)} ${c.cyan(items[idx].label)}`);
657
+ console.log(`${uiIndent()}${c.green("\u2713")} ${c.bold(title)} ${c.cyan(items[idx].label)}`);
522
658
  return items[idx];
523
659
  }
524
660
 
525
661
  async function multiSelectFallback(title, items) {
526
- console.log(` ${c.cyan("?")} ${c.bold(title)}`);
662
+ console.log(`${uiIndent()}${c.cyan("?")} ${c.bold(title)}`);
527
663
  for (let i = 0; i < items.length; i++) {
528
664
  const item = items[i];
529
665
  const mark = item.selected ? "*" : " ";
530
666
  const hint = item.hint ? ` ${c.gray(item.hint)}` : "";
531
- console.log(` ${String(i + 1).padStart(2, " ")}) [${mark}] ${item.label}${hint}`);
667
+ console.log(`${uiIndent()} ${String(i + 1).padStart(2, " ")}) [${mark}] ${item.label}${hint}`);
532
668
  }
533
669
  const answer = await input("Choose option numbers (comma-separated)", "1");
534
670
  const indexes = parseMultiNumberInput(answer, items.length);
535
671
  const selected = indexes.map((i) => items[i]);
536
- console.log(` ${c.green("\u2713")} ${c.bold(title)} ${c.cyan(selected.map((it) => it.label).join(", "))}`);
672
+ console.log(`${uiIndent()}${c.green("\u2713")} ${c.bold(title)} ${c.cyan(selected.map((it) => it.label).join(", "))}`);
537
673
  return selected;
538
674
  }
539
675
 
@@ -1,7 +1,7 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
 
4
- import { buildSelectedIndexSet, getVisibleWindow, getMaxVisibleMenuItems } from "./ui.js";
4
+ import { buildSelectedIndexSet, getVisibleWindow, getMaxVisibleMenuItems, table, c, stripAnsi } from "./ui.js";
5
5
 
6
6
  test("buildSelectedIndexSet keeps original indexes", () => {
7
7
  const items = [
@@ -39,3 +39,35 @@ test("getMaxVisibleMenuItems enforces safe min/max", () => {
39
39
  assert.equal(getMaxVisibleMenuItems(12), 6);
40
40
  assert.equal(getMaxVisibleMenuItems(40), 20);
41
41
  });
42
+
43
+ test("table aligns ANSI-styled cells", () => {
44
+ const output = [];
45
+ const originalLog = console.log;
46
+ console.log = (line) => output.push(stripAnsi(String(line)));
47
+
48
+ try {
49
+ table(
50
+ ["Agent Role", "Model", "Mode"],
51
+ [
52
+ [c.bold("orchestrator"), c.cyan("google/gemini-3-pro"), c.gray("primary")],
53
+ [c.bold("qa"), c.cyan("openai/gpt-5.3-codex"), c.gray("subagent")],
54
+ ]
55
+ );
56
+ } finally {
57
+ console.log = originalLog;
58
+ }
59
+
60
+ assert.ok(output.length >= 4);
61
+ const firstRow = output[2];
62
+ const secondRow = output[3];
63
+
64
+ const firstSeps = [...firstRow.matchAll(/│/g)].map((m) => m.index);
65
+ const secondSeps = [...secondRow.matchAll(/│/g)].map((m) => m.index);
66
+
67
+ assert.equal(firstSeps.length, 2);
68
+ assert.equal(secondSeps.length, 2);
69
+ assert.deepEqual(firstSeps, secondSeps);
70
+ assert.ok(firstRow.includes("orchestrator"));
71
+ assert.ok(firstRow.includes("google/gemini-3-pro"));
72
+ assert.ok(secondRow.includes("openai/gpt-5.3-codex"));
73
+ });