opencode-auto-agent 1.3.0 → 1.3.1

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.1",
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
 
@@ -450,6 +469,42 @@ export function buildSelectedIndexSet(items) {
450
469
  return indexes;
451
470
  }
452
471
 
472
+ /**
473
+ * Calculate the visible start/end indexes for long menu lists.
474
+ * @param {number} total
475
+ * @param {number} cursor
476
+ * @param {number} maxVisible
477
+ * @returns {{start:number,end:number}}
478
+ */
479
+ export function getVisibleWindow(total, cursor, maxVisible) {
480
+ if (total <= 0) return { start: 0, end: 0 };
481
+ if (maxVisible >= total) return { start: 0, end: total };
482
+
483
+ const half = Math.floor(maxVisible / 2);
484
+ let start = cursor - half;
485
+ let end = start + maxVisible;
486
+
487
+ if (start < 0) {
488
+ start = 0;
489
+ end = maxVisible;
490
+ }
491
+ if (end > total) {
492
+ end = total;
493
+ start = total - maxVisible;
494
+ }
495
+
496
+ return { start, end };
497
+ }
498
+
499
+ /**
500
+ * Compute the max menu rows based on terminal height.
501
+ * Leaves room for prompt output and avoids terminal overflow.
502
+ */
503
+ export function getMaxVisibleMenuItems(termRows = stdout.rows || 24) {
504
+ const safe = Math.max(6, termRows - 8);
505
+ return Math.min(20, safe);
506
+ }
507
+
453
508
  function supportsInteractiveMenu() {
454
509
  return Boolean(stdin.isTTY && stdout.isTTY && typeof stdin.setRawMode === "function");
455
510
  }
@@ -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 } from "./ui.js";
5
5
 
6
6
  test("buildSelectedIndexSet keeps original indexes", () => {
7
7
  const items = [
@@ -24,3 +24,18 @@ 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
+ });