fix: split submit key and prompt pasting

This commit is contained in:
h
2026-05-22 01:04:08 +02:00
parent 8eefd6f928
commit 21dc26810b
+30 -14
View File
@@ -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)."""