Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ pty kill <name> # 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
Expand Down
6 changes: 5 additions & 1 deletion docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
7 changes: 4 additions & 3 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions docs/vrs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
29 changes: 29 additions & 0 deletions docs/vrs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.
3 changes: 2 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,8 @@ to send text followed by Enter, use --seq (see the second example).

Flags:
--seq <value> Ordered chunk or key event (repeatable). key:<name> 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 <sec> 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).
Expand Down
59 changes: 54 additions & 5 deletions src/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ const KEY_MAP: Record<string, string> = {
};

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<string, number> = {
Expand All @@ -40,16 +45,57 @@ function modifierParam(mods: Set<string>): 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}`,
);
}
}

Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions tests/help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
23 changes: 19 additions & 4 deletions tests/keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -106,20 +114,27 @@ 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);
});
});

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");
});

Expand Down
51 changes: 51 additions & 0 deletions tests/send-paste.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading