fix(cli): suppress CPR on POSIX local TTYs under load

Delayed ESC[6n replies leak as ^[[row;colR into the classic CLI on
SSH/slow PTYs (#13870) and on local POSIX TTYs under heavy subagent
load. Suppress CPR on non-Windows platforms (layout hint only); keep
native Windows on prompt_toolkit's default pending native coverage.
Wire selection through _select_classic_cli_pt_output.
This commit is contained in:
HexLab98 2026-07-19 09:50:42 +00:00 committed by kshitij
parent 7651764ce6
commit 3f820a1c7c

90
cli.py
View file

@ -3270,28 +3270,28 @@ def _disable_prompt_toolkit_cpr_warning(app) -> None:
pass
def _terminal_may_leak_cpr() -> bool:
"""Detect terminals where CPR (ESC[6n) replies are likely to leak.
def _terminal_may_leak_cpr(*, platform: str | None = None) -> bool:
"""Whether classic CLI should suppress prompt_toolkit CPR (ESC[6n) queries.
The CPR leak in #13870 is environment-specific: it shows up over SSH +
cloudflared/mux tunnels and slow PTYs, where the terminal's
``ESC[<row>;<col>R`` reply round-trips slowly enough to race past the input
parser and land in the display as raw ``20;1R`` text (and the pending-CPR
future can stall the renderer, freezing the prompt). On a local terminal the
reply returns instantly and cleanly, so CPR works fine and there is nothing
to fix we leave prompt_toolkit's default behavior untouched there.
Delayed CPR replies (``ESC[<row>;<col>R`` / visible ``^[[<row>;<col>R``)
leak into the status line and can freeze input when the reply is slow
(#13870 on SSH/slow PTYs). The same race hits **local POSIX** TTYs under
heavy subagent / status-line load deterministic delayed-CPR PTY harness
in ``tests/cli/test_cpr_local_leak.py``.
We only suppress CPR on a remote/tunneled link (SSH env vars) or when the
user has explicitly opted out via prompt_toolkit's own ``PROMPT_TOOLKIT_NO_CPR``
escape hatch. Keeping this narrow (not the broader WSL/Ghostty/Windows set
that ``_preserve_ctrl_enter_newline`` keys on) means the only behavior change
lands exactly where the bug reproduces.
Policy:
- ``PROMPT_TOOLKIT_NO_CPR=1`` always suppress
- native Windows (``win32``) keep prompt_toolkit's default for now
(no native-Windows Application coverage yet); still honor NO_CPR
- all other platforms suppress (CPR is only a layout hint; heuristic
height is enough). SSH env is no longer required to trigger this.
"""
if os.environ.get("PROMPT_TOOLKIT_NO_CPR", "") == "1":
return True
if any(os.environ.get(v) for v in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY")):
return True
return False
plat = sys.platform if platform is None else platform
if plat == "win32":
return False
return True
def _build_cpr_disabled_output(stdout):
@ -3299,23 +3299,14 @@ def _build_cpr_disabled_output(stdout):
prompt_toolkit's renderer sends ``ESC[6n`` (Device Status Report) to learn
the cursor row before painting in non-fullscreen mode; the terminal replies
``ESC[<row>;<col>R``. Over SSH + cloudflared/mux tunnels and some slow PTYs
these replies race past the input parser and land in the display as raw text
like ``20;1R21;1R``, and the pending-CPR future can stall the renderer so the
prompt appears frozen after the agent's final answer (see #13870).
``ESC[<row>;<col>R``. When that reply is delayed it races into the display
as raw ``^[[39;1R`` and can stall the renderer's pending-CPR future
(#13870; also local POSIX under heavy subagent load).
Constructing the output with ``enable_cpr=False`` makes the renderer mark CPR
``NOT_SUPPORTED`` up front, so ``ESC[6n`` is never sent and no CPR response
can leak. This is the root-cause counterpart to the input-side scrubbing in
``_strip_leaked_terminal_responses`` that cleans leaks after the fact; this
stops them at the source. The UI is otherwise identical (prompt_toolkit uses
its heuristic available-height fallback, which it already relies on whenever a
terminal doesn't answer CPR).
This is only invoked on terminals flagged by ``_terminal_may_leak_cpr()``
CPR is a layout hint, not a speed optimization, and it works fine locally, so
we leave the upstream default in place on local terminals and only suppress it
where the leak actually reproduces.
Constructing the output with ``enable_cpr=False`` marks CPR
``NOT_SUPPORTED`` so ``ESC[6n`` is never sent. prompt_toolkit then uses its
heuristic available-height fallback. Input-side
``_strip_leaked_terminal_responses`` remains belt-and-suspenders.
Note: ``Vt100_Output.from_pty()`` does NOT expose ``enable_cpr`` in
prompt_toolkit 3.x, so we reproduce its ``get_size`` setup and call the
@ -3341,6 +3332,18 @@ def _build_cpr_disabled_output(stdout):
return None
def _select_classic_cli_pt_output(stdout):
"""Select prompt_toolkit Output for classic-CLI Application construction.
Returns a CPR-disabled ``Vt100_Output`` when ``_terminal_may_leak_cpr()``
is true, otherwise ``None`` so Application keeps prompt_toolkit's default
output (Windows preserve-default path).
"""
if not _terminal_may_leak_cpr():
return None
return _build_cpr_disabled_output(stdout)
def _strip_leaked_terminal_responses_with_meta(text: str) -> tuple[str, bool]:
"""Strip leaked terminal control-response sequences from user input.
@ -14859,22 +14862,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
}
style = PTStyle.from_dict(self._build_tui_style_dict())
# Disable CPR (Cursor Position Report) at the source so prompt_toolkit
# never sends ESC[6n cursor-position queries — but only on terminals
# where the reply is likely to leak. Over SSH/cloudflared tunnels and
# slow PTYs the CPR replies (ESC[<row>;<col>R) leak into the display as
# raw "20;1R21;1R" text and can stall the renderer's pending-CPR future,
# freezing the prompt after the agent's final answer (#13870). CPR is a
# layout hint, not a speed optimization, and it works fine locally, so we
# leave prompt_toolkit's default untouched on local terminals and only
# suppress it where the bug reproduces. None (local, or build failure)
# falls back to the default output; the input-side scrubbing in
# _strip_leaked_terminal_responses still guards against any leaks.
_cpr_disabled_output = (
_build_cpr_disabled_output(sys.stdout)
if _terminal_may_leak_cpr()
else None
)
# Select CPR-disabled output when _terminal_may_leak_cpr() says so
# (POSIX local + SSH; Windows keeps PT default — see helper docs).
# None falls back to prompt_toolkit's default output; input scrubbing
# in _strip_leaked_terminal_responses still guards residual leaks.
_cpr_disabled_output = _select_classic_cli_pt_output(sys.stdout)
# Create the application
app = Application(