From 21dc26810b8241c2e317b1134042d5c96af0f7b1 Mon Sep 17 00:00:00 2001 From: h Date: Fri, 22 May 2026 01:04:08 +0200 Subject: [PATCH] fix: split submit key and prompt pasting --- src/claude_code_api/pty.py | 44 ++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/src/claude_code_api/pty.py b/src/claude_code_api/pty.py index fcd820f..a4969f4 100644 --- a/src/claude_code_api/pty.py +++ b/src/claude_code_api/pty.py @@ -43,6 +43,13 @@ _VALID_PERMISSION_MODES: frozenset[str] = frozenset( _DEFAULT_DRAIN_CHUNK = 65536 _DEFAULT_OUTPUT_BUFFER_CAP = 1_000_000 +# Gap between the bracketed-paste closing marker and the Enter keystroke +# in `PtyClaudeProcess.write()`. Claude's Ink-based TUI reads stdin in +# chunks; if `\r` is glued to `ESC [ 201 ~` it gets absorbed by the +# paste-event handler and Submit never fires. 50ms is enough on a slow +# Pi for the TUI to surface the paste event before the Enter arrives. +_SUBMIT_KEY_DELAY = 0.05 + @dataclass(frozen=True) class PtyProcessOptions: @@ -358,16 +365,21 @@ class PtyClaudeProcess: async def write(self, data: str | bytes, *, newline: bool = True) -> int: r"""Write bytes to the child's stdin. - Strings are UTF-8 encoded. When `newline=True` (the default) the - payload is wrapped in xterm bracketed-paste markers (`ESC [ 200 ~` - ... `ESC [ 201 ~`) and followed by a carriage return — that is the - Enter-key keycode interactive `claude` expects. + Strings are UTF-8 encoded. When `newline=True` (the default): - Without the bracketed-paste framing, the TUI heuristically treats - bursts longer than ~63 bytes as a paste and *buffers* them in the - input box without submitting; the trailing `\r` is then absorbed - as a newline inside the box rather than acting as Submit. - Bracketed paste makes the framing explicit for any length payload. + 1. The payload is wrapped in xterm bracketed-paste markers + (`ESC [ 200 ~` ... `ESC [ 201 ~`) and written first. Without + that framing, the TUI heuristically treats bursts longer than + ~63 bytes as a paste and *buffers* them in the input box + without submitting. + 2. A short pause (`_SUBMIT_KEY_DELAY`) gives the TUI's stdin + reader a chance to surface the paste as a discrete event. + Claude's TUI (Ink-based) reads stdin in chunks; if a `\r` + is glued to the closing `ESC [ 201 ~`, the paste-event handler + consumes the whole chunk and the Enter is dropped — the + message lands in the input box but never submits. + 3. A separate write sends the `\r` as a fresh keystroke that + triggers Submit. Callers that need raw byte streaming (e.g. arrow keys, individual keypresses) pass `newline=False` and write the framing themselves. @@ -376,12 +388,16 @@ class PtyClaudeProcess: msg = "PtyClaudeProcess not started" raise RuntimeError(msg) payload = data.encode("utf-8") if isinstance(data, str) else bytes(data) - if newline: - if payload.endswith(b"\r"): - payload = payload[:-1] - payload = b"\x1b[200~" + payload + b"\x1b[201~\r" pty = self._pty - return await asyncio.to_thread(pty.write, payload) + if not newline: + return await asyncio.to_thread(pty.write, payload) + if payload.endswith(b"\r"): + payload = payload[:-1] + paste_chunk = b"\x1b[200~" + payload + b"\x1b[201~" + n1 = await asyncio.to_thread(pty.write, paste_chunk) + await asyncio.sleep(_SUBMIT_KEY_DELAY) + n2 = await asyncio.to_thread(pty.write, b"\r") + return n1 + n2 async def send_control(self, char: str) -> None: """Send a control character (e.g. 'c' for Ctrl-C, 'd' for Ctrl-D)."""