Files
hermes-agent/tests/cli/test_cli_force_redraw.py
Teknium bb00b783fb fix(cli): eliminate ghost status-bar + DSR input leaks from terminal drift
The CLI renders through prompt_toolkit in non-full-screen mode, so every
repaint uses the renderer's tracked _cursor_pos.y to cursor_up() + erase
before drawing the new frame. Any time that tracked position drifts from
terminal reality, redraws stack on top of stale content instead of
overwriting it. Four user-visible bugs share this root cause.

Fixes:

- #5474 (SIGWINCH ghosts): the resize wrapper previously only handled
  column-shrink reflow. Generalize it to force a full screen-clear
  (erase_screen + cursor_goto(0,0)) and renderer.reset() on every resize
  — covers widen, row-shrink, and multiplexer SIGWINCH-less redraws.

- #8688 (cmux/tmux tab switch): no SIGWINCH fires on focus regain, so
  prompt_toolkit has no signal to recover. Add a _force_full_redraw()
  helper, bound to Ctrl+L (standard bash/zsh/vim convention) and exposed
  as /redraw. Users can manually clear drift without restarting Hermes.

- #14692 (DSR response leaks — ^[[53;1R): resize storms make
  prompt_toolkit's CSI 6n queries race past the input parser; the
  terminal's reply ends up as literal input text. Add a sibling of the
  bracketed-paste sanitizer that strips \x1b[<row>;<col>R and the
  caret-escape visible form from paste text, buffer text-filter, and
  the input-processing loop.

The idle-redraw removal (#12641) is in the preceding commit from
@foxion37 — keeping them as separate commits preserves attribution.
2026-04-27 05:31:47 -07:00

74 lines
2.5 KiB
Python

"""Tests for CLI redraw helpers used to recover from terminal buffer drift.
Covers:
- _force_full_redraw (#8688 cmux tab switch, /redraw, Ctrl+L)
- the resize handler we install over prompt_toolkit's _on_resize (#5474)
Both behaviors are exercised against fake prompt_toolkit renderer/output
objects — we're asserting the escape sequences the CLI sends, not that
the terminal physically repainted.
"""
from unittest.mock import MagicMock
import pytest
from cli import HermesCLI
@pytest.fixture
def bare_cli():
"""A HermesCLI with no __init__ — we only exercise the redraw helper."""
cli = object.__new__(HermesCLI)
return cli
class TestForceFullRedraw:
def test_no_app_is_safe(self, bare_cli):
# _force_full_redraw must be a no-op when the TUI isn't running.
bare_cli._app = None
bare_cli._force_full_redraw() # must not raise
def test_missing_app_attr_is_safe(self, bare_cli):
# Simulate HermesCLI before the TUI has ever been constructed.
bare_cli._force_full_redraw() # must not raise
def test_sends_full_clear_and_invalidates(self, bare_cli):
app = MagicMock()
out = app.renderer.output
bare_cli._app = app
bare_cli._force_full_redraw()
# Must erase screen, home cursor, and flush — in that order.
out.reset_attributes.assert_called_once()
out.erase_screen.assert_called_once()
out.cursor_goto.assert_called_once_with(0, 0)
out.flush.assert_called_once()
# Must reset prompt_toolkit's tracked screen/cursor state so the
# next incremental redraw starts from a clean (0, 0) origin.
app.renderer.reset.assert_called_once_with(leave_alternate_screen=False)
# Must schedule a repaint.
app.invalidate.assert_called_once()
def test_swallows_renderer_exceptions(self, bare_cli):
# If the renderer blows up for any reason, the helper must not
# propagate — otherwise a stray Ctrl+L would crash the CLI.
app = MagicMock()
app.renderer.output.erase_screen.side_effect = RuntimeError("boom")
bare_cli._app = app
bare_cli._force_full_redraw() # must not raise
# invalidate() is still attempted after a renderer failure.
app.invalidate.assert_called_once()
def test_swallows_invalidate_exceptions(self, bare_cli):
app = MagicMock()
app.invalidate.side_effect = RuntimeError("boom")
bare_cli._app = app
bare_cli._force_full_redraw() # must not raise