Skip to content

Commit 3a403f0

Browse files
authored
Merge pull request #165 from compoundingtech/schickling/2026-08-15-pty-send
Accept common key chord notations
2 parents cb9a340 + 3ec1b33 commit 3a403f0

12 files changed

Lines changed: 191 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
## Unreleased
44

5+
### Key input notation
6+
7+
- Named key input is case-insensitive and accepts `+`, `-`, or `_` modifier
8+
separators plus compact `C-` Control notation, so `ctrl+u`, `ctrl-u`,
9+
`ctrl_u`, and `C-u` are equivalent. Invalid or incomplete key specs now name
10+
the supported forms, modifiers, and keys while preserving up-front atomic
11+
validation. (closes #164)
12+
513
### Storage format
614

715
- Supporting live daemons now advertise a `recovery` capability in session

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ pty peek -f myserver # follow output read-only
9090
pty send myserver "hello" # send text (no implicit newline)
9191
pty send myserver $'hello\n' # send text with newline (shell syntax)
9292
pty send myserver --seq "git status" --seq key:return # ordered sequence
93-
pty send myserver --seq key:ctrl+c # send control keys
93+
pty send myserver --seq key:ctrl+c # also: ctrl-c, ctrl_c, C-c
9494
pty send myserver --paste "$(cat prompt.md)" # wrap as bracketed paste
9595

9696
pty stats # live metrics for all sessions

SKILL.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ pty kill <name> # clean up when done
4141
```
4242
Tag the sessions you create; only touch sessions you created.
4343

44+
Key modifiers accept `+`, `-`, or `_` separators and ignore case. For example,
45+
`key:ctrl+u`, `key:ctrl-u`, `key:ctrl_u`, and readline-style `key:C-u` are
46+
equivalent.
47+
4448
## Footguns (the ones that actually bite)
4549
- **A broken global `pty` on `$PATH` silently breaks the whole message bus.**
4650
`st` / smalltalk delivery shells out to `pty send` found on `$PATH`. If a

docs/client.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -497,7 +497,11 @@ Resolve a key name to its byte sequence. Supports:
497497
- Named keys: `return`, `tab`, `escape`, `space`, `backspace`, `delete`
498498
- Arrows: `up`, `down`, `left`, `right`
499499
- Navigation: `home`, `end`, `pageup`, `pagedown`
500-
- Modifiers: `ctrl+c`, `alt+x`, `shift+a`
500+
- Modifiers: `ctrl+c`, `ctrl-c`, `ctrl_c`, `C-c`, `alt+x`, `shift+a`
501+
502+
Key names and modifiers are case-insensitive. Modifier chords accept `+`, `-`,
503+
or `_` separators; compact `C-` is accepted for Control. Invalid key specs
504+
report the accepted notation, modifiers, and named keys.
501505

502506
### `parseSeqValue(value: string): string`
503507

docs/testing.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ await session.close();
9696
9797
### press(keyName)
9898
99-
Send a named key. Supports modifiers with `+`:
99+
Send a named key. Names are case-insensitive; modifier chords accept `+`, `-`,
100+
or `_`, and compact `C-` means Control:
100101
101102
```typescript test
102103
import { Session } from "@compoundingtech/pty/testing";
@@ -204,9 +205,9 @@ The `press()` method accepts these key names:
204205
| Page Up | `pageup` |
205206
| Page Down | `pagedown` |
206207
207-
Modifiers: `ctrl+`, `alt+`, `shift+`
208+
Modifiers: `ctrl`, `alt`, `shift`; use `+`, `-`, or `_` as the separator.
208209
209-
Examples: `ctrl+c`, `ctrl+z`, `alt+x`, `shift+a`, `ctrl+backspace`
210+
Examples: `ctrl+c`, `ctrl-c`, `ctrl_c`, `C-c`, `alt+x`, `shift+a`, `ctrl+backspace`
210211
211212
## Patterns
212213

docs/vrs/requirements.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,8 @@ implementation contract and validation map live in [spec.md](./spec.md).
108108
it never removes a live or replacement generation. Semantic outcomes and
109109
operational failures are machine-distinguishable, and validation covers the
110110
snapshot-to-cleanup race with real processes.
111+
- **R13 Discoverable key notation:** Supported key-input surfaces resolve named
112+
keys case-insensitively and accept unambiguous modifier chords using `+`,
113+
`-`, or `_` separators, including compact `C-` control notation. Invalid,
114+
incomplete, or ambiguous key specs fail before any sequence bytes are sent;
115+
their diagnostics state the accepted modifiers, notation, and key names.

docs/vrs/spec.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,34 @@ packet order and fails explicitly when the peer lacks a capability. The testing
318318
library drives real processes and PTYs and exposes screen, cursor, scrollback,
319319
input, resize, and multi-client geometry without mocks.
320320
321+
### Key specifications
322+
323+
```text
324+
input spelling -> case fold -> exact named key / modifier chord -> bytes
325+
|
326+
+-> reject invalid or ambiguous input
327+
before opening the send connection
328+
```
329+
330+
The shared key resolver used by CLI, client, and testing surfaces accepts these
331+
equivalent, case-insensitive modifier spellings (R11, R13):
332+
333+
| Spelling | Interpretation | Canonical diagnostic form |
334+
| --- | --- | --- |
335+
| `ctrl+u` | full modifier with `+` | `ctrl+u` |
336+
| `ctrl-u` | full modifier with `-` | `ctrl+u` |
337+
| `ctrl_u` | full modifier with `_` | `ctrl+u` |
338+
| `C-u` | compact control notation | `ctrl+u` |
339+
340+
Separators may compose multiple full modifiers, such as `ctrl-alt-delete`.
341+
`C-` is the only compact modifier alias; `C+`, `M-`, and `S-` do not silently
342+
acquire meanings. A spelling that could denote both an exact named key and a
343+
modifier chord is ambiguous and rejected. Diagnostics for incomplete specs,
344+
unknown modifiers, and unknown keys include the accepted forms; unknown-key
345+
diagnostics also enumerate the supported named keys. `send --seq` resolves all
346+
key items before connecting, so one invalid item prevents every item in that
347+
invocation from being delivered.
348+
321349
## Ownership and validation matrix
322350
323351
| Requirement | Owning source | Primary executable evidence |
@@ -334,6 +362,7 @@ input, resize, and multi-client geometry without mocks.
334362
| 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) |
335363
| 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) |
336364
| 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) |
365+
| 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) |
337366
338367
`node scripts/verify-docs.ts --vrs-only` validates this two-document shape,
339368
sequential requirement IDs, links, and complete requirement references.

src/cli.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,8 @@ to send text followed by Enter, use --seq (see the second example).
189189
190190
Flags:
191191
--seq <value> Ordered chunk or key event (repeatable). key:<name> sends a
192-
key, e.g. key:return, key:ctrl+c, key:tab
192+
key, e.g. key:return, key:ctrl+c, key:ctrl-c, key:C-c.
193+
Modifiers also accept _ separators; names ignore case.
193194
--with-delay <sec> Delay (seconds) between --seq items. DEFAULT 0.3s so a
194195
trailing key:return doesn't race ahead of the program
195196
parsing the text. --with-delay 0 = straight stream (no gap).

src/keys.ts

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ const KEY_MAP: Record<string, string> = {
1818
};
1919

2020
const MODIFIERS = new Set(["ctrl", "alt", "shift"]);
21+
const MODIFIER_SEPARATORS = /[+_-]/;
22+
const NAMED_KEYS = Object.keys(KEY_MAP).sort().join(", ");
23+
const KEY_SPEC_HELP =
24+
`Use ctrl+u, ctrl-u, ctrl_u, or C-u; supported modifiers are ctrl, alt, and shift; ` +
25+
`supported keys are a-z, ${NAMED_KEYS}.`;
2126

2227
/** Keycodes for CSI u encoding (Kitty keyboard protocol). */
2328
const CSI_U_KEYCODES: Record<string, number> = {
@@ -40,16 +45,57 @@ function modifierParam(mods: Set<string>): number {
4045
);
4146
}
4247

43-
/** Parse a key spec like `ctrl+c`, `return`, `alt+x` into bytes. */
48+
function normalizeModifier(mod: string, index: number, spec: string): string {
49+
// Readline/tmux-style C-u is the established compact spelling for ctrl+u.
50+
// Keep the one-letter alias scoped to a leading C- so C+u and other
51+
// abbreviated modifier alphabets do not acquire surprise meaning.
52+
if (mod === "c" && index === 0 && /^c-/i.test(spec)) return "ctrl";
53+
return mod;
54+
}
55+
56+
function isSupportedBase(base: string): boolean {
57+
return KEY_MAP[base] !== undefined || (base.length === 1 && base >= "a" && base <= "z");
58+
}
59+
60+
/** Parse a key spec like `ctrl+c`, `ctrl-c`, `C-c`, `return`, or `alt+x` into bytes. */
4461
export function resolveKey(spec: string): string {
45-
const parts = spec.toLowerCase().split("+");
62+
const normalized = spec.toLowerCase();
63+
const hasSeparator = MODIFIER_SEPARATORS.test(normalized);
64+
const rawParts = hasSeparator ? normalized.split(MODIFIER_SEPARATORS) : [normalized];
65+
const rawBase = rawParts.at(-1) ?? "";
66+
const rawMods = rawParts.slice(0, -1).map((mod, index) =>
67+
normalizeModifier(mod, index, spec),
68+
);
69+
70+
// A separator-bearing name could be both a named key and a modifier chord.
71+
// Refuse that collision instead of silently changing meaning if the key map
72+
// ever grows such a name.
73+
const isValidChord =
74+
rawBase !== "" &&
75+
rawMods.length > 0 &&
76+
rawMods.every((mod) => mod !== "" && MODIFIERS.has(mod)) &&
77+
isSupportedBase(rawBase);
78+
if (hasSeparator && KEY_MAP[normalized] !== undefined && isValidChord) {
79+
throw new Error(
80+
`Ambiguous key spec "${spec}": it is both a named key and a modifier chord. ${KEY_SPEC_HELP}`,
81+
);
82+
}
83+
if (KEY_MAP[normalized] !== undefined && !isValidChord) return KEY_MAP[normalized];
84+
85+
const parts = rawParts;
4686
const base = parts.pop()!;
47-
const mods = new Set(parts);
87+
if (base === "" || parts.some((part) => part === "")) {
88+
throw new Error(`Incomplete key spec "${spec}". ${KEY_SPEC_HELP}`);
89+
}
90+
91+
const mods = new Set(parts.map((mod, index) => normalizeModifier(mod, index, spec)));
4892

4993
// Validate modifiers
5094
for (const mod of mods) {
5195
if (!MODIFIERS.has(mod)) {
52-
throw new Error(`Unknown modifier: "${mod}" in key spec "${spec}"`);
96+
throw new Error(
97+
`Unknown modifier: "${mod}" in key spec "${spec}". ${KEY_SPEC_HELP}`,
98+
);
5399
}
54100
}
55101

@@ -58,7 +104,10 @@ export function resolveKey(spec: string): string {
58104
const mapped = KEY_MAP[base];
59105

60106
if (mapped === undefined && !isLetter) {
61-
throw new Error(`Unknown key: "${base}" in key spec "${spec}"`);
107+
throw new Error(
108+
`Unknown key: "${base}" in key spec "${spec}". ` +
109+
KEY_SPEC_HELP,
110+
);
62111
}
63112

64113
// Single letter keys

tests/help.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@ describe("pty --help — per-subcommand help", () => {
7171
});
7272

7373
describe("pty --help — no drift", () => {
74+
it("documents accepted key modifier notations", () => {
75+
const r = help("send");
76+
expect(r.status).toBe(0);
77+
expect(r.stdout).toContain("key:ctrl+c");
78+
expect(r.stdout).toContain("key:ctrl-c");
79+
expect(r.stdout).toContain("key:C-c");
80+
expect(r.stdout).toContain("_ separators");
81+
});
82+
7483
it("documents the repeatable persisted environment overlay", () => {
7584
const r = help("run");
7685
expect(r.status).toBe(0);

0 commit comments

Comments
 (0)