opencode-auto-agent 1.3.0 → 1.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-auto-agent",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
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",
@@ -112,26 +112,22 @@ export async function init(targetDir, { preset, force, nonInteractive } = {}) {
112
112
  continue;
113
113
  }
114
114
 
115
- // Build selection list: default first, then all others
116
- const items = [];
117
- if (defaultModel) {
118
- items.push({
119
- label: defaultModel.id,
120
- value: defaultModel.id,
121
- hint: "(recommended)",
122
- });
123
- }
124
- for (const m of models) {
125
- if (m.id !== defaultModel?.id) {
126
- items.push({ label: m.id, value: m.id });
127
- }
128
- }
115
+ const recommendedProvider = defaultModel?.provider || providerFromId(defaultId);
116
+ const providerItems = buildProviderItems(models, recommendedProvider);
117
+
118
+ const providerChoice = await select(
119
+ `Provider for ${c.bold(role)} ${c.gray(`\u2014 ${roleDef.description}`)}`,
120
+ providerItems
121
+ );
129
122
 
130
- const result = await select(
131
- `Model for ${c.bold(role)} ${c.gray(`\u2014 ${roleDef.description}`)}`,
132
- items
123
+ const providerModels = models.filter((m) => m.provider === providerChoice.value);
124
+ const modelItems = buildModelItemsForProvider(providerModels, defaultModel?.id);
125
+ const modelChoice = await select(
126
+ `Model for ${c.bold(role)} ${c.gray(`(${providerChoice.value})`)}`,
127
+ modelItems
133
128
  );
134
- modelMapping[role] = result.value;
129
+
130
+ modelMapping[role] = modelChoice.value;
135
131
  }
136
132
 
137
133
  // Show summary table
@@ -399,6 +395,43 @@ notifications:
399
395
 
400
396
  // ── Helpers ────────────────────────────────────────────────────────────────
401
397
 
398
+ function providerFromId(modelId) {
399
+ return modelId.includes("/") ? modelId.split("/")[0] : modelId;
400
+ }
401
+
402
+ function buildProviderItems(models, preferredProvider) {
403
+ const counts = new Map();
404
+ for (const m of models) {
405
+ counts.set(m.provider, (counts.get(m.provider) || 0) + 1);
406
+ }
407
+
408
+ const providers = [...counts.keys()].sort((a, b) => {
409
+ if (a === preferredProvider) return -1;
410
+ if (b === preferredProvider) return 1;
411
+ return a.localeCompare(b);
412
+ });
413
+
414
+ return providers.map((provider) => ({
415
+ label: provider,
416
+ value: provider,
417
+ hint: `${counts.get(provider)} models${provider === preferredProvider ? " (recommended)" : ""}`,
418
+ }));
419
+ }
420
+
421
+ function buildModelItemsForProvider(providerModels, preferredModelId) {
422
+ const sorted = [...providerModels].sort((a, b) => {
423
+ if (a.id === preferredModelId) return -1;
424
+ if (b.id === preferredModelId) return 1;
425
+ return a.model.localeCompare(b.model);
426
+ });
427
+
428
+ return sorted.map((m) => ({
429
+ label: m.model,
430
+ value: m.id,
431
+ hint: m.id === preferredModelId ? "(recommended)" : "",
432
+ }));
433
+ }
434
+
402
435
  function presetDescription(name) {
403
436
  const desc = {
404
437
  java: "General Java project (Maven/Gradle)",
@@ -131,35 +131,25 @@ export async function setup(targetDir, preset, { models: reconfigModels, nonInte
131
131
  for (const role of AGENTS) {
132
132
  const currentModel = modelMapping[role] || DEFAULT_MODEL_MAP[role];
133
133
  const currentFound = findModel(models, currentModel);
134
+ const defaultFound = findModel(models, DEFAULT_MODEL_MAP[role]);
135
+ const preferredProvider =
136
+ currentFound?.provider ||
137
+ defaultFound?.provider ||
138
+ providerFromId(currentModel);
139
+
140
+ const providerChoice = await select(
141
+ `Provider for ${c.bold(role)} ${c.gray(`\u2014 ${AGENT_ROLES[role].description}`)}`,
142
+ buildProviderItems(models, preferredProvider)
143
+ );
134
144
 
135
- // Build selection: current first, default second (if different), then rest
136
- const items = [];
137
- const seen = new Set();
138
-
139
- if (currentFound) {
140
- items.push({ label: currentFound.id, value: currentFound.id, hint: "(current)" });
141
- seen.add(currentFound.id);
142
- }
143
-
144
- const defaultId = DEFAULT_MODEL_MAP[role];
145
- const defaultFound = findModel(models, defaultId);
146
- if (defaultFound && !seen.has(defaultFound.id)) {
147
- items.push({ label: defaultFound.id, value: defaultFound.id, hint: "(recommended)" });
148
- seen.add(defaultFound.id);
149
- }
150
-
151
- for (const m of models) {
152
- if (!seen.has(m.id)) {
153
- items.push({ label: m.id, value: m.id });
154
- }
155
- }
156
-
157
- const result = await select(
158
- `Model for ${c.bold(role)} ${c.gray(`\u2014 ${AGENT_ROLES[role].description}`)}`,
159
- items
145
+ const providerModels = models.filter((m) => m.provider === providerChoice.value);
146
+ const modelChoice = await select(
147
+ `Model for ${c.bold(role)} ${c.gray(`(${providerChoice.value})`)}`,
148
+ buildModelItemsForProvider(providerModels, currentModel, defaultFound?.id)
160
149
  );
161
- if (result.value !== currentModel) modelsChanged = true;
162
- modelMapping[role] = result.value;
150
+
151
+ if (modelChoice.value !== currentModel) modelsChanged = true;
152
+ modelMapping[role] = modelChoice.value;
163
153
  }
164
154
  } else {
165
155
  warn("No models found. Keeping current model assignments.", "Configure opencode providers first.");
@@ -311,6 +301,51 @@ function buildAgentPrompt(role, preset) {
311
301
  return prompts[role] || `You are the ${role} agent.`;
312
302
  }
313
303
 
304
+ function providerFromId(modelId) {
305
+ return modelId.includes("/") ? modelId.split("/")[0] : modelId;
306
+ }
307
+
308
+ function buildProviderItems(models, preferredProvider) {
309
+ const counts = new Map();
310
+ for (const m of models) {
311
+ counts.set(m.provider, (counts.get(m.provider) || 0) + 1);
312
+ }
313
+
314
+ const providers = [...counts.keys()].sort((a, b) => {
315
+ if (a === preferredProvider) return -1;
316
+ if (b === preferredProvider) return 1;
317
+ return a.localeCompare(b);
318
+ });
319
+
320
+ return providers.map((provider) => ({
321
+ label: provider,
322
+ value: provider,
323
+ hint: `${counts.get(provider)} models${provider === preferredProvider ? " (recommended)" : ""}`,
324
+ }));
325
+ }
326
+
327
+ function buildModelItemsForProvider(providerModels, currentModelId, recommendedModelId) {
328
+ const sorted = [...providerModels].sort((a, b) => {
329
+ if (a.id === currentModelId) return -1;
330
+ if (b.id === currentModelId) return 1;
331
+ if (a.id === recommendedModelId) return -1;
332
+ if (b.id === recommendedModelId) return 1;
333
+ return a.model.localeCompare(b.model);
334
+ });
335
+
336
+ return sorted.map((m) => {
337
+ let hint = "";
338
+ if (m.id === currentModelId) hint = "(current)";
339
+ else if (m.id === recommendedModelId) hint = "(recommended)";
340
+
341
+ return {
342
+ label: m.model,
343
+ value: m.id,
344
+ hint,
345
+ };
346
+ });
347
+ }
348
+
314
349
  /**
315
350
  * Patch or add the `model:` field in markdown YAML frontmatter.
316
351
  */
package/src/lib/ui.js CHANGED
@@ -246,23 +246,33 @@ export async function select(title, items) {
246
246
 
247
247
  return new Promise((resolve) => {
248
248
  let cursor = 0;
249
- const totalLines = items.length + 1;
249
+ const maxVisible = getMaxVisibleMenuItems();
250
+ const totalLines = Math.min(items.length, maxVisible) + 2;
250
251
  let hasRendered = false;
251
252
 
252
253
  const render = () => {
254
+ const { start, end } = getVisibleWindow(items.length, cursor, maxVisible);
255
+ const visible = items.slice(start, end);
256
+
253
257
  if (hasRendered) {
254
258
  stdout.write(`\x1b[${totalLines}A`);
255
259
  }
256
260
 
257
261
  stdout.write(`\x1b[2K ${c.cyan("?")} ${c.bold(title)}\n`);
258
- for (let i = 0; i < items.length; i++) {
259
- const item = items[i];
260
- const isSelected = i === cursor;
262
+ for (let i = 0; i < visible.length; i++) {
263
+ const itemIndex = start + i;
264
+ const item = visible[i];
265
+ const isSelected = itemIndex === cursor;
261
266
  const pointer = isSelected ? c.cyan("\u25B6") : " ";
262
267
  const label = isSelected ? c.cyan(item.label) : c.white(item.label);
263
268
  const hint = item.hint ? c.gray(` ${item.hint}`) : "";
264
269
  stdout.write(`\x1b[2K ${pointer} ${label}${hint}\n`);
265
270
  }
271
+
272
+ const range = `${start + 1}-${end}`;
273
+ const moreAbove = start > 0 ? c.gray("↑") : c.gray(" ");
274
+ 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");
266
276
  hasRendered = true;
267
277
  };
268
278
 
@@ -328,26 +338,35 @@ export async function multiSelect(title, items) {
328
338
  return new Promise((resolve) => {
329
339
  let cursor = 0;
330
340
  const selected = buildSelectedIndexSet(items);
331
- const totalLines = items.length + 2;
341
+ const maxVisible = getMaxVisibleMenuItems();
342
+ const totalLines = Math.min(items.length, maxVisible) + 2;
332
343
  let hasRendered = false;
333
344
 
334
345
  const render = () => {
346
+ const { start, end } = getVisibleWindow(items.length, cursor, maxVisible);
347
+ const visible = items.slice(start, end);
348
+
335
349
  if (hasRendered) {
336
350
  stdout.write(`\x1b[${totalLines}A`);
337
351
  }
338
352
 
339
- stdout.write(`\x1b[2K ${c.cyan("?")} ${c.bold(title)} ${c.gray("(space to toggle, enter to confirm)")}\n`);
340
- for (let i = 0; i < items.length; i++) {
341
- const item = items[i];
342
- const isCursor = i === cursor;
343
- const isSelected = selected.has(i);
353
+ stdout.write(`\x1b[2K ${c.cyan("?")} ${c.bold(title)} ${c.gray("(space to toggle)")}\n`);
354
+ for (let i = 0; i < visible.length; i++) {
355
+ const itemIndex = start + i;
356
+ const item = visible[i];
357
+ const isCursor = itemIndex === cursor;
358
+ const isSelected = selected.has(itemIndex);
344
359
  const pointer = isCursor ? c.cyan("\u25B6") : " ";
345
360
  const check = isSelected ? c.green("\u25C9") : c.gray("\u25CB");
346
361
  const label = isCursor ? c.cyan(item.label) : c.white(item.label);
347
362
  const hint = item.hint ? c.gray(` ${item.hint}`) : "";
348
363
  stdout.write(`\x1b[2K ${pointer} ${check} ${label}${hint}\n`);
349
364
  }
350
- stdout.write("\x1b[2K\n");
365
+
366
+ const range = `${start + 1}-${end}`;
367
+ const moreAbove = start > 0 ? c.gray("↑") : c.gray(" ");
368
+ 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");
351
370
  hasRendered = true;
352
371
  };
353
372
 
@@ -409,12 +428,12 @@ export function table(headers, rows) {
409
428
  return Math.max(stripAnsi(h).length, maxData) + 2;
410
429
  });
411
430
 
412
- const headerLine = headers.map((h, i) => c.bold(h.padEnd(colWidths[i]))).join(c.gray(" \u2502 "));
431
+ const headerLine = headers.map((h, i) => c.bold(padAnsiEnd(h, colWidths[i]))).join(c.gray(" \u2502 "));
413
432
  console.log(` ${headerLine}`);
414
433
  console.log(` ${colWidths.map((w) => c.gray(BOX.h.repeat(w))).join(c.gray("\u2500\u253c\u2500"))}`);
415
434
 
416
435
  for (const row of rows) {
417
- const line = row.map((cell, i) => String(cell || "").padEnd(colWidths[i])).join(c.gray(" \u2502 "));
436
+ const line = row.map((cell, i) => padAnsiEnd(cell, colWidths[i])).join(c.gray(" \u2502 "));
418
437
  console.log(` ${line}`);
419
438
  }
420
439
  }
@@ -429,6 +448,13 @@ export function stripAnsi(str) {
429
448
  return str.replace(/\x1b\[[0-9;]*m/g, "");
430
449
  }
431
450
 
451
+ function padAnsiEnd(value, width) {
452
+ const text = String(value || "");
453
+ const visible = stripAnsi(text).length;
454
+ if (visible >= width) return text;
455
+ return text + " ".repeat(width - visible);
456
+ }
457
+
432
458
  /**
433
459
  * Truncate a string to maxLen, adding ellipsis if needed.
434
460
  */
@@ -450,6 +476,42 @@ export function buildSelectedIndexSet(items) {
450
476
  return indexes;
451
477
  }
452
478
 
479
+ /**
480
+ * Calculate the visible start/end indexes for long menu lists.
481
+ * @param {number} total
482
+ * @param {number} cursor
483
+ * @param {number} maxVisible
484
+ * @returns {{start:number,end:number}}
485
+ */
486
+ export function getVisibleWindow(total, cursor, maxVisible) {
487
+ if (total <= 0) return { start: 0, end: 0 };
488
+ if (maxVisible >= total) return { start: 0, end: total };
489
+
490
+ const half = Math.floor(maxVisible / 2);
491
+ let start = cursor - half;
492
+ let end = start + maxVisible;
493
+
494
+ if (start < 0) {
495
+ start = 0;
496
+ end = maxVisible;
497
+ }
498
+ if (end > total) {
499
+ end = total;
500
+ start = total - maxVisible;
501
+ }
502
+
503
+ return { start, end };
504
+ }
505
+
506
+ /**
507
+ * Compute the max menu rows based on terminal height.
508
+ * Leaves room for prompt output and avoids terminal overflow.
509
+ */
510
+ export function getMaxVisibleMenuItems(termRows = stdout.rows || 24) {
511
+ const safe = Math.max(6, termRows - 8);
512
+ return Math.min(20, safe);
513
+ }
514
+
453
515
  function supportsInteractiveMenu() {
454
516
  return Boolean(stdin.isTTY && stdout.isTTY && typeof stdin.setRawMode === "function");
455
517
  }
@@ -1,7 +1,7 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
 
4
- import { buildSelectedIndexSet } 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 = [
@@ -24,3 +24,50 @@ test("buildSelectedIndexSet handles empty selection", () => {
24
24
  const selected = buildSelectedIndexSet(items);
25
25
  assert.equal(selected.size, 0);
26
26
  });
27
+
28
+ test("getVisibleWindow keeps cursor centered when possible", () => {
29
+ const w = getVisibleWindow(100, 50, 10);
30
+ assert.deepEqual(w, { start: 45, end: 55 });
31
+ });
32
+
33
+ test("getVisibleWindow clamps to beginning and end", () => {
34
+ assert.deepEqual(getVisibleWindow(100, 2, 10), { start: 0, end: 10 });
35
+ assert.deepEqual(getVisibleWindow(100, 98, 10), { start: 90, end: 100 });
36
+ });
37
+
38
+ test("getMaxVisibleMenuItems enforces safe min/max", () => {
39
+ assert.equal(getMaxVisibleMenuItems(12), 6);
40
+ assert.equal(getMaxVisibleMenuItems(40), 20);
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
+ });