fix(cli): disable prompt_toolkit CPR queries to stop escape-sequence leak (#13870)

prompt_toolkit's renderer sends ESC[6n cursor-position queries before
painting in non-fullscreen mode; the terminal replies ESC[<row>;<col>R.
Over SSH/cloudflared tunnels and slow PTYs these replies race past the
input parser and land in the display as raw '20;1R21;1R' text, and the
pending-CPR future can stall the renderer so the prompt freezes after the
agent's final answer.

Build the prompt_toolkit output with enable_cpr=False so CPR is marked
NOT_SUPPORTED up front and ESC[6n is never sent. This is the root-cause
counterpart to the existing input-side _strip_leaked_terminal_responses
scrubbing. Vt100_Output.from_pty() does not expose enable_cpr in
prompt_toolkit 3.x, so _build_cpr_disabled_output() reproduces its
get_size setup and calls the constructor directly; it returns None on any
failure so startup falls back to the default output.

Verified in a real PTY: baseline emits 1 ESC[6n query, the fix emits 0,
banner/UI render identically. Layout is unaffected — with CPR off the
renderer sizes the prompt to its preferred height (the same fallback
prompt_toolkit uses on any terminal that doesn't answer CPR).

Co-authored-by: Hermes Agent <noreply@nousresearch.com>
This commit is contained in:
HiddenPuppy 2026-06-27 03:52:26 -07:00 committed by Teknium
parent e7c013494d
commit b34771fc06
2 changed files with 157 additions and 1 deletions

91
cli.py
View file

@ -2950,6 +2950,77 @@ 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.
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.
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.
"""
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
def _build_cpr_disabled_output(stdout):
"""Build a Vt100_Output that never sends Cursor Position Report queries.
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).
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.
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
constructor directly. Returns ``None`` on any failure so the caller falls back
to prompt_toolkit's default output (CPR enabled, but input-side scrubbing
still protects against leaks).
"""
try:
import io as _io
from prompt_toolkit.output.vt100 import Vt100_Output, _get_size
from prompt_toolkit.data_structures import Size
def _get_term_size():
rows = columns = None
try:
rows, columns = _get_size(stdout.fileno())
except (OSError, _io.UnsupportedOperation, AttributeError, ValueError):
pass
return Size(rows=rows or 24, columns=columns or 80)
return Vt100_Output(stdout, _get_term_size, enable_cpr=False)
except Exception:
return None
def _strip_leaked_terminal_responses_with_meta(text: str) -> tuple[str, bool]:
"""Strip leaked terminal control-response sequences from user input.
@ -14321,7 +14392,24 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
'voice-status-recording': 'bg:#1a1a2e #FF4444 bold',
}
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
)
# Create the application
app = Application(
layout=layout,
@ -14329,6 +14417,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
style=style,
full_screen=False,
mouse_support=False,
**({"output": _cpr_disabled_output} if _cpr_disabled_output is not None else {}),
# Read from display.cli_refresh_interval (default 0 = disabled).
# When non-zero, prompt_toolkit redraws the UI on this cadence
# during idle, keeping wall-clock status-bar read-outs ticking.