From 9c6222d7422fcdab0b8962a9ca0ac903aa9a4221 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Sat, 24 Oct 2026 02:03:13 +0800 Subject: [PATCH] core/tools: read keys as markers in write_pty_keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grammar had grown three notations for the same thing — a backslash escape (`\e`, `\t`, `\x1b`), a `ctrl+x` name, and a `key=esc|up|…` marker — so ESC had five spellings and code and docs already disagreed on one of them (`key=escape`). `key=` also looked uncomposable while being the only way to send a key twice (`key=upkey=up`), and anything unrecognised was written to the terminal verbatim: `key=hme` typed those seven characters into a shell prompt, in a tool that never prompts. Markers are now one form — ``, `<<` for a literal `<` — with named keys through ``, Ctrl and Alt as `` / ``, `s-tab` for the reverse Tab every TUI uses, and `` to repeat. The name table lives in one place, and an unknown or badly repeated marker raises instead of being typed: a typo is an error the model can fix, not bytes the human has to notice. `ctrl+` and `key=` are gone rather than kept as aliases, so nothing has two spellings again. --- src/plyngent/tools/process/pty_terminal.py | 144 ++++++++++++++++--- src/plyngent/tools/process/write_pty.py | 2 +- src/plyngent/tools/process/write_pty_keys.py | 26 ++-- 3 files changed, 140 insertions(+), 32 deletions(-) diff --git a/src/plyngent/tools/process/pty_terminal.py b/src/plyngent/tools/process/pty_terminal.py index 3e0a6e6..712e78f 100644 --- a/src/plyngent/tools/process/pty_terminal.py +++ b/src/plyngent/tools/process/pty_terminal.py @@ -10,11 +10,9 @@ from __future__ import annotations import re -# Match common \xNN / \uNNNN / named ctrl+ / esc sequences in write_pty_keys data. +# Escapes and ```` key markers in write_pty_keys data. _HEX_BYTE = re.compile(r"\\x([0-9A-Fa-f]{2})") _HEX_U = re.compile(r"\\u([0-9A-Fa-f]{4})") -_CTRL = re.compile(r"ctrl\+([a-z@\[\\\]\^_])", re.IGNORECASE) -_KEY = re.compile(r"key=(esc|escape|enter|tab|up|down|left|right)", re.IGNORECASE) _SIMPLE = { r"\n": "\n", r"\r": "\r", @@ -23,18 +21,49 @@ _SIMPLE = { r"\E": "\x1b", r"\0": "\x00", } +_SIMPLE_ESCAPE_LEN = 2 -_KEY_MAP = { +#: ```` markers: the keys a terminal understands, and the bytes each sends. +_KEY_NAMES: dict[str, str] = { "esc": "\x1b", "escape": "\x1b", "enter": "\r", + "return": "\r", "tab": "\t", + "s-tab": "\x1b[Z", + "space": " ", + "bs": "\x7f", + "backspace": "\x7f", + "del": "\x1b[3~", + "delete": "\x1b[3~", + "home": "\x1b[H", + "end": "\x1b[F", "up": "\x1b[A", "down": "\x1b[B", - "right": "\x1b[C", "left": "\x1b[D", + "right": "\x1b[C", + "pgup": "\x1b[5~", + "pgdn": "\x1b[6~", + "pageup": "\x1b[5~", + "pagedown": "\x1b[6~", + "f1": "\x1bOP", + "f2": "\x1bOQ", + "f3": "\x1bOR", + "f4": "\x1bOS", + "f5": "\x1b[15~", + "f6": "\x1b[17~", + "f7": "\x1b[18~", + "f8": "\x1b[19~", + "f9": "\x1b[20~", + "f10": "\x1b[21~", + "f11": "\x1b[23~", + "f12": "\x1b[24~", } -_SIMPLE_ESCAPE_LEN = 2 + +#: What a Ctrl may be combined with beyond A-Z: the rest of the C0 controls. +_CONTROL_CHARS = "@[\\]^_" +#: Longest marker content to look for, so a stray ``<`` stays a literal ``<``. +_MAX_MARKER = 20 # C0 controls except TAB/LF/CR (those stay for readable logs). _UNSAFE_CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") @@ -58,15 +87,82 @@ def sanitize_pty_output_for_tool(data: str) -> str: return _UNSAFE_CTRL.sub(_esc_ctrl, out) +class KeySyntaxError(ValueError): + """A ``<...>`` marker that names no key, or repeats it wrongly.""" + + +def _control(char: str) -> str: + """The C0 byte for ````.""" + if len(char) != 1: + msg = f"ctrl takes one character, not {char!r} (write )" + raise KeySyntaxError(msg) + upper = char.upper() + if not ("A" <= upper <= "Z" or char in _CONTROL_CHARS): + msg = f"no control byte for " + raise KeySyntaxError(msg) + return chr(ord(upper) & 0x1F) + + +def _key_bytes(name: str) -> str: + """What one ```` marker sends, ``c-`` / ``m-`` prefixes included.""" + known = _KEY_NAMES.get(name.lower()) + if known is not None: + return known + head, sep, rest = name.partition("-") + if sep and len(head) == 1 and rest: + if head.lower() == "c": + return _control(rest) + if head.lower() == "m": + return "\x1b" + (rest if len(rest) == 1 else _key_bytes(rest)) + msg = f"unknown key <{name}> (see the write_pty_keys docs for the names)" + raise KeySyntaxError(msg) + + +def _expansion(content: str) -> str: + """The bytes for the inside of one marker, honouring a ``*N`` repeat.""" + name, _, count = content.partition("*") + if not count: + return _key_bytes(name) + if not count.isdigit() or int(count) < 1: + msg = f"bad repeat in <{content}> (write )" + raise KeySyntaxError(msg) + return _key_bytes(name) * int(count) + + +def _marker_at(data: str, index: int) -> tuple[str, int] | None: + """The marker starting at *index*, as ``(content, next index)``, or ``None``. + + A ``<`` that is not followed by a short, whitespace-free ``>`` pair stays a + literal ``<`` — ordinary input like ``sort < file`` must not need escaping. + """ + end = data.find(">", index + 1, index + 1 + _MAX_MARKER) + if end <= index + 1: + return None + content = data[index + 1 : end] + if any(char.isspace() or char == "<" for char in content): + return None + return content, end + 1 + + def decode_write_data(data: str) -> str: - """Expand agent-friendly escapes into raw PTY input (for ``write_pty_keys`` only). + """Expand ```` markers and escapes into raw PTY input (``write_pty_keys``). - Supported (as literal characters in ``data``): + Markers, written between angle brackets: - - ``\\n`` ``\\r`` ``\\t`` ``\\e`` / ``\\E`` (ESC) ``\\0`` - - ``\\xHH`` byte, ``\\uHHHH`` Unicode code point - - ``ctrl+c`` / ``ctrl+x`` ... (case-insensitive; A-Z or @ [ \\ ] ^ _) - - ``key=esc`` ``key=enter`` ``key=tab`` ``key=up|down|left|right`` + - ```` ```` ```` ```` ```` ```` ```` + ```` ```` ```` ```` ```` ```` + ```` ```` ```` … ```` + - ```` … ```` for Ctrl (plus ```` ```` ```` + ```` ```` ````), ```` for Alt: an ESC before the key + - ```` repeats a key three times, ``<<`` is a literal ``<`` + + An unknown name raises :class:`KeySyntaxError` rather than typing the marker + into the terminal. Anything that is not a marker or an escape is literal. + + Escapes (literal characters in ``data``): + + - ``\\\\n`` ``\\\\r`` ``\\\\t`` ``\\\\e`` / ``\\\\E`` (ESC) ``\\\\0`` + - ``\\\\xHH`` byte, ``\\\\uHHHH`` Unicode code point """ if not data: return data @@ -75,18 +171,20 @@ def decode_write_data(data: str) -> str: i = 0 n = len(data) while i < n: + if data[i] == "<": + if data.startswith("<<", i): + out.append("<") # the escape for a literal one + i += 2 + continue + marker = _marker_at(data, i) + if marker is not None: + content, i = marker + out.append(_expansion(content)) + continue + out.append("<") # a `<` that opens nothing + i += 1 + continue rest = data[i:] - m_key = _KEY.match(rest) - if m_key is not None: - out.append(_KEY_MAP[m_key.group(1).lower()]) - i += m_key.end() - continue - m_ctrl = _CTRL.match(rest) - if m_ctrl is not None: - ch = m_ctrl.group(1).upper() - out.append(chr((ord(ch) - ord("@")) & 0x1F)) - i += m_ctrl.end() - continue m_hex = _HEX_BYTE.match(rest) if m_hex is not None: out.append(chr(int(m_hex.group(1), 16))) diff --git a/src/plyngent/tools/process/write_pty.py b/src/plyngent/tools/process/write_pty.py index 523aa1c..10788a5 100644 --- a/src/plyngent/tools/process/write_pty.py +++ b/src/plyngent/tools/process/write_pty.py @@ -26,7 +26,7 @@ def write_pty_payload(session_id: int, raw: str) -> str: async def write_pty(session_id: int, data: str) -> str: """Write **literal** text to a PTY session. Does not append a newline. - ``data`` is sent unchanged (no ``ctrl+x`` / ``\\\\xHH`` expansion). For + ``data`` is sent unchanged (no ```` / ``\\xHH`` expansion). For control sequences use :func:`write_pty_keys`. """ try: diff --git a/src/plyngent/tools/process/write_pty_keys.py b/src/plyngent/tools/process/write_pty_keys.py index ff9a2c3..2cb68e5 100644 --- a/src/plyngent/tools/process/write_pty_keys.py +++ b/src/plyngent/tools/process/write_pty_keys.py @@ -3,27 +3,37 @@ from __future__ import annotations from plyngent.agent import ToolTag, tool from plyngent.tools.workspace import WorkspaceError -from .pty_terminal import decode_write_data +from .pty_terminal import KeySyntaxError, decode_write_data from .write_pty import write_pty_payload @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO) async def write_pty_keys(session_id: int, data: str) -> str: - """Write to a PTY after expanding key escapes (never for normal typing). + """Write to a PTY after expanding ```` markers (never for normal typing). Use this only for control sequences. Prefer :func:`write_pty` for plain text - so strings like ``press ctrl+c`` are not rewritten. + so strings like ``press `` are not rewritten. - Escapes (literal characters in ``data``): + Markers, written between angle brackets: - - ``\\\\n`` ``\\\\r`` ``\\\\t`` ``\\\\e``/``\\\\E`` (ESC) ``\\\\0`` - - ``\\\\xHH`` byte, ``\\\\uHHHH`` Unicode code point - - ``ctrl+c`` / ``ctrl+x`` ... (case-insensitive) - - ``key=esc|enter|tab|up|down|left|right`` + - ```` ```` ```` ```` ```` ```` ```` + ```` ```` ```` ```` ```` ```` + ```` ```` ```` … ```` + - ```` … ```` for Ctrl, ```` for Alt (an ESC first) + - ```` repeats a key, ``<<`` is a literal ``<`` + + An unknown marker is an error, not text typed into the terminal: a typo must + not land in a shell prompt. + + Escapes (as the characters ``data`` carries, so doubled inside JSON): + ``\\\\n`` ``\\\\r`` ``\\\\t`` ``\\\\e``/``\\\\E`` (ESC) ``\\\\0``, + ``\\\\xHH`` byte, ``\\\\uHHHH`` code point. Anything else is literal. """ try: raw = decode_write_data(data) return write_pty_payload(session_id, raw) + except KeySyntaxError as exc: + return f"error: {exc}" except WorkspaceError as exc: return f"error: {exc}" except OSError as exc: