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 +3 -3
- package/src/commands/init.js +51 -18
- package/src/commands/setup.js +62 -27
- package/src/lib/models.test.js +42 -0
- package/src/lib/ui.js +166 -56
- package/src/lib/ui.test.js +41 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-auto-agent",
|
|
3
|
-
"version": "1.
|
|
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
|
|
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",
|
package/src/commands/init.js
CHANGED
|
@@ -112,26 +112,22 @@ export async function init(targetDir, { preset, force, nonInteractive } = {}) {
|
|
|
112
112
|
continue;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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)",
|
package/src/commands/setup.js
CHANGED
|
@@ -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
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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(
|
|
253
|
-
for (let i = 0; i <
|
|
254
|
-
const
|
|
255
|
-
const
|
|
256
|
-
const
|
|
257
|
-
const
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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[${
|
|
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 =
|
|
331
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
380
|
-
|
|
381
|
-
stdout.write(`\x1b[${
|
|
382
|
-
|
|
383
|
-
|
|
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[${
|
|
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
|
+
});
|