as-test 1.4.1 → 1.5.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.
@@ -3,9 +3,169 @@ import { spawnSync } from "child_process";
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
4
4
  import * as path from "path";
5
5
  import { createInterface } from "readline";
6
- import { getCliVersion } from "../util.js";
6
+ import { getCliVersion, getExec } from "../util.js";
7
7
  import { buildWebRunnerSource } from "./web-runner-source.js";
8
8
  const TARGETS = ["wasi", "bindings", "web"];
9
+ // Popular runtimes offered by the interactive picker. Availability is probed
10
+ // against the machine/project (PATH for native binaries, node_modules for
11
+ // Playwright) so unavailable entries can be dimmed rather than hidden.
12
+ const RUNTIMES = [
13
+ {
14
+ value: "node:wasi",
15
+ label: "Node.js",
16
+ target: "wasi",
17
+ cmd: "node .as-test/runners/default.wasi.js",
18
+ browser: "",
19
+ },
20
+ {
21
+ value: "node:bindings",
22
+ label: "Node.js",
23
+ target: "bindings",
24
+ cmd: "node .as-test/runners/default.bindings.js",
25
+ browser: "",
26
+ },
27
+ {
28
+ value: "wasmtime",
29
+ label: "Wasmtime",
30
+ target: "wasi",
31
+ cmd: "wasmtime run <file>",
32
+ browser: "",
33
+ bin: "wasmtime",
34
+ },
35
+ {
36
+ value: "wasmer",
37
+ label: "Wasmer",
38
+ target: "wasi",
39
+ cmd: "wasmer run <file>",
40
+ browser: "",
41
+ bin: "wasmer",
42
+ },
43
+ {
44
+ value: "wazero",
45
+ label: "wazero",
46
+ target: "wasi",
47
+ cmd: "wazero run <file>",
48
+ browser: "",
49
+ bin: "wazero",
50
+ },
51
+ {
52
+ value: "chromium",
53
+ label: "Chromium",
54
+ target: "web",
55
+ cmd: "node .as-test/runners/default.web.js",
56
+ browser: "chromium",
57
+ needsPlaywright: true,
58
+ },
59
+ {
60
+ value: "firefox",
61
+ label: "Firefox",
62
+ target: "web",
63
+ cmd: "node .as-test/runners/default.web.js",
64
+ browser: "firefox",
65
+ needsPlaywright: true,
66
+ },
67
+ {
68
+ value: "webkit",
69
+ label: "WebKit",
70
+ target: "web",
71
+ cmd: "node .as-test/runners/default.web.js",
72
+ browser: "webkit",
73
+ needsPlaywright: true,
74
+ },
75
+ ];
76
+ // Probe whether a runtime is usable on this machine/project. Node-based
77
+ // runtimes are always available (we are running under Node); native binaries
78
+ // must resolve on PATH; browser runtimes require Playwright to be installed.
79
+ function probeRuntime(runtime, root) {
80
+ if (runtime.bin) {
81
+ if (getExec(runtime.bin)) return { available: true };
82
+ return { available: false, hint: `${runtime.bin} not on PATH` };
83
+ }
84
+ if (runtime.needsPlaywright) {
85
+ const pkg = path.join(root, "node_modules", "playwright", "package.json");
86
+ if (existsSync(pkg)) return { available: true };
87
+ return { available: false, hint: "playwright not installed" };
88
+ }
89
+ return { available: true };
90
+ }
91
+ // The default runtime command for a build target, matching the long-standing
92
+ // `--target`/`--yes` behaviour (plain Node runner, no browser).
93
+ function runtimeForTarget(target) {
94
+ const cmd =
95
+ target == "wasi"
96
+ ? "node .as-test/runners/default.wasi.js"
97
+ : target == "bindings"
98
+ ? "node .as-test/runners/default.bindings.js"
99
+ : "node .as-test/runners/default.web.js";
100
+ return { cmd, browser: "" };
101
+ }
102
+ function defaultRuntimeLabel(target) {
103
+ if (target == "wasi") return "node:wasi";
104
+ if (target == "bindings") return "node:bindings";
105
+ return "web";
106
+ }
107
+ // Ensure a mode key is unique against names already in use, suffixing -2, -3…
108
+ function uniqueModeName(name, taken) {
109
+ if (!taken.includes(name)) return name;
110
+ for (let i = 2; ; i++) {
111
+ const candidate = `${name}-${i}`;
112
+ if (!taken.includes(candidate)) return candidate;
113
+ }
114
+ }
115
+ // Prompt for a user-defined runtime: a command (with optional <file>
116
+ // placeholder), a mode name, and — for the web target — an optional browser.
117
+ // Returns null if no command is entered or interactive input is unavailable.
118
+ async function askCustomRuntime(target, face, taken) {
119
+ if (!face) return null;
120
+ const cmd = (
121
+ await ask(
122
+ `${chalk.bold.blue("◇ Custom runtime command")}\n` +
123
+ `${chalk.dim("│ <file> is replaced with the generated .wasm — e.g. wasmtime <file>")}\n│ `,
124
+ face,
125
+ "",
126
+ )
127
+ ).trim();
128
+ if (!cmd.length) return null;
129
+ const rawName = (
130
+ await ask(
131
+ `${chalk.bold.blue("◇ Name for this runtime (used as the mode key, default: custom)")}\n│ `,
132
+ face,
133
+ "",
134
+ )
135
+ ).trim();
136
+ const name = uniqueModeName(rawName.length ? rawName : "custom", taken);
137
+ let browser = "";
138
+ if (target == "web") {
139
+ browser = (
140
+ await ask(
141
+ `${chalk.bold.blue("◇ Browser (optional: chromium / firefox / webkit)")}\n│ `,
142
+ face,
143
+ "",
144
+ )
145
+ ).trim();
146
+ }
147
+ printSelectionLine(`${name}: ${cmd}${browser ? ` (${browser})` : ""}`);
148
+ return { value: name, target, cmd, browser };
149
+ }
150
+ // Turn the selected runtimes into config `modes` — one named mode per runtime,
151
+ // each carrying its build target and runtime command. Every mode runs by
152
+ // default (the absent `default` flag defaults to true), so `ast test` executes
153
+ // them all.
154
+ function buildRuntimeModes(selected) {
155
+ const modes = {};
156
+ for (const rt of selected) {
157
+ modes[rt.value] = {
158
+ buildOptions: { target: rt.target },
159
+ runOptions: {
160
+ runtime: {
161
+ cmd: rt.cmd,
162
+ ...(rt.browser ? { browser: rt.browser } : {}),
163
+ },
164
+ },
165
+ };
166
+ }
167
+ return modes;
168
+ }
9
169
  const EXAMPLE_MODES = ["minimal", "full", "none"];
10
170
  const FEATURE_KEYS = ["coverage", "tryAs"];
11
171
  const FEATURE_LABELS = {
@@ -27,6 +187,9 @@ export async function init(rawArgs) {
27
187
  ? {
28
188
  root: path.resolve(process.cwd(), options.dir),
29
189
  target: options.target ?? "wasi",
190
+ runtime: runtimeForTarget(options.target ?? "wasi"),
191
+ modes: {},
192
+ runtimeLabel: defaultRuntimeLabel(options.target ?? "wasi"),
30
193
  example: options.example ?? "minimal",
31
194
  fuzzExample: options.fuzzExample ?? false,
32
195
  features: resolveFeatures(options.features, {
@@ -43,6 +206,8 @@ export async function init(rawArgs) {
43
206
  printPlan(
44
207
  answers.root,
45
208
  answers.target,
209
+ answers.runtimeLabel,
210
+ Object.keys(answers.modes),
46
211
  answers.example,
47
212
  answers.fuzzExample,
48
213
  answers.features,
@@ -58,6 +223,8 @@ export async function init(rawArgs) {
58
223
  const summary = applyInit(
59
224
  answers.root,
60
225
  answers.target,
226
+ answers.runtime,
227
+ answers.modes,
61
228
  answers.example,
62
229
  answers.fuzzExample,
63
230
  answers.features,
@@ -265,6 +432,8 @@ async function runInteractiveOnboarding(options, face) {
265
432
  } else {
266
433
  printSelectionLine(resolvedRoot);
267
434
  }
435
+ // Step 1: pick the build target (mode). The runtime list is then filtered to
436
+ // the runtimes that support this target.
268
437
  const target =
269
438
  options.target ??
270
439
  (onboardingMode == "quick"
@@ -272,21 +441,9 @@ async function runInteractiveOnboarding(options, face) {
272
441
  : await askMenuChoice(
273
442
  "Build target",
274
443
  [
275
- {
276
- value: "wasi",
277
- label:
278
- "wasi (default runner: node .as-test/runners/default.wasi.js)",
279
- },
280
- {
281
- value: "bindings",
282
- label:
283
- "bindings (default runner: node .as-test/runners/default.bindings.js)",
284
- },
285
- {
286
- value: "web",
287
- label:
288
- "web (default runner: node .as-test/runners/default.web.js)",
289
- },
444
+ { value: "wasi", label: "wasi (WebAssembly System Interface)" },
445
+ { value: "bindings", label: "bindings (Node.js host bindings)" },
446
+ { value: "web", label: "web (browser via Playwright)" },
290
447
  ],
291
448
  face,
292
449
  "wasi",
@@ -294,6 +451,72 @@ async function runInteractiveOnboarding(options, face) {
294
451
  if (options.target || onboardingMode == "quick") {
295
452
  printPromptAndSelectionLine("Build target", target);
296
453
  }
454
+ // Step 2: pick one or more runtimes, scoped to the chosen target. Unavailable
455
+ // runtimes (native binary missing from PATH, or Playwright not installed) are
456
+ // dimmed; "Custom…" lets the user define their own. Each chosen runtime
457
+ // becomes a config mode so `ast test` runs the whole matrix.
458
+ let runtime;
459
+ let runtimeLabel;
460
+ let modes = {};
461
+ const targetRuntimes = RUNTIMES.filter((rt) => rt.target == target);
462
+ if (options.target || onboardingMode == "quick") {
463
+ // Flag/quick runs are non-interactive: keep the historical Node default.
464
+ runtime = runtimeForTarget(target);
465
+ runtimeLabel = defaultRuntimeLabel(target);
466
+ printPromptAndSelectionLine("Runtime", runtimeLabel);
467
+ } else {
468
+ const toggleChoices = targetRuntimes.map((rt) => {
469
+ const status = probeRuntime(rt, resolvedRoot);
470
+ return {
471
+ value: rt.value,
472
+ label: rt.label,
473
+ disabled: !status.available,
474
+ hint: status.hint,
475
+ };
476
+ });
477
+ toggleChoices.push({
478
+ value: "custom",
479
+ label: "Custom…",
480
+ alwaysSelectable: true,
481
+ });
482
+ // Pre-select the first available built-in runtime so confirming immediately
483
+ // yields a sensible single choice.
484
+ const firstAvailable =
485
+ targetRuntimes.find((rt) => probeRuntime(rt, resolvedRoot).available)
486
+ ?.value ?? targetRuntimes[0].value;
487
+ const initial = {};
488
+ for (const choice of toggleChoices) {
489
+ initial[choice.value] = choice.value == firstAvailable;
490
+ }
491
+ const result = await askMultiToggle(
492
+ "Runtimes (↑/↓ move, space toggle, enter confirm — dimmed = not detected)",
493
+ toggleChoices,
494
+ face,
495
+ initial,
496
+ );
497
+ const selected = targetRuntimes.filter((rt) => result[rt.value]);
498
+ if (result["custom"]) {
499
+ const custom = await askCustomRuntime(
500
+ target,
501
+ face,
502
+ selected.map((rt) => rt.value),
503
+ );
504
+ if (custom) selected.push(custom);
505
+ }
506
+ if (!selected.length) {
507
+ // Confirming with nothing selected falls back to the default runtime.
508
+ const fallback =
509
+ targetRuntimes.find((rt) => rt.value == firstAvailable) ??
510
+ targetRuntimes[0];
511
+ selected.push(fallback);
512
+ }
513
+ const primary = selected[0];
514
+ runtime = { cmd: primary.cmd, browser: primary.browser };
515
+ runtimeLabel = selected.map((rt) => rt.value).join(", ");
516
+ // The picker is authoritative: write a mode per chosen runtime so the
517
+ // exact selection (single, matrix, or custom) is what `ast test` runs.
518
+ modes = buildRuntimeModes(selected);
519
+ }
297
520
  const featureDefaults = { coverage: false, tryAs: false };
298
521
  const explicitFeatures =
299
522
  options.features.coverage !== undefined ||
@@ -355,6 +578,9 @@ async function runInteractiveOnboarding(options, face) {
355
578
  return {
356
579
  root: resolvedRoot,
357
580
  target,
581
+ runtime,
582
+ modes,
583
+ runtimeLabel,
358
584
  example,
359
585
  fuzzExample,
360
586
  features,
@@ -464,7 +690,16 @@ function isTarget(value) {
464
690
  function isExampleMode(value) {
465
691
  return EXAMPLE_MODES.includes(value);
466
692
  }
467
- function printPlan(root, target, example, fuzzExample, features, install) {
693
+ function printPlan(
694
+ root,
695
+ target,
696
+ runtimeLabel,
697
+ modeNames,
698
+ example,
699
+ fuzzExample,
700
+ features,
701
+ install,
702
+ ) {
468
703
  const displayRoot = () => {
469
704
  const rel = path.relative(process.cwd(), root).split(path.sep).join("/");
470
705
  if (!rel || rel == ".") return "./";
@@ -568,6 +803,15 @@ function printPlan(root, target, example, fuzzExample, features, install) {
568
803
  const treeRoot = buildTree(fileEntries);
569
804
  console.log(chalk.bold.blue("◇ Planned Changes"));
570
805
  console.log("│" + chalk.dim(` - Target: ${target}`));
806
+ console.log(
807
+ "│" +
808
+ chalk.dim(
809
+ ` - Runtime${runtimeLabel.includes(",") ? "s" : ""}: ${runtimeLabel}`,
810
+ ),
811
+ );
812
+ if (modeNames.length) {
813
+ console.log("│" + chalk.dim(` - Modes: ${modeNames.join(", ")}`));
814
+ }
571
815
  console.log("│" + chalk.dim(` - Example: ${example}`));
572
816
  console.log(
573
817
  "│" + chalk.dim(` - Fuzzer example: ${fuzzExample ? "yes" : "no"}`),
@@ -586,7 +830,16 @@ function printPlan(root, target, example, fuzzExample, features, install) {
586
830
  }
587
831
  console.log("│");
588
832
  }
589
- function applyInit(root, target, example, fuzzExample, features, force) {
833
+ function applyInit(
834
+ root,
835
+ target,
836
+ runtime,
837
+ modes,
838
+ example,
839
+ fuzzExample,
840
+ features,
841
+ force,
842
+ ) {
590
843
  const summary = {
591
844
  created: [],
592
845
  updated: [],
@@ -637,17 +890,17 @@ function applyInit(root, target, example, fuzzExample, features, force) {
637
890
  },
638
891
  runOptions: {
639
892
  runtime: {
640
- cmd:
641
- target == "wasi"
642
- ? "node .as-test/runners/default.wasi.js"
643
- : target == "bindings"
644
- ? "node .as-test/runners/default.bindings.js"
645
- : "node .as-test/runners/default.web.js",
893
+ cmd: runtime.cmd,
894
+ ...(runtime.browser ? { browser: runtime.browser } : {}),
646
895
  },
647
896
  reporter: "default",
648
897
  },
649
- modes:
650
- target == "web"
898
+ // The interactive picker supplies one mode per selected runtime. The
899
+ // non-interactive paths (--yes/--target/quick) leave `modes` empty, so the
900
+ // historical web convenience modes are scaffolded for the web target.
901
+ modes: Object.keys(modes).length
902
+ ? modes
903
+ : target == "web"
651
904
  ? {
652
905
  web: {
653
906
  default: false,
@@ -899,12 +1152,14 @@ async function askChoice(label, choices, face, fallback) {
899
1152
  throw new Error(`Invalid choice "${answer}" for ${label}`);
900
1153
  }
901
1154
  async function askMenuChoice(label, choices, face, fallback) {
902
- const fallbackValue = choices.some((choice) => choice.value == fallback)
1155
+ const enabled = choices.filter((choice) => !choice.disabled);
1156
+ const pool = enabled.length ? enabled : choices;
1157
+ const fallbackValue = pool.some((choice) => choice.value == fallback)
903
1158
  ? fallback
904
- : choices[0].value;
1159
+ : pool[0].value;
905
1160
  if (!face) return fallbackValue;
906
1161
  if (!canUseArrowMenu(face)) {
907
- const values = choices.map((choice) => choice.value);
1162
+ const values = pool.map((choice) => choice.value);
908
1163
  return askChoice(label, values, face, fallbackValue);
909
1164
  }
910
1165
  return askMenuChoiceWithArrows(label, choices, face, fallbackValue);
@@ -915,7 +1170,16 @@ async function askMultiToggle(label, choices, face, initial) {
915
1170
  return askMultiToggleWithArrows(label, choices, face, initial);
916
1171
  }
917
1172
  const result = { ...initial };
1173
+ const anySelectable = choices.some(
1174
+ (choice) => !choice.disabled && !choice.alwaysSelectable,
1175
+ );
918
1176
  for (const choice of choices) {
1177
+ const selectable =
1178
+ Boolean(choice.alwaysSelectable) || !anySelectable || !choice.disabled;
1179
+ if (!selectable) {
1180
+ result[choice.value] = false;
1181
+ continue;
1182
+ }
919
1183
  result[choice.value] = await askYesNo(
920
1184
  `${label} — enable ${choice.label}?`,
921
1185
  face,
@@ -961,8 +1225,32 @@ function canUseArrowMenu(face) {
961
1225
  async function askMenuChoiceWithArrows(label, choices, face, fallback) {
962
1226
  const stdin = process.stdin;
963
1227
  const stdout = process.stdout;
964
- const fallbackIndex = choices.findIndex((choice) => choice.value == fallback);
965
- let selectedIndex = fallbackIndex == -1 ? 0 : fallbackIndex;
1228
+ // When nothing is selectable (e.g. every runtime for a target is dimmed
1229
+ // because its tooling isn't installed yet), fall back to letting the user
1230
+ // pick anyway — the dimming stays as an informational warning.
1231
+ const anySelectable = choices.some((choice) => !choice.disabled);
1232
+ const isSelectable = (index) => !anySelectable || !choices[index].disabled;
1233
+ const firstSelectable = choices.findIndex((_, i) => isSelectable(i));
1234
+ const fallbackIndex = choices.findIndex(
1235
+ (choice) => choice.value == fallback && !choice.disabled,
1236
+ );
1237
+ let selectedIndex =
1238
+ fallbackIndex != -1
1239
+ ? fallbackIndex
1240
+ : firstSelectable != -1
1241
+ ? firstSelectable
1242
+ : 0;
1243
+ // Step from `selectedIndex` in `step` direction (wrapping) to the next
1244
+ // selectable option, ignoring disabled entries. Returns the current index
1245
+ // unchanged if nothing else is selectable.
1246
+ const stepSelection = (step) => {
1247
+ for (let i = 1; i <= choices.length; i++) {
1248
+ const candidate =
1249
+ (selectedIndex + step * i + choices.length * i) % choices.length;
1250
+ if (isSelectable(candidate)) return candidate;
1251
+ }
1252
+ return selectedIndex;
1253
+ };
966
1254
  let renderedLineCount = 0;
967
1255
  const previousRawMode = Boolean(stdin.isRaw);
968
1256
  const lineWidth = Math.max(20, (stdout.columns ?? 80) - 2);
@@ -977,6 +1265,15 @@ async function askMenuChoiceWithArrows(label, choices, face, fallback) {
977
1265
  const lines = [titleLine()];
978
1266
  for (let i = 0; i < choices.length; i++) {
979
1267
  const choice = choices[i];
1268
+ if (choice.disabled) {
1269
+ const text = choice.hint
1270
+ ? `${choice.label} (${choice.hint})`
1271
+ : choice.label;
1272
+ lines.push(
1273
+ `│ ${chalk.dim("✕")} ${chalk.dim(clamp(text, Math.max(8, lineWidth - 6)))}`,
1274
+ );
1275
+ continue;
1276
+ }
980
1277
  const marker = i == selectedIndex ? chalk.blue("●") : chalk.dim("○");
981
1278
  lines.push(
982
1279
  `│ ${marker} ${clamp(choice.label, Math.max(8, lineWidth - 6))}`,
@@ -1069,7 +1366,7 @@ async function askMenuChoiceWithArrows(label, choices, face, fallback) {
1069
1366
  input == "\x1b[D" ||
1070
1367
  input == "\x1bOD"
1071
1368
  ) {
1072
- selectedIndex = (selectedIndex - 1 + choices.length) % choices.length;
1369
+ selectedIndex = stepSelection(-1);
1073
1370
  writeLines(menuLines());
1074
1371
  return;
1075
1372
  }
@@ -1079,11 +1376,12 @@ async function askMenuChoiceWithArrows(label, choices, face, fallback) {
1079
1376
  input == "\x1b[C" ||
1080
1377
  input == "\x1bOC"
1081
1378
  ) {
1082
- selectedIndex = (selectedIndex + 1) % choices.length;
1379
+ selectedIndex = stepSelection(1);
1083
1380
  writeLines(menuLines());
1084
1381
  return;
1085
1382
  }
1086
1383
  if (input == "\r" || input == "\n") {
1384
+ if (!isSelectable(selectedIndex)) return;
1087
1385
  finish(choices[selectedIndex].value);
1088
1386
  return;
1089
1387
  }
@@ -1101,7 +1399,29 @@ async function askMultiToggleWithArrows(label, choices, face, initial) {
1101
1399
  const stdin = process.stdin;
1102
1400
  const stdout = process.stdout;
1103
1401
  const selected = { ...initial };
1104
- let cursorIndex = 0;
1402
+ // When no "real" option is selectable (e.g. every browser is dimmed because
1403
+ // Playwright isn't installed yet — which init can fix), allow toggling the
1404
+ // dimmed entries anyway; the dimming stays as an informational warning. The
1405
+ // always-selectable Custom… entry is excluded from this decision.
1406
+ const anySelectable = choices.some(
1407
+ (choice) => !choice.disabled && !choice.alwaysSelectable,
1408
+ );
1409
+ const isSelectable = (index) => {
1410
+ const choice = choices[index];
1411
+ return (
1412
+ Boolean(choice.alwaysSelectable) || !anySelectable || !choice.disabled
1413
+ );
1414
+ };
1415
+ const stepCursor = (step) => {
1416
+ for (let i = 1; i <= choices.length; i++) {
1417
+ const candidate =
1418
+ (cursorIndex + step * i + choices.length * i) % choices.length;
1419
+ if (isSelectable(candidate)) return candidate;
1420
+ }
1421
+ return cursorIndex;
1422
+ };
1423
+ let cursorIndex = choices.findIndex((_, i) => isSelectable(i));
1424
+ if (cursorIndex == -1) cursorIndex = 0;
1105
1425
  let renderedLineCount = 0;
1106
1426
  const previousRawMode = Boolean(stdin.isRaw);
1107
1427
  const lineWidth = Math.max(20, (stdout.columns ?? 80) - 2);
@@ -1118,6 +1438,20 @@ async function askMultiToggleWithArrows(label, choices, face, initial) {
1118
1438
  const choice = choices[i];
1119
1439
  const isOn = Boolean(selected[choice.value]);
1120
1440
  const cursor = i == cursorIndex ? chalk.blue("›") : " ";
1441
+ if (choice.disabled) {
1442
+ const text = choice.hint
1443
+ ? `${choice.label} (${choice.hint})`
1444
+ : choice.label;
1445
+ const dimmed = chalk.dim(clamp(text, Math.max(8, lineWidth - 8)));
1446
+ // Selectable-but-dimmed (installable) keeps its ●/○; hard-blocked uses ✕.
1447
+ const marker = isSelectable(i)
1448
+ ? isOn
1449
+ ? chalk.blue("●")
1450
+ : chalk.dim("○")
1451
+ : chalk.dim("✕");
1452
+ lines.push(`│ ${cursor} ${marker} ${dimmed}`);
1453
+ continue;
1454
+ }
1121
1455
  const marker = isOn ? chalk.blue("●") : chalk.dim("○");
1122
1456
  const text = clamp(choice.label, Math.max(8, lineWidth - 6));
1123
1457
  const painted = i == cursorIndex ? chalk.bold(text) : text;
@@ -1203,16 +1537,17 @@ async function askMultiToggleWithArrows(label, choices, face, initial) {
1203
1537
  return;
1204
1538
  }
1205
1539
  if (input == "\x1b[A" || input == "\x1bOA") {
1206
- cursorIndex = (cursorIndex - 1 + choices.length) % choices.length;
1540
+ cursorIndex = stepCursor(-1);
1207
1541
  writeLines(menuLines());
1208
1542
  return;
1209
1543
  }
1210
1544
  if (input == "\x1b[B" || input == "\x1bOB") {
1211
- cursorIndex = (cursorIndex + 1) % choices.length;
1545
+ cursorIndex = stepCursor(1);
1212
1546
  writeLines(menuLines());
1213
1547
  return;
1214
1548
  }
1215
1549
  if (input == " ") {
1550
+ if (!isSelectable(cursorIndex)) return;
1216
1551
  const key = choices[cursorIndex].value;
1217
1552
  selected[key] = !selected[key];
1218
1553
  writeLines(menuLines());