diff --git a/src/claude_code_api/pty.py b/src/claude_code_api/pty.py index c8e5744..92c24b8 100644 --- a/src/claude_code_api/pty.py +++ b/src/claude_code_api/pty.py @@ -59,9 +59,26 @@ _SNAPSHOT_INTERVAL = 10.0 # 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 +# paste-event handler and Submit never fires. 250ms is conservative for +# a slow Pi under load — observed 50ms races on opus-high turns where +# the paste handler is still scheduling when Enter arrives. +_SUBMIT_KEY_DELAY = 0.25 + +# After sending Enter we poll the PTY output for the paste-indicator +# (claude's TUI renders ``[Pasted text #...`` while the paste is still +# buffered in the input box). If it's still there after this window, +# Enter never registered — we retry up to ``_SUBMIT_RETRIES`` times. +_SUBMIT_VERIFY_WINDOW = 1.5 +_SUBMIT_VERIFY_POLL = 0.1 +_SUBMIT_RETRIES = 3 +_PASTE_INDICATOR = b"[Pasted text" + +# Size of the most-recent slice of PTY output considered "the current +# screen render" when verifying a submit. The TUI emits a full clear + +# redraw per state change; the latest redraw fits comfortably in this +# window, while earlier intermediate frames (which may still show the +# paste indicator) live further back in the same buffer. +_TUI_TAIL_WINDOW = 8192 @dataclass(frozen=True) @@ -326,6 +343,35 @@ class PtyClaudeProcess: if overflow > 0: del self._output_buffer[:overflow] + async def _wait_for_submit(self, pre_enter_len: int) -> bool: + """Poll the PTY output to confirm Enter actually submitted the paste. + + Claude's TUI renders ``[Pasted text #N +M lines]`` in the input + box while the paste is still buffered (unsubmitted). After Enter + registers, the input clears and the TUI redraws — the next + screen render no longer contains the indicator. + + We compare bytes written *after* the Enter write (``pre_enter_len`` + was snapshotted just before). Within those new bytes, only the + most recent ``_TUI_TAIL_WINDOW`` matter — that's the current + rendered screen; earlier bytes are intermediate redraws (one of + which would still show the indicator). + + Returns True once the indicator is absent from the recent tail, + False on timeout. + """ + deadline = asyncio.get_running_loop().time() + _SUBMIT_VERIFY_WINDOW + while asyncio.get_running_loop().time() < deadline: + buf = self.captured_output() + new_bytes = buf[pre_enter_len:] + tail = new_bytes[-_TUI_TAIL_WINDOW:] + # Only check once the TUI has produced at least one redraw + # after our Enter (otherwise tail is empty / pre-render). + if tail and _PASTE_INDICATOR not in tail: + return True + await asyncio.sleep(_SUBMIT_VERIFY_POLL) + return False + async def _snapshot_loop(self, snapshot_dir: pathlib.Path) -> None: """Periodically dump the captured PTY buffer to a file. @@ -530,7 +576,37 @@ class PtyClaudeProcess: ) n1 = await asyncio.to_thread(pty.write, paste_chunk) await asyncio.sleep(_SUBMIT_KEY_DELAY) - n2 = await asyncio.to_thread(pty.write, b"\r") + n2 = 0 + for attempt in range(1, _SUBMIT_RETRIES + 1): + # Snapshot just before each Enter write so the verifier only + # looks at bytes the TUI produces in response to *this* + # Enter, not earlier renders (which still show the paste + # indicator from when the paste was first buffered). + pre_enter_len = len(self.captured_output()) + n2 += await asyncio.to_thread(pty.write, b"\r") + submitted = await self._wait_for_submit(pre_enter_len) + if submitted: + if attempt > 1: + _log.info( + "write: session_id=%s SUBMIT registered after %d Enter(s)", + self._session_id, + attempt, + ) + break + _log.warning( + "write: session_id=%s Enter #%d did not clear paste indicator " + "(buf=%d bytes) — retrying", + self._session_id, + attempt, + len(self.captured_output()), + ) + else: + _log.error( + "write: session_id=%s SUBMIT never registered after %d " + "Enter attempts — paste likely stuck in input box", + self._session_id, + _SUBMIT_RETRIES, + ) _log.info( "write: session_id=%s SUBMIT done (paste=%d Enter=%d)", self._session_id,