diff --git a/cli.py b/cli.py index a1544a681df..a4d0116c81a 100644 --- a/cli.py +++ b/cli.py @@ -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[;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[;R`` / visible ``^[[;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[;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[;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[;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(