fix: better "enter" after pasted text
This commit is contained in:
@@ -59,9 +59,26 @@ _SNAPSHOT_INTERVAL = 10.0
|
|||||||
# Gap between the bracketed-paste closing marker and the Enter keystroke
|
# Gap between the bracketed-paste closing marker and the Enter keystroke
|
||||||
# in `PtyClaudeProcess.write()`. Claude's Ink-based TUI reads stdin in
|
# in `PtyClaudeProcess.write()`. Claude's Ink-based TUI reads stdin in
|
||||||
# chunks; if `\r` is glued to `ESC [ 201 ~` it gets absorbed by the
|
# 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
|
# paste-event handler and Submit never fires. 250ms is conservative for
|
||||||
# Pi for the TUI to surface the paste event before the Enter arrives.
|
# a slow Pi under load — observed 50ms races on opus-high turns where
|
||||||
_SUBMIT_KEY_DELAY = 0.05
|
# 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)
|
@dataclass(frozen=True)
|
||||||
@@ -326,6 +343,35 @@ class PtyClaudeProcess:
|
|||||||
if overflow > 0:
|
if overflow > 0:
|
||||||
del self._output_buffer[:overflow]
|
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:
|
async def _snapshot_loop(self, snapshot_dir: pathlib.Path) -> None:
|
||||||
"""Periodically dump the captured PTY buffer to a file.
|
"""Periodically dump the captured PTY buffer to a file.
|
||||||
|
|
||||||
@@ -530,7 +576,37 @@ class PtyClaudeProcess:
|
|||||||
)
|
)
|
||||||
n1 = await asyncio.to_thread(pty.write, paste_chunk)
|
n1 = await asyncio.to_thread(pty.write, paste_chunk)
|
||||||
await asyncio.sleep(_SUBMIT_KEY_DELAY)
|
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(
|
_log.info(
|
||||||
"write: session_id=%s SUBMIT done (paste=%d Enter=%d)",
|
"write: session_id=%s SUBMIT done (paste=%d Enter=%d)",
|
||||||
self._session_id,
|
self._session_id,
|
||||||
|
|||||||
Reference in New Issue
Block a user