opencode-auto-agent 1.2.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.2.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",
@@ -15,8 +15,8 @@
15
15
  ],
16
16
  "scripts": {
17
17
  "dev": "node bin/cli.js",
18
- "test": "node --test src/**/*.test.js 2>/dev/null || true",
19
- "verify": "node bin/cli.js help"
18
+ "test": "node --test src/**/*.test.js",
19
+ "verify": "npm run test && node bin/cli.js help"
20
20
  },
21
21
  "keywords": [
22
22
  "ai",
@@ -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
  */
@@ -0,0 +1,42 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { parseModelsOutput, findModel, isValidModel } from "./models.js";
5
+
6
+ test("parseModelsOutput parses slash-style model lines", () => {
7
+ const raw = [
8
+ "openai/gpt-5.1-codex",
9
+ "anthropic/claude-sonnet-4-5 Fast and reliable",
10
+ "google/gemini-3-pro",
11
+ ].join("\n");
12
+
13
+ const models = parseModelsOutput(raw);
14
+ assert.equal(models.length, 3);
15
+ assert.equal(models[0].id, "openai/gpt-5.1-codex");
16
+ assert.equal(models[1].provider, "anthropic");
17
+ });
18
+
19
+ test("parseModelsOutput parses table-like provider/model lines", () => {
20
+ const raw = [
21
+ "provider model",
22
+ "openai gpt-5.1-codex",
23
+ "google gemini-3-pro",
24
+ ].join("\n");
25
+
26
+ const models = parseModelsOutput(raw);
27
+ assert.equal(models.length, 2);
28
+ assert.equal(models[0].id, "openai/gpt-5.1-codex");
29
+ assert.equal(models[1].id, "google/gemini-3-pro");
30
+ });
31
+
32
+ test("findModel falls back from exact to model-name match", () => {
33
+ const models = [
34
+ { id: "openrouter/gpt-5.1-codex", provider: "openrouter", model: "gpt-5.1-codex" },
35
+ { id: "google/gemini-3-pro", provider: "google", model: "gemini-3-pro" },
36
+ ];
37
+
38
+ const found = findModel(models, "openai/gpt-5.1-codex");
39
+ assert.ok(found);
40
+ assert.equal(found.id, "openrouter/gpt-5.1-codex");
41
+ assert.equal(isValidModel(models, "openai/gpt-5.1-codex"), true);
42
+ });
package/src/lib/ui.js CHANGED
@@ -240,36 +240,43 @@ export async function input(question, defaultValue = "") {
240
240
  export async function select(title, items) {
241
241
  if (!items.length) throw new Error("select: no items provided");
242
242
 
243
+ if (!supportsInteractiveMenu()) {
244
+ return selectFallback(title, items);
245
+ }
246
+
243
247
  return new Promise((resolve) => {
244
248
  let cursor = 0;
249
+ const maxVisible = getMaxVisibleMenuItems();
250
+ const totalLines = Math.min(items.length, maxVisible) + 2;
251
+ let hasRendered = false;
245
252
 
246
253
  const render = () => {
247
- // Move cursor up to overwrite previous render
248
- if (cursor !== -1) {
249
- stdout.write(`\x1b[${items.length + 1}A`);
254
+ const { start, end } = getVisibleWindow(items.length, cursor, maxVisible);
255
+ const visible = items.slice(start, end);
256
+
257
+ if (hasRendered) {
258
+ stdout.write(`\x1b[${totalLines}A`);
250
259
  }
251
260
 
252
- stdout.write(` ${c.cyan("?")} ${c.bold(title)}\n`);
253
- for (let i = 0; i < items.length; i++) {
254
- const item = items[i];
255
- const selected = i === cursor;
256
- const pointer = selected ? c.cyan("\u25B6") : " ";
257
- const label = selected ? c.cyan(item.label) : c.white(item.label);
261
+ stdout.write(`\x1b[2K ${c.cyan("?")} ${c.bold(title)}\n`);
262
+ for (let i = 0; i < visible.length; i++) {
263
+ const itemIndex = start + i;
264
+ const item = visible[i];
265
+ const isSelected = itemIndex === cursor;
266
+ const pointer = isSelected ? c.cyan("\u25B6") : " ";
267
+ const label = isSelected ? c.cyan(item.label) : c.white(item.label);
258
268
  const hint = item.hint ? c.gray(` ${item.hint}`) : "";
259
- stdout.write(` ${pointer} ${label}${hint}\n`);
269
+ stdout.write(`\x1b[2K ${pointer} ${label}${hint}\n`);
260
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");
276
+ hasRendered = true;
261
277
  };
262
278
 
263
- // Initial render
264
- stdout.write(` ${c.cyan("?")} ${c.bold(title)}\n`);
265
- for (let i = 0; i < items.length; i++) {
266
- const item = items[i];
267
- const selected = i === cursor;
268
- const pointer = selected ? c.cyan("\u25B6") : " ";
269
- const label = selected ? c.cyan(item.label) : c.white(item.label);
270
- const hint = item.hint ? c.gray(` ${item.hint}`) : "";
271
- stdout.write(` ${pointer} ${label}${hint}\n`);
272
- }
279
+ render();
273
280
 
274
281
  stdin.setRawMode(true);
275
282
  stdin.resume();
@@ -288,13 +295,12 @@ export async function select(title, items) {
288
295
  stdin.setRawMode(false);
289
296
  stdin.removeListener("data", onData);
290
297
  stdin.pause();
291
- // Clear and reprint final selection
292
- stdout.write(`\x1b[${items.length + 1}A`);
293
- stdout.write(` ${c.green("\u2713")} ${c.bold(title)} ${c.cyan(items[cursor].label)}\n`);
294
- for (let i = 0; i < items.length; i++) {
295
- stdout.write(`\x1b[2K\n`); // clear each line
298
+ stdout.write(`\x1b[${totalLines}A`);
299
+ for (let i = 0; i < totalLines; i++) {
300
+ stdout.write("\x1b[2K\n");
296
301
  }
297
- stdout.write(`\x1b[${items.length}A`); // move back up
302
+ stdout.write(`\x1b[${totalLines}A`);
303
+ stdout.write(`\x1b[2K ${c.green("\u2713")} ${c.bold(title)} ${c.cyan(items[cursor].label)}\n`);
298
304
  resolve(items[cursor]);
299
305
  return;
300
306
  }
@@ -325,41 +331,46 @@ export async function select(title, items) {
325
331
  export async function multiSelect(title, items) {
326
332
  if (!items.length) throw new Error("multiSelect: no items provided");
327
333
 
334
+ if (!supportsInteractiveMenu()) {
335
+ return multiSelectFallback(title, items);
336
+ }
337
+
328
338
  return new Promise((resolve) => {
329
339
  let cursor = 0;
330
- const selected = new Set(
331
- items.filter((it) => it.selected).map((_, i) => i)
332
- );
340
+ const selected = buildSelectedIndexSet(items);
341
+ const maxVisible = getMaxVisibleMenuItems();
342
+ const totalLines = Math.min(items.length, maxVisible) + 2;
343
+ let hasRendered = false;
333
344
 
334
345
  const render = () => {
335
- stdout.write(`\x1b[${items.length + 2}A`);
336
- stdout.write(` ${c.cyan("?")} ${c.bold(title)} ${c.gray("(space to toggle, enter to confirm)")}\n`);
337
- for (let i = 0; i < items.length; i++) {
338
- const item = items[i];
339
- const isCursor = i === cursor;
340
- const isSelected = selected.has(i);
346
+ const { start, end } = getVisibleWindow(items.length, cursor, maxVisible);
347
+ const visible = items.slice(start, end);
348
+
349
+ if (hasRendered) {
350
+ stdout.write(`\x1b[${totalLines}A`);
351
+ }
352
+
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);
341
359
  const pointer = isCursor ? c.cyan("\u25B6") : " ";
342
360
  const check = isSelected ? c.green("\u25C9") : c.gray("\u25CB");
343
361
  const label = isCursor ? c.cyan(item.label) : c.white(item.label);
344
362
  const hint = item.hint ? c.gray(` ${item.hint}`) : "";
345
363
  stdout.write(`\x1b[2K ${pointer} ${check} ${label}${hint}\n`);
346
364
  }
347
- 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");
370
+ hasRendered = true;
348
371
  };
349
372
 
350
- // Initial render
351
- stdout.write(` ${c.cyan("?")} ${c.bold(title)} ${c.gray("(space to toggle, enter to confirm)")}\n`);
352
- for (let i = 0; i < items.length; i++) {
353
- const item = items[i];
354
- const isCursor = i === cursor;
355
- const isSelected = selected.has(i);
356
- const pointer = isCursor ? c.cyan("\u25B6") : " ";
357
- const check = isSelected ? c.green("\u25C9") : c.gray("\u25CB");
358
- const label = isCursor ? c.cyan(item.label) : c.white(item.label);
359
- const hint = item.hint ? c.gray(` ${item.hint}`) : "";
360
- stdout.write(` ${pointer} ${check} ${label}${hint}\n`);
361
- }
362
- stdout.write("\n");
373
+ render();
363
374
 
364
375
  stdin.setRawMode(true);
365
376
  stdin.resume();
@@ -376,14 +387,14 @@ export async function multiSelect(title, items) {
376
387
  stdin.setRawMode(false);
377
388
  stdin.removeListener("data", onData);
378
389
  stdin.pause();
379
- const result = [...selected].map((i) => items[i]);
380
- // Clear and show final
381
- stdout.write(`\x1b[${items.length + 2}A`);
382
- stdout.write(`\x1b[2K ${c.green("\u2713")} ${c.bold(title)} ${c.cyan(result.map((r) => r.label).join(", "))}\n`);
383
- for (let i = 0; i < items.length + 1; i++) {
384
- stdout.write(`\x1b[2K\n`);
390
+ const orderedIndexes = [...selected].sort((a, b) => a - b);
391
+ const result = orderedIndexes.map((i) => items[i]);
392
+ stdout.write(`\x1b[${totalLines}A`);
393
+ for (let i = 0; i < totalLines; i++) {
394
+ stdout.write("\x1b[2K\n");
385
395
  }
386
- stdout.write(`\x1b[${items.length + 1}A`);
396
+ 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`);
387
398
  resolve(result);
388
399
  return;
389
400
  }
@@ -444,3 +455,102 @@ export function truncate(str, maxLen = 40) {
444
455
  if (str.length <= maxLen) return str;
445
456
  return str.slice(0, maxLen - 1) + "\u2026";
446
457
  }
458
+
459
+ /**
460
+ * Build selected index set preserving original item indexes.
461
+ * @param {Array<{selected?: boolean}>} items
462
+ * @returns {Set<number>}
463
+ */
464
+ export function buildSelectedIndexSet(items) {
465
+ const indexes = new Set();
466
+ for (let i = 0; i < items.length; i++) {
467
+ if (items[i]?.selected) indexes.add(i);
468
+ }
469
+ return indexes;
470
+ }
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
+
508
+ function supportsInteractiveMenu() {
509
+ return Boolean(stdin.isTTY && stdout.isTTY && typeof stdin.setRawMode === "function");
510
+ }
511
+
512
+ async function selectFallback(title, items) {
513
+ console.log(` ${c.cyan("?")} ${c.bold(title)}`);
514
+ for (let i = 0; i < items.length; i++) {
515
+ const item = items[i];
516
+ const hint = item.hint ? ` ${c.gray(item.hint)}` : "";
517
+ console.log(` ${String(i + 1).padStart(2, " ")}) ${item.label}${hint}`);
518
+ }
519
+ const answer = await input("Choose an option number", "1");
520
+ const idx = clampIndex(parseInt(answer, 10) - 1, items.length);
521
+ console.log(` ${c.green("\u2713")} ${c.bold(title)} ${c.cyan(items[idx].label)}`);
522
+ return items[idx];
523
+ }
524
+
525
+ async function multiSelectFallback(title, items) {
526
+ console.log(` ${c.cyan("?")} ${c.bold(title)}`);
527
+ for (let i = 0; i < items.length; i++) {
528
+ const item = items[i];
529
+ const mark = item.selected ? "*" : " ";
530
+ const hint = item.hint ? ` ${c.gray(item.hint)}` : "";
531
+ console.log(` ${String(i + 1).padStart(2, " ")}) [${mark}] ${item.label}${hint}`);
532
+ }
533
+ const answer = await input("Choose option numbers (comma-separated)", "1");
534
+ const indexes = parseMultiNumberInput(answer, items.length);
535
+ const selected = indexes.map((i) => items[i]);
536
+ console.log(` ${c.green("\u2713")} ${c.bold(title)} ${c.cyan(selected.map((it) => it.label).join(", "))}`);
537
+ return selected;
538
+ }
539
+
540
+ function clampIndex(idx, length) {
541
+ if (Number.isNaN(idx) || idx < 0) return 0;
542
+ if (idx >= length) return length - 1;
543
+ return idx;
544
+ }
545
+
546
+ function parseMultiNumberInput(text, length) {
547
+ const indexes = new Set();
548
+ for (const rawPart of text.split(",")) {
549
+ const n = parseInt(rawPart.trim(), 10);
550
+ if (Number.isNaN(n)) continue;
551
+ const idx = n - 1;
552
+ if (idx >= 0 && idx < length) indexes.add(idx);
553
+ }
554
+ if (indexes.size === 0) indexes.add(0);
555
+ return [...indexes].sort((a, b) => a - b);
556
+ }
@@ -0,0 +1,41 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { buildSelectedIndexSet, getVisibleWindow, getMaxVisibleMenuItems } from "./ui.js";
5
+
6
+ test("buildSelectedIndexSet keeps original indexes", () => {
7
+ const items = [
8
+ { label: "a", selected: false },
9
+ { label: "b", selected: true },
10
+ { label: "c", selected: false },
11
+ { label: "d", selected: true },
12
+ ];
13
+
14
+ const selected = buildSelectedIndexSet(items);
15
+ assert.deepEqual([...selected], [1, 3]);
16
+ });
17
+
18
+ test("buildSelectedIndexSet handles empty selection", () => {
19
+ const items = [
20
+ { label: "a" },
21
+ { label: "b", selected: false },
22
+ ];
23
+
24
+ const selected = buildSelectedIndexSet(items);
25
+ assert.equal(selected.size, 0);
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
+ });