diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e48992..56d17bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### Key input notation + +- Named key input is case-insensitive and accepts `+`, `-`, or `_` modifier + separators plus compact `C-` Control notation, so `ctrl+u`, `ctrl-u`, + `ctrl_u`, and `C-u` are equivalent. Invalid or incomplete key specs now name + the supported forms, modifiers, and keys while preserving up-front atomic + validation. (closes #164) + ### Storage format - Supporting live daemons now advertise a `recovery` capability in session diff --git a/README.md b/README.md index 093b1a6..d842070 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ pty peek -f myserver # follow output read-only pty send myserver "hello" # send text (no implicit newline) pty send myserver $'hello\n' # send text with newline (shell syntax) pty send myserver --seq "git status" --seq key:return # ordered sequence -pty send myserver --seq key:ctrl+c # send control keys +pty send myserver --seq key:ctrl+c # also: ctrl-c, ctrl_c, C-c pty send myserver --paste "$(cat prompt.md)" # wrap as bracketed paste pty stats # live metrics for all sessions diff --git a/SKILL.md b/SKILL.md index 03e0216..decf044 100644 --- a/SKILL.md +++ b/SKILL.md @@ -41,6 +41,10 @@ pty kill # clean up when done ``` Tag the sessions you create; only touch sessions you created. +Key modifiers accept `+`, `-`, or `_` separators and ignore case. For example, +`key:ctrl+u`, `key:ctrl-u`, `key:ctrl_u`, and readline-style `key:C-u` are +equivalent. + ## Footguns (the ones that actually bite) - **A broken global `pty` on `$PATH` silently breaks the whole message bus.** `st` / smalltalk delivery shells out to `pty send` found on `$PATH`. If a diff --git a/docs/client.md b/docs/client.md index f686d85..4030aa3 100644 --- a/docs/client.md +++ b/docs/client.md @@ -497,7 +497,11 @@ Resolve a key name to its byte sequence. Supports: - Named keys: `return`, `tab`, `escape`, `space`, `backspace`, `delete` - Arrows: `up`, `down`, `left`, `right` - Navigation: `home`, `end`, `pageup`, `pagedown` -- Modifiers: `ctrl+c`, `alt+x`, `shift+a` +- Modifiers: `ctrl+c`, `ctrl-c`, `ctrl_c`, `C-c`, `alt+x`, `shift+a` + +Key names and modifiers are case-insensitive. Modifier chords accept `+`, `-`, +or `_` separators; compact `C-` is accepted for Control. Invalid key specs +report the accepted notation, modifiers, and named keys. ### `parseSeqValue(value: string): string` diff --git a/docs/testing.md b/docs/testing.md index 7acfa88..c730ff2 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -96,7 +96,8 @@ await session.close(); ### press(keyName) -Send a named key. Supports modifiers with `+`: +Send a named key. Names are case-insensitive; modifier chords accept `+`, `-`, +or `_`, and compact `C-` means Control: ```typescript test import { Session } from "@compoundingtech/pty/testing"; @@ -204,9 +205,9 @@ The `press()` method accepts these key names: | Page Up | `pageup` | | Page Down | `pagedown` | -Modifiers: `ctrl+`, `alt+`, `shift+` +Modifiers: `ctrl`, `alt`, `shift`; use `+`, `-`, or `_` as the separator. -Examples: `ctrl+c`, `ctrl+z`, `alt+x`, `shift+a`, `ctrl+backspace` +Examples: `ctrl+c`, `ctrl-c`, `ctrl_c`, `C-c`, `alt+x`, `shift+a`, `ctrl+backspace` ## Patterns diff --git a/docs/vrs/requirements.md b/docs/vrs/requirements.md index e61ef13..77aa285 100644 --- a/docs/vrs/requirements.md +++ b/docs/vrs/requirements.md @@ -108,3 +108,8 @@ implementation contract and validation map live in [spec.md](./spec.md). it never removes a live or replacement generation. Semantic outcomes and operational failures are machine-distinguishable, and validation covers the snapshot-to-cleanup race with real processes. +- **R13 Discoverable key notation:** Supported key-input surfaces resolve named + keys case-insensitively and accept unambiguous modifier chords using `+`, + `-`, or `_` separators, including compact `C-` control notation. Invalid, + incomplete, or ambiguous key specs fail before any sequence bytes are sent; + their diagnostics state the accepted modifiers, notation, and key names. diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index fe51985..90e0764 100644 --- a/docs/vrs/spec.md +++ b/docs/vrs/spec.md @@ -318,6 +318,34 @@ packet order and fails explicitly when the peer lacks a capability. The testing library drives real processes and PTYs and exposes screen, cursor, scrollback, input, resize, and multi-client geometry without mocks. +### Key specifications + +```text +input spelling -> case fold -> exact named key / modifier chord -> bytes + | + +-> reject invalid or ambiguous input + before opening the send connection +``` + +The shared key resolver used by CLI, client, and testing surfaces accepts these +equivalent, case-insensitive modifier spellings (R11, R13): + +| Spelling | Interpretation | Canonical diagnostic form | +| --- | --- | --- | +| `ctrl+u` | full modifier with `+` | `ctrl+u` | +| `ctrl-u` | full modifier with `-` | `ctrl+u` | +| `ctrl_u` | full modifier with `_` | `ctrl+u` | +| `C-u` | compact control notation | `ctrl+u` | + +Separators may compose multiple full modifiers, such as `ctrl-alt-delete`. +`C-` is the only compact modifier alias; `C+`, `M-`, and `S-` do not silently +acquire meanings. A spelling that could denote both an exact named key and a +modifier chord is ambiguous and rejected. Diagnostics for incomplete specs, +unknown modifiers, and unknown keys include the accepted forms; unknown-key +diagnostics also enumerate the supported named keys. `send --seq` resolves all +key items before connecting, so one invalid item prevents every item in that +invocation from being delivered. + ## Ownership and validation matrix | Requirement | Owning source | Primary executable evidence | @@ -334,6 +362,7 @@ input, resize, and multi-client geometry without mocks. | R10 | [sessions](../../src/sessions.ts), [events](../../src/events.ts), [recovery](../../src/recovery.ts), [protocol](../../src/protocol.ts) | [atomic writes](../../tests/atomic-writes.test.ts), [metadata events](../../tests/metadata-events.test.ts), [events](../../tests/events.test.ts), [recovery](../../tests/recovery.test.ts), [disk layout](../../tests/disk-layout-docs.test.ts) | | R11 | [CLI](../../src/cli.ts), [client API](../../src/client-api.ts), [remote](../../src/remote.ts), [testing API](../../src/testing/index.ts) | [help](../../tests/help.test.ts), [completions](../../tests/completions.test.ts), [remote](../../tests/remote-fabric.test.ts), [screenshots](../../tests/screenshot.test.ts), [keys](../../tests/keys.test.ts) | | R12 | [sessions](../../src/sessions.ts), [server](../../src/server.ts), [client API](../../src/client-api.ts), [CLI](../../src/cli.ts), [completions](../../src/completions.ts) | [exit evidence](../../tests/exit-reap.test.ts), [generation guard](../../tests/gc-generation-guard.test.ts), [immediate reuse](../../tests/rm-immediate-reuse.test.ts), [help](../../tests/help.test.ts), [completions](../../tests/completions.test.ts), [security](../../tests/security-fixes.test.ts) | +| R13 | [keys](../../src/keys.ts), [CLI](../../src/cli.ts) | [keys](../../tests/keys.test.ts), [send CLI](../../tests/send-paste.test.ts), [help](../../tests/help.test.ts) | `node scripts/verify-docs.ts --vrs-only` validates this two-document shape, sequential requirement IDs, links, and complete requirement references. diff --git a/src/cli.ts b/src/cli.ts index 46a187f..d4c0ef0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -189,7 +189,8 @@ to send text followed by Enter, use --seq (see the second example). Flags: --seq Ordered chunk or key event (repeatable). key: sends a - key, e.g. key:return, key:ctrl+c, key:tab + key, e.g. key:return, key:ctrl+c, key:ctrl-c, key:C-c. + Modifiers also accept _ separators; names ignore case. --with-delay Delay (seconds) between --seq items. DEFAULT 0.3s so a trailing key:return doesn't race ahead of the program parsing the text. --with-delay 0 = straight stream (no gap). diff --git a/src/keys.ts b/src/keys.ts index 94718a0..c4fe7a4 100644 --- a/src/keys.ts +++ b/src/keys.ts @@ -18,6 +18,11 @@ const KEY_MAP: Record = { }; const MODIFIERS = new Set(["ctrl", "alt", "shift"]); +const MODIFIER_SEPARATORS = /[+_-]/; +const NAMED_KEYS = Object.keys(KEY_MAP).sort().join(", "); +const KEY_SPEC_HELP = + `Use ctrl+u, ctrl-u, ctrl_u, or C-u; supported modifiers are ctrl, alt, and shift; ` + + `supported keys are a-z, ${NAMED_KEYS}.`; /** Keycodes for CSI u encoding (Kitty keyboard protocol). */ const CSI_U_KEYCODES: Record = { @@ -40,16 +45,57 @@ function modifierParam(mods: Set): number { ); } -/** Parse a key spec like `ctrl+c`, `return`, `alt+x` into bytes. */ +function normalizeModifier(mod: string, index: number, spec: string): string { + // Readline/tmux-style C-u is the established compact spelling for ctrl+u. + // Keep the one-letter alias scoped to a leading C- so C+u and other + // abbreviated modifier alphabets do not acquire surprise meaning. + if (mod === "c" && index === 0 && /^c-/i.test(spec)) return "ctrl"; + return mod; +} + +function isSupportedBase(base: string): boolean { + return KEY_MAP[base] !== undefined || (base.length === 1 && base >= "a" && base <= "z"); +} + +/** Parse a key spec like `ctrl+c`, `ctrl-c`, `C-c`, `return`, or `alt+x` into bytes. */ export function resolveKey(spec: string): string { - const parts = spec.toLowerCase().split("+"); + const normalized = spec.toLowerCase(); + const hasSeparator = MODIFIER_SEPARATORS.test(normalized); + const rawParts = hasSeparator ? normalized.split(MODIFIER_SEPARATORS) : [normalized]; + const rawBase = rawParts.at(-1) ?? ""; + const rawMods = rawParts.slice(0, -1).map((mod, index) => + normalizeModifier(mod, index, spec), + ); + + // A separator-bearing name could be both a named key and a modifier chord. + // Refuse that collision instead of silently changing meaning if the key map + // ever grows such a name. + const isValidChord = + rawBase !== "" && + rawMods.length > 0 && + rawMods.every((mod) => mod !== "" && MODIFIERS.has(mod)) && + isSupportedBase(rawBase); + if (hasSeparator && KEY_MAP[normalized] !== undefined && isValidChord) { + throw new Error( + `Ambiguous key spec "${spec}": it is both a named key and a modifier chord. ${KEY_SPEC_HELP}`, + ); + } + if (KEY_MAP[normalized] !== undefined && !isValidChord) return KEY_MAP[normalized]; + + const parts = rawParts; const base = parts.pop()!; - const mods = new Set(parts); + if (base === "" || parts.some((part) => part === "")) { + throw new Error(`Incomplete key spec "${spec}". ${KEY_SPEC_HELP}`); + } + + const mods = new Set(parts.map((mod, index) => normalizeModifier(mod, index, spec))); // Validate modifiers for (const mod of mods) { if (!MODIFIERS.has(mod)) { - throw new Error(`Unknown modifier: "${mod}" in key spec "${spec}"`); + throw new Error( + `Unknown modifier: "${mod}" in key spec "${spec}". ${KEY_SPEC_HELP}`, + ); } } @@ -58,7 +104,10 @@ export function resolveKey(spec: string): string { const mapped = KEY_MAP[base]; if (mapped === undefined && !isLetter) { - throw new Error(`Unknown key: "${base}" in key spec "${spec}"`); + throw new Error( + `Unknown key: "${base}" in key spec "${spec}". ` + + KEY_SPEC_HELP, + ); } // Single letter keys diff --git a/tests/help.test.ts b/tests/help.test.ts index 7eea65d..d9ce8da 100644 --- a/tests/help.test.ts +++ b/tests/help.test.ts @@ -71,6 +71,15 @@ describe("pty --help — per-subcommand help", () => { }); describe("pty --help — no drift", () => { + it("documents accepted key modifier notations", () => { + const r = help("send"); + expect(r.status).toBe(0); + expect(r.stdout).toContain("key:ctrl+c"); + expect(r.stdout).toContain("key:ctrl-c"); + expect(r.stdout).toContain("key:C-c"); + expect(r.stdout).toContain("_ separators"); + }); + it("documents the repeatable persisted environment overlay", () => { const r = help("run"); expect(r.status).toBe(0); diff --git a/tests/keys.test.ts b/tests/keys.test.ts index 10f3a20..ec03334 100644 --- a/tests/keys.test.ts +++ b/tests/keys.test.ts @@ -34,6 +34,14 @@ describe("resolveKey", () => { expect(resolveKey("ctrl+d")).toBe("\x04"); }); + it("accepts common unambiguous modifier notations", () => { + for (const spec of ["ctrl+u", "ctrl-u", "ctrl_u", "C-u", "c-u"]) { + expect(resolveKey(spec), spec).toBe("\x15"); + } + expect(resolveKey("ctrl-alt-delete")).toBe("\x1b[3;7~"); + expect(resolveKey("ctrl_alt+shift_up")).toBe("\x1b[1;8A"); + }); + it("resolves alt chords", () => { expect(resolveKey("alt+x")).toBe("\x1bx"); expect(resolveKey("alt+a")).toBe("\x1ba"); @@ -106,13 +114,18 @@ describe("resolveKey", () => { }); it("throws on unknown key", () => { - expect(() => resolveKey("f99")).toThrow(/Unknown key/); - expect(() => resolveKey("nonexistent")).toThrow(/Unknown key/); + expect(() => resolveKey("f99")).toThrow(/Unknown key.*ctrl\+u.*supported keys/is); + expect(() => resolveKey("nonexistent")).toThrow(/Unknown key.*supported keys/is); }); it("throws on unknown modifier", () => { - expect(() => resolveKey("super+c")).toThrow(/Unknown modifier/); - expect(() => resolveKey("meta+x")).toThrow(/Unknown modifier/); + expect(() => resolveKey("super+c")).toThrow(/Unknown modifier.*ctrl, alt, and shift/s); + expect(() => resolveKey("meta+x")).toThrow(/Unknown modifier.*ctrl\+u/s); + }); + + it("rejects incomplete or unsupported compact forms with actionable help", () => { + expect(() => resolveKey("ctrl-")).toThrow(/Incomplete key spec.*ctrl-u.*supported keys/is); + expect(() => resolveKey("C+u")).toThrow(/Unknown modifier.*C-u.*supported keys/is); }); }); @@ -120,6 +133,8 @@ describe("parseSeqValue", () => { it("resolves key: prefixed values", () => { expect(parseSeqValue("key:return")).toBe("\r"); expect(parseSeqValue("key:ctrl+c")).toBe("\x03"); + expect(parseSeqValue("key:ctrl-c")).toBe("\x03"); + expect(parseSeqValue("key:C-c")).toBe("\x03"); expect(parseSeqValue("key:tab")).toBe("\t"); }); diff --git a/tests/send-paste.test.ts b/tests/send-paste.test.ts index 3832302..26612d1 100644 --- a/tests/send-paste.test.ts +++ b/tests/send-paste.test.ts @@ -262,3 +262,54 @@ describe("pty send strict flag parsing (#20)", () => { expect(received).toBe("still-works"); }, 15_000); }); + +describe("pty send key notation (#164)", () => { + it("delivers the common control-key spellings as equivalent bytes", async () => { + const dir = makeSessionDir(); + const name = uniqueName(); + const dump = path.join(dir, "dump.bin"); + await startDumpSession(dir, name, dump); + + const r = runCli( + dir, + "send", + name, + "--with-delay", + "0", + "--seq", + "key:ctrl+u", + "--seq", + "key:ctrl-u", + "--seq", + "key:ctrl_u", + "--seq", + "key:C-u", + ); + expect(r.status, r.stderr).toBe(0); + expect(await waitForDump(dump, 4, 3000)).toBe("\x15\x15\x15\x15"); + }, 15_000); + + it("validates the whole sequence before delivering an earlier chunk", async () => { + const dir = makeSessionDir(); + const name = uniqueName(); + const dump = path.join(dir, "dump.bin"); + await startDumpSession(dir, name, dump); + + const r = runCli( + dir, + "send", + name, + "--with-delay", + "0", + "--seq", + "PARTIAL", + "--seq", + "key:ctrl-", + "--seq", + "AFTER", + ); + expect(r.status).not.toBe(0); + expect(r.stderr).toMatch(/Incomplete key spec.*ctrl-u.*supported keys/is); + expect(await waitForDump(dump, 1, 500)).toBe(""); + }, 15_000); +});