Hmbown/CodeWhale · error · ExecError

multiple non-modifier keys in

Error message

multiple non-modifier keys in "${text}"

What it means

A chord may contain any number of modifiers plus exactly one non-modifier key. parseChord throws when it encounters a second non-modifier key name, because a physical key event can press only one main key at a time.

Solutions

  1. Send each key press as a separate call: press('a') then press('b')
  2. Keep at most one non-modifier key per chord: 'cmd+c', not 'cmd+c+v'
  3. Normalize/split multi-key input at the call site before invoking the backend

Example fix

// before
await press("cmd+c+v");
// after
await press("cmd+c");
await press("cmd+v");
Defensive patterns

Strategy: validation

Validate before calling

const MODS = new Set(["cmd", "ctrl", "alt", "shift", "fn"]);
const nonMods = key.split("+").map(s => s.trim().toLowerCase()).filter(p => p && !MODS.has(p));
if (nonMods.length > 1) throw new Error(`at most one non-modifier key, got: ${nonMods.join(", ")}`);

Try / catch

try {
  await press(text);
} catch (e) {
  if (e instanceof ExecError && e.message.startsWith("multiple non-modifier keys")) {
    for (const k of text.split("+")) await press(k.trim());
  } else throw e;
}

Prevention

When it happens

Trigger: Passing text like 'cmd+a+b', 'a+b', or a space-separated pair that still splits on '+' into two KEY_CODES names.

Common situations: Caller intends 'press a, then b' but formats it as a chord; LLM-generated key strings joining two keys; misunderstanding that '+' sequences mean sequential presses.

Understand the failure class

Background: "Invalid ... format", "must be in format X", "does not look like a ..." — invalid argument format errors across CLI tools and libraries — this error's family across 17 libraries.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22). Data as JSON: /api/errors/88c1557d70bed307. Report an issue: GitHub.

Appendix: source

Thrown at crates/tui/plugins/computer-use/src/backends/darwin.mjs:492

    const lease = await nativeLease("key_event", { code, flags, down: true });
    try {
      await action();
      // The acknowledgement carries the yield_ms the helper waited for a
      // hardware-input gap before posting the press.
      return lease.receipt;
    } finally {
      await withSignal(null, () => lease.release());
    }
  }

  function parseChord(text) {
    const parts = String(text).split("+").map((s) => s.trim().toLowerCase()).filter(Boolean);
    if (!parts.length) throw new ExecError("empty key text");
    let flags = 0;
    let key = null;
    for (const p of parts) {
      if (MODIFIERS[p] != null) flags |= MODIFIERS[p];
      else if (KEY_CODES[p] != null) { if (key) throw new ExecError(`multiple non-modifier keys in "${text}"`); key = p; }
      else throw new ExecError(`unknown key "${p}" (supported: ${Object.keys(KEY_CODES).join(", ")} + modifiers cmd/ctrl/alt/shift/fn)`);
    }
    if (key == null) throw new ExecError(`no non-modifier key in "${text}"`);
    return { flags, code: KEY_CODES[key], key };
  }

  // ---------- displays ----------
  async function displayInfo() { return native("displays"); }

  // ---------- screenshots ----------
  function recordingsDir() {
    return process.env.CODEWHALE_CU_RECORDINGS_DIR || path.join(os.homedir(), ".codewhale-cu", "recordings");
  }

  async function screenshot({ display, region, app_ref, window_id, path: outPath } = {}) {
    // Once an app is selected, ordinary observations follow it behind the
    // user's work. An explicit display/region remains a deliberate desktop capture.
    if (app_ref === undefined && display === undefined && region === undefined) app_ref = state.inputApp ?? undefined;

View on GitHub (pinned to 73e0f67d83)