agent-yes 1.162.0 → 1.165.0

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/lab/ui/index.html CHANGED
@@ -891,6 +891,11 @@
891
891
  color: var(--green);
892
892
  border-color: #2a3a2a;
893
893
  }
894
+ /* The pending question when an agent needs_input — blue accent to match the
895
+ needs_input dot, reading as "your turn / here's what it's asking". */
896
+ .detail.ask {
897
+ color: var(--accent);
898
+ }
894
899
  .detail {
895
900
  color: var(--muted);
896
901
  font-size: 12.5px;
@@ -3187,13 +3192,19 @@
3187
3192
  );
3188
3193
  }
3189
3194
 
3190
- // A small git chip (±changed ↑ahead ↓behind) for a row, amber when the tree
3191
- // is dirty. Empty for clean/in-sync repos and non-git cwds no noise.
3195
+ // A small git chip (±changed ⑂pins ⊙sub-dirty ↑ahead ↓behind) for a row,
3196
+ // amber when the tree has real file changes. Submodule pin-drift shows as a
3197
+ // muted "⑂" that never turns the chip amber, so it can't bury real edits.
3198
+ // Empty for clean/in-sync repos and non-git cwds — no noise.
3192
3199
  function gitChipHtml(e) {
3193
3200
  const label = gitLabel(e);
3194
3201
  if (!label) return "";
3195
3202
  const g = e.git || {};
3196
- const tip = `git: ${g.changed || 0} changed · ${g.ahead || 0} ahead · ${g.behind || 0} behind`;
3203
+ const tip =
3204
+ `git: ${g.changed || 0} changed file${(g.changed || 0) === 1 ? "" : "s"}` +
3205
+ (g.pins ? ` · ${g.pins} submodule pin-bump${g.pins === 1 ? "" : "s"}` : "") +
3206
+ (g.subDirty ? ` · ${g.subDirty} submodule dirty` : "") +
3207
+ ` · ${g.ahead || 0} ahead · ${g.behind || 0} behind`;
3197
3208
  return `<span class="git${g.dirty ? " dirty" : ""}" title="${esc(tip)}">${esc(label)}</span>`;
3198
3209
  }
3199
3210
 
@@ -3285,7 +3296,13 @@
3285
3296
  ${gitChipHtml(e)}
3286
3297
  <span class="age">${age(e)}</span></div>
3287
3298
  ${e.title ? `<div class="rowtitle" title="${esc(e.title)}">${esc(e.title)}</div>` : ""}
3288
- ${e.prompt ? `<div class="detail" title="${esc(e.prompt)}">${esc(e.prompt)}</div>` : ""}
3299
+ ${
3300
+ e.status === "needs_input" && e.question
3301
+ ? `<div class="detail ask" title="${esc(e.question)}">⌨ ${esc(e.question)}</div>`
3302
+ : e.prompt
3303
+ ? `<div class="detail" title="${esc(e.prompt)}">${esc(e.prompt)}</div>`
3304
+ : ""
3305
+ }
3289
3306
  <div class="rowtags">${tags}</div>
3290
3307
  </div>`;
3291
3308
  })
@@ -3918,7 +3935,7 @@
3918
3935
  return `<div class="omni-row omni-spawn${sc}" data-i="${i}">
3919
3936
  <span class="dot active"></span>
3920
3937
  <div class="omni-main">
3921
- <div class="omni-title">⑂ Fork <b>${esc(r.srcBranch)}</b> &amp; run <span style="opacity:.55">(carries uncommitted work)</span></div>
3938
+ <div class="omni-title">⑂ Fork <b>${esc(r.srcBranch)}</b> &amp; run <span style="opacity:.55">(clean committed work only)</span></div>
3922
3939
  <div class="omni-sub">${esc(r.fromCwd)} &nbsp;→&nbsp; new branch <b>${esc(r.branch)}</b> &nbsp;·&nbsp; ⏎ to edit &amp; launch</div>
3923
3940
  </div></div>`;
3924
3941
  }
@@ -4050,14 +4067,12 @@
4050
4067
  return;
4051
4068
  }
4052
4069
  if (row && row.kind === "fork") {
4053
- // Editable at launch (auto-slug is the default); carries the source's WIP.
4070
+ // Editable at launch (auto-slug is the default). The fork is clean —
4071
+ // committed work only, the source's uncommitted changes stay put.
4054
4072
  // Fork from the source PINNED on the row (row.fromCwd), not a freshly
4055
4073
  // re-resolved anchor — the anchor can drift as the list refreshes, but the
4056
4074
  // branch we fork from must be the one the row was built for.
4057
- const branch = prompt(
4058
- "Fork to a new branch (carries your uncommitted work):",
4059
- row.branch,
4060
- );
4075
+ const branch = prompt("Fork to a new branch (clean — committed work only):", row.branch);
4061
4076
  if (!branch || !branch.trim()) return; // cancelled — leave the omnibox open
4062
4077
  closeOmni(false);
4063
4078
  await spawnAndSelect(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-yes",
3
- "version": "1.162.0",
3
+ "version": "1.165.0",
4
4
  "description": "A wrapper tool that automates interactions with various AI CLI tools by automatically handling common prompts and responses.",
5
5
  "keywords": [
6
6
  "ai",
@@ -1,5 +1,5 @@
1
1
  import { expect, test } from "vitest";
2
- import { classifyNeedsInput, isWorkingScreen } from "./needsInput.ts";
2
+ import { classifyNeedsInput, isWorkingScreen, parseMenu } from "./needsInput.ts";
3
3
  import { loadSharedCliDefaults } from "./configShared.ts";
4
4
 
5
5
  // Use the REAL shipped claude/codex patterns so the test guards the actual config.
@@ -72,3 +72,44 @@ test("detects a codex selection menu (› cursor)", () => {
72
72
  test("no patterns configured → always null", () => {
73
73
  expect(classifyNeedsInput(["❯ 1. anything"], { needsInput: [], working: [] })).toBeNull();
74
74
  });
75
+
76
+ test("parseMenu: cursor on option 1, all options collected (for ay select)", () => {
77
+ const screen = [
78
+ "Which auth method should we use?",
79
+ "",
80
+ "❯ 1. Session tokens",
81
+ " 2. JWT",
82
+ " 3. OAuth",
83
+ "",
84
+ "? for shortcuts",
85
+ ];
86
+ const menu = parseMenu(screen, claude);
87
+ expect(menu).not.toBeNull();
88
+ expect(menu!.cursor).toBe(1);
89
+ expect(menu!.options).toEqual([1, 2, 3]);
90
+ });
91
+
92
+ test("parseMenu: reads a pre-highlighted non-first default (cursor delta, not blind N-1)", () => {
93
+ const screen = ["Proceed?", " 1. Yes", "❯ 2. No, ask again", " 3. Cancel"];
94
+ const menu = parseMenu(screen, claude);
95
+ expect(menu!.cursor).toBe(2);
96
+ expect(menu!.options).toEqual([1, 2, 3]);
97
+ });
98
+
99
+ test("parseMenu: codex › cursor", () => {
100
+ const menu = parseMenu(["Pick a branch", " 1. main", "› 2. develop"], codex);
101
+ expect(menu!.cursor).toBe(2);
102
+ expect(menu!.options).toEqual([1, 2]);
103
+ });
104
+
105
+ test("parseMenu: null when not on a menu (idle prompt / working)", () => {
106
+ expect(parseMenu(['❯ Try "fix the bug"', "? for shortcuts"], claude)).toBeNull();
107
+ expect(parseMenu(["❯ 1. Session tokens", "✻ Working… (esc to interrupt)"], claude)).toBeNull();
108
+ });
109
+
110
+ test("parseMenu: a 'N.M' version in an option label isn't mistaken for another option", () => {
111
+ const screen = ["Cleanup?", "❯ 1. Delete 3.5GB of cache", " 2. Keep"];
112
+ const menu = parseMenu(screen, claude);
113
+ expect(menu!.cursor).toBe(1);
114
+ expect(menu!.options).toEqual([1, 2]);
115
+ });
package/ts/needsInput.ts CHANGED
@@ -67,6 +67,60 @@ export function classifyNeedsInput(
67
67
  return { question: block.join(" • ").slice(0, 400) };
68
68
  }
69
69
 
70
+ export interface MenuState {
71
+ /** The 1-based option number the menu cursor (❯/›/>) currently sits on. */
72
+ cursor: number;
73
+ /** Every visible option number, ascending — for range-checking a requested N. */
74
+ options: number[];
75
+ /** Same compact menu rendering as {@link classifyNeedsInput}. */
76
+ question: string;
77
+ }
78
+
79
+ // An option row: an optional cursor glyph / bullet, then "N. " (the trailing
80
+ // space rejects version-like "3.5GB" that isn't a menu option).
81
+ const OPTION_LINE = /^[\s❯›>▶◉○●·*\-]*?(\d+)\.\s/;
82
+
83
+ /**
84
+ * Parse the selection menu a `needs_input` agent is parked on into a cursor
85
+ * position + the available option numbers, so a caller can compute how far the
86
+ * cursor must move (Down/Up) to reach a target option. Returns null when the
87
+ * screen isn't a menu (delegates that judgement to {@link classifyNeedsInput},
88
+ * so `working` still wins) or no numbered cursor line is found. Pure — the
89
+ * `ay select` action reuses the exact detection `ay ls` renders with.
90
+ */
91
+ export function parseMenu(
92
+ lines: string[],
93
+ cfg: { needsInput?: RegExp[]; working?: RegExp[] },
94
+ ): MenuState | null {
95
+ const ni = classifyNeedsInput(lines, cfg);
96
+ if (!ni) return null;
97
+ const patterns = cfg.needsInput ?? [];
98
+ // The cursor line is the last one carrying a needsInput match (matches how
99
+ // classifyNeedsInput anchors its question window).
100
+ let cursorLine = -1;
101
+ for (let i = 0; i < lines.length; i++) {
102
+ if (patterns.some((re) => reTest(re, lines[i]!))) cursorLine = i;
103
+ }
104
+ if (cursorLine < 0) return null;
105
+ const cm = /(\d+)\./.exec(lines[cursorLine]!);
106
+ if (!cm) return null;
107
+ const cursor = parseInt(cm[1]!, 10);
108
+ // Gather option numbers from the rows around the cursor (a menu is contiguous).
109
+ const start = Math.max(0, cursorLine - 12);
110
+ const end = Math.min(lines.length, cursorLine + 12);
111
+ const options: number[] = [];
112
+ for (let i = start; i < end; i++) {
113
+ const m = OPTION_LINE.exec(lines[i]!);
114
+ if (m) {
115
+ const v = parseInt(m[1]!, 10);
116
+ if (!options.includes(v)) options.push(v);
117
+ }
118
+ }
119
+ if (!options.includes(cursor)) options.push(cursor);
120
+ options.sort((a, b) => a - b);
121
+ return { cursor, options, question: ni.question };
122
+ }
123
+
70
124
  /**
71
125
  * True when the rendered screen still shows a "busy" marker (config `working`,
72
126
  * e.g. claude's `esc to interrupt`). Paired with a long-quiet log this is the
package/ts/serve.ts CHANGED
@@ -9,6 +9,7 @@ import yargs from "yargs";
9
9
  import {
10
10
  controlCodeFromName,
11
11
  deriveLiveStatus,
12
+ extractNeedsInput,
12
13
  extractTaskCounts,
13
14
  listRecords,
14
15
  readNotes,
@@ -172,12 +173,15 @@ function loginShellEnv(): Record<string, string> | null {
172
173
  // Delimiters fence off the env dump from any banner/prompt noise the rc files
173
174
  // print to stdout; `env -0` is NUL-separated so values with newlines survive.
174
175
  const delim = "_AY_SHELL_ENV_DELIM_";
175
- const res = Bun.spawnSync([shell, "-ilc", `printf %s "${delim}"; env -0; printf %s "${delim}"`], {
176
- stdin: "ignore",
177
- stderr: "ignore",
178
- timeout: 5_000,
179
- env: process.env as Record<string, string>,
180
- });
176
+ const res = Bun.spawnSync(
177
+ [shell, "-ilc", `printf %s "${delim}"; env -0; printf %s "${delim}"`],
178
+ {
179
+ stdin: "ignore",
180
+ stderr: "ignore",
181
+ timeout: 5_000,
182
+ env: process.env as Record<string, string>,
183
+ },
184
+ );
181
185
  const out = res.stdout?.toString() ?? "";
182
186
  const start = out.indexOf(delim);
183
187
  const end = out.lastIndexOf(delim);
@@ -930,8 +934,37 @@ export async function cmdServe(rest: string[]): Promise<number> {
930
934
  }
931
935
  };
932
936
 
937
+ // Per-agent "waiting on you" detection: the agent is parked on an interactive
938
+ // menu it did NOT auto-resolve (config `needsInput` patterns). Same source and
939
+ // classifier as `ay ls` / `ay status`, so the console's dot matches the CLI's
940
+ // needs_input. Cached per (size, mtime) exactly like logTitle/logTasks — each
941
+ // miss renders a log window through xterm, so the 1s list tick stays cheap on
942
+ // an idle fleet. Returns the pending question text (surfaced in the UI), or
943
+ // null when the agent isn't blocked.
944
+ const niCache = new Map<string, { size: number; mtimeMs: number; question: string | null }>();
945
+ const logNeedsInput = async (
946
+ logFile: string | null | undefined,
947
+ cli: string,
948
+ ): Promise<string | null> => {
949
+ if (!logFile) return null;
950
+ try {
951
+ const { size, mtimeMs } = await stat(logFile);
952
+ const hit = niCache.get(logFile);
953
+ if (hit && hit.size === size && hit.mtimeMs === mtimeMs) return hit.question;
954
+ const ni = await extractNeedsInput(logFile, cli);
955
+ const question = ni?.question ?? null;
956
+ niCache.set(logFile, { size, mtimeMs, question });
957
+ return question;
958
+ } catch {
959
+ return null;
960
+ }
961
+ };
962
+
933
963
  // Per-repo git snapshot for the list (branch + dirty/changed + ahead/behind, from
934
- // one `git status --porcelain --branch`). WATCHER-INVALIDATED, not polled: a read
964
+ // one `git status --porcelain=v2 --branch`). v2 (not v1) so the submodule field
965
+ // lets us split real file changes from submodule pin-drift, which would otherwise
966
+ // inflate `changed` — in a superproject with many submodules the constant gitlink
967
+ // drift buries the real edits. WATCHER-INVALIDATED, not polled: a read
935
968
  // returns the cached snapshot instantly and NEVER spawns `git status` on the
936
969
  // request path. A per-repo-root fs watcher recomputes (debounced) only when the
937
970
  // repo actually changes, so an idle fleet costs ~0 git processes. The old design
@@ -942,7 +975,9 @@ export async function cmdServe(rest: string[]): Promise<number> {
942
975
  interface GitInfo {
943
976
  branch: string | null;
944
977
  dirty: boolean;
945
- changed: number;
978
+ changed: number; // real file changes (excludes submodule pin-bumps & internal dirt)
979
+ pins: number; // submodule gitlinks pointing at new commit(s) — pin-bump/drift
980
+ subDirty: number; // submodule has internal changes but its recorded pin is unchanged
946
981
  ahead: number;
947
982
  behind: number;
948
983
  }
@@ -964,16 +999,55 @@ export async function cmdServe(rest: string[]): Promise<number> {
964
999
  }
965
1000
  };
966
1001
  const parseGitStatus = (out: string): GitInfo => {
967
- const lines = out.split("\n");
968
- // Branch header, e.g. "## main...origin/main [ahead 1, behind 2]", "## main"
969
- // (no upstream), "## HEAD (no branch)", or "## No commits yet on x".
970
- const h = /^## (.+)$/.exec(lines[0] ?? "")?.[1] ?? "";
971
- const unborn = /^No commits yet on (.+)$/.exec(h);
972
- const branch = unborn ? unborn[1]! : /^(.+?)(?:\.\.\.|\s|$)/.exec(h)?.[1] || null;
973
- const ahead = Number(/\bahead (\d+)/.exec(h)?.[1] ?? 0);
974
- const behind = Number(/\bbehind (\d+)/.exec(h)?.[1] ?? 0);
975
- const changed = lines.slice(1).filter((l) => l.trim().length > 0).length;
976
- return { branch, dirty: changed > 0, changed, ahead, behind };
1002
+ // porcelain=v2 --branch: "# branch.*" headers then one line per changed entry.
1003
+ let branch: string | null = null;
1004
+ let ahead = 0;
1005
+ let behind = 0;
1006
+ let changed = 0; // real file changes
1007
+ let pins = 0; // submodule pin-bumps (gitlink new commit)
1008
+ let subDirty = 0; // submodule internal dirt, recorded pin unchanged
1009
+ for (const line of out.split("\n")) {
1010
+ if (line.length === 0) continue;
1011
+ if (line[0] === "#") {
1012
+ // "# branch.head <name|(detached)>" and "# branch.ab +A -B" (ab absent
1013
+ // when there's no upstream). branch.head carries the name even on an
1014
+ // unborn branch, so no special-casing needed.
1015
+ const head = /^# branch\.head (.+)$/.exec(line);
1016
+ if (head) {
1017
+ branch = head[1] === "(detached)" ? null : head[1]!;
1018
+ continue;
1019
+ }
1020
+ const ab = /^# branch\.ab \+(\d+) -(\d+)/.exec(line);
1021
+ if (ab) {
1022
+ ahead = Number(ab[1]);
1023
+ behind = Number(ab[2]);
1024
+ }
1025
+ continue;
1026
+ }
1027
+ // Changed-entry lines. Untracked ("?") and unmerged ("u") are always real
1028
+ // work; ignored ("!") never appears (we don't pass --ignored). For ordinary
1029
+ // ("1") / renamed ("2") entries the 3rd space-delimited token is the
1030
+ // submodule field <sub>: "N..." for a normal path, or "S<c><m><u>" for a
1031
+ // submodule where c=C means its gitlink moved to new commit(s) (pin-bump),
1032
+ // and m/u flag internal dirty/untracked with the recorded pin unchanged.
1033
+ // Paths (which may contain spaces) come after token 2, so counting by token
1034
+ // index is robust without -z.
1035
+ const type = line[0];
1036
+ if (type === "?") {
1037
+ changed++;
1038
+ } else if (type === "u") {
1039
+ changed++;
1040
+ } else if (type === "1" || type === "2") {
1041
+ const sub = line.split(" ")[2] ?? "N...";
1042
+ if (sub[0] === "S") {
1043
+ if (sub[1] === "C") pins++;
1044
+ else subDirty++;
1045
+ } else {
1046
+ changed++;
1047
+ }
1048
+ }
1049
+ }
1050
+ return { branch, dirty: changed > 0, changed, pins, subDirty, ahead, behind };
977
1051
  };
978
1052
  // cwd -> repo root ("" = resolved, not a repo). Resolved once per cwd via a cheap
979
1053
  // `git rev-parse --show-toplevel` (no tree scan) and cached, so many agents in the
@@ -999,7 +1073,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
999
1073
  if (rw.busy) return void recompute(root, rw); // re-arm if one is in flight
1000
1074
  rw.busy = true;
1001
1075
  try {
1002
- const out = await runGit(["status", "--porcelain", "--branch"], root);
1076
+ const out = await runGit(["status", "--porcelain=v2", "--branch"], root);
1003
1077
  if (out != null) rw.val = parseGitStatus(out);
1004
1078
  } finally {
1005
1079
  rw.busy = false;
@@ -1051,12 +1125,23 @@ export async function cmdServe(rest: string[]): Promise<number> {
1051
1125
  // the LIVE status here — same liveness+log-mtime basis as `ay ls` — so the
1052
1126
  // console's dot (and the browser tab glyph) flips to idle in step with `ay ls`.
1053
1127
  const status = await deriveLiveStatus(r);
1128
+ // "Waiting on you": alive, quiet, but parked on an unanswered menu. Checked
1129
+ // only for live agents, and skipped when unresponsive (the Rust wedge signal
1130
+ // wins) — mirroring deriveLiveState's precedence in `ay ls` so the console's
1131
+ // dot and the CLI agree.
1132
+ const question =
1133
+ status !== "exited" && !r.unresponsive ? await logNeedsInput(r.log_file, r.cli) : null;
1054
1134
  return {
1055
1135
  ...r,
1056
- // The Rust supervisor's unresponsive flag is an authoritative wedge signal —
1057
- // surface it as `stuck` so the console's dot matches `ay ls`. (A dead agent
1058
- // is never unresponsiveRust clears the flag on exit.)
1059
- status: status !== "exited" && r.unresponsive ? "stuck" : status,
1136
+ // Precedence: exited stays exited; the Rust supervisor's unresponsive flag is
1137
+ // an authoritative wedge signal (`stuck`); then a blocked menu (`needs_input`);
1138
+ // else the base live status so the console's dot matches `ay ls`. (A dead
1139
+ // agent is never unresponsive Rust clears the flag on exit.)
1140
+ status:
1141
+ status === "exited" ? status : r.unresponsive ? "stuck" : question ? "needs_input" : status,
1142
+ // The pending menu/question text when needs_input, for the console to show
1143
+ // WHAT the agent is waiting on. Null otherwise.
1144
+ question,
1060
1145
  title: await logTitle(r.log_file),
1061
1146
  git: status === "exited" ? null : await gitStatus(r.cwd),
1062
1147
  // Task progress from the rendered todo block (null when none detected → no
@@ -1642,9 +1727,11 @@ export async function cmdServe(rest: string[]): Promise<number> {
1642
1727
  return new Response("fork requires a non-empty fromCwd and branch", { status: 400 });
1643
1728
  const from = typeof body.from === "string" ? body.from.trim() : "";
1644
1729
  if (fork) {
1645
- // Fork the anchor agent's branch (carrying its WIP) into a new sibling
1646
- // worktree via codehost/provision (git worktree off HEAD, no clone), then
1647
- // spawn the agent there.
1730
+ // Fork the anchor agent's branch into a new sibling worktree via
1731
+ // codehost/provision (git worktree off HEAD, no clone), then spawn the
1732
+ // agent there. The fork is clean — committed work only; the source's
1733
+ // uncommitted changes stay put (codehost forkWorktree defaults wip:false,
1734
+ // and we don't opt in).
1648
1735
  let prov: {
1649
1736
  forkWorktree: (o: { fromCwd: string; branch: string; wsRoot?: string }) => Promise<{
1650
1737
  ok: boolean;
@@ -1800,7 +1887,12 @@ export async function cmdServe(rest: string[]): Promise<number> {
1800
1887
  const child = Bun.spawn([shell, "-c", script, "ay-spawn", ...agentArgv], {
1801
1888
  cwd,
1802
1889
  detached: true,
1803
- env: { ...agentEnv, AGENT_YES_CWD: cwd, AGENT_YES_CLI: cli, AGENT_YES_AGENT_ID: agentId },
1890
+ env: {
1891
+ ...agentEnv,
1892
+ AGENT_YES_CWD: cwd,
1893
+ AGENT_YES_CLI: cli,
1894
+ AGENT_YES_AGENT_ID: agentId,
1895
+ },
1804
1896
  stdin: "ignore",
1805
1897
  stdout: "ignore",
1806
1898
  stderr: Bun.file(errPath),
@@ -43,6 +43,18 @@ describe("subcommands.controlCodeFromName", () => {
43
43
  expect(controlCodeFromName("")).toBe("");
44
44
  });
45
45
 
46
+ it("maps navigation keys to their ANSI sequences (for ay key / ay select)", async () => {
47
+ const { controlCodeFromName } = await loadModule();
48
+ expect(controlCodeFromName("up")).toBe("\x1b[A");
49
+ expect(controlCodeFromName("down")).toBe("\x1b[B");
50
+ expect(controlCodeFromName("right")).toBe("\x1b[C");
51
+ expect(controlCodeFromName("left")).toBe("\x1b[D");
52
+ expect(controlCodeFromName("space")).toBe(" ");
53
+ expect(controlCodeFromName("backspace")).toBe("\x7f");
54
+ expect(controlCodeFromName("pageup")).toBe("\x1b[5~");
55
+ expect(controlCodeFromName("pagedown")).toBe("\x1b[6~");
56
+ });
57
+
46
58
  it("supports raw:0xNN escape", async () => {
47
59
  const { controlCodeFromName } = await loadModule();
48
60
  expect(controlCodeFromName("raw:0x03")).toBe("\x03");
@@ -51,7 +63,27 @@ describe("subcommands.controlCodeFromName", () => {
51
63
 
52
64
  it("throws on unknown code names", async () => {
53
65
  const { controlCodeFromName } = await loadModule();
54
- expect(() => controlCodeFromName("nope")).toThrow(/unknown --code/);
66
+ expect(() => controlCodeFromName("nope")).toThrow(/unknown key\/code/);
67
+ });
68
+ });
69
+
70
+ describe("subcommands.menuSelectKeys (ay select cursor arithmetic)", () => {
71
+ it("sends Downs + Enter to reach an option below the cursor", async () => {
72
+ const { menuSelectKeys } = await loadModule();
73
+ expect(menuSelectKeys(1, 3)).toEqual(["down", "down", "enter"]);
74
+ });
75
+ it("sends Ups + Enter to reach an option above the cursor", async () => {
76
+ const { menuSelectKeys } = await loadModule();
77
+ expect(menuSelectKeys(3, 1)).toEqual(["up", "up", "enter"]);
78
+ });
79
+ it("sends only Enter when the cursor already sits on the target", async () => {
80
+ const { menuSelectKeys } = await loadModule();
81
+ expect(menuSelectKeys(2, 2)).toEqual(["enter"]);
82
+ });
83
+ it("encodes to the exact ANSI byte stream via controlCodeFromName", async () => {
84
+ const { menuSelectKeys, controlCodeFromName } = await loadModule();
85
+ const bytes = menuSelectKeys(2, 4).map((k) => controlCodeFromName(k));
86
+ expect(bytes.join("")).toBe("\x1b[B\x1b[B\r"); // down, down, enter
55
87
  });
56
88
  });
57
89