hermes-bsd/tests/cli/test_cli_force_redraw.py

226 lines
9 KiB
Python
Raw Normal View History

2026-04-27 04:57:39 -07:00
"""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
import cli as cli_mod
2026-04-27 04:57:39 -07:00
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_replays_then_invalidates(self, bare_cli, monkeypatch):
2026-04-27 04:57:39 -07:00
app = MagicMock()
out = app.renderer.output
bare_cli._app = app
events = []
out.reset_attributes.side_effect = lambda: events.append("reset_attrs")
out.erase_screen.side_effect = lambda: events.append("erase")
out.cursor_goto.side_effect = lambda *_: events.append("home")
out.flush.side_effect = lambda: events.append("flush")
app.renderer.reset.side_effect = lambda **_: events.append("renderer_reset")
monkeypatch.setattr(cli_mod, "_replay_output_history", lambda: events.append("replay"))
app.invalidate.side_effect = lambda: events.append("invalidate")
2026-04-27 04:57:39 -07:00
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()
assert events == [
"reset_attrs",
"erase",
"home",
"flush",
"renderer_reset",
"replay",
"invalidate",
]
fix(cli): clear viewport on width-change resize so the status bar can't duplicate (#49120) The classic CLI status bar could appear twice after a horizontal terminal resize — two bars at two widths with two different elapsed readings. Root cause: prompt_toolkit's Application._on_resize() calls renderer.erase(), which does cursor_up(_cursor_pos.y) + erase_down() using the _cursor_pos.y cached from the LAST render at the OLD width (renderer.py:745). On a column shrink the terminal reflows the already-painted full-width chrome into extra physical rows, so the cached y undershoots: cursor_up doesn't climb past the reflowed rows and erase_down leaves the old bar stranded ABOVE the live origin. The next paint stacks a fresh bar below it. The existing post-resize suppression hides the NEW bar for ~0.35s but never erases the already-reflowed OLD one, so the ghost survives the whole window. Ctrl+L / /redraw clears it, confirming a viewport wipe is the fix. Fix: on a WIDTH change, _recover_after_resize now routes through the same recovery as Ctrl+L — _clear_prompt_toolkit_screen(rebuild_scrollback=False) (CSI 2J, visible viewport only) + _replay_output_history() — BEFORE delegating to prompt_toolkit's resize. Banner-safe: 2J never touches scrollback history (that's CSI 3J, which we don't send here), so the startup banner is preserved. Rows-only resizes skip the clear (no reflow → no ghost) to avoid an extra repaint. Tracks _last_resize_width to distinguish the two. Tests: replace the now-obsolete 'never clears on resize' assertion with two tests — rows-only resize delegates without clearing; width change clears the viewport + replays and never wipes scrollback.
2026-06-19 08:43:42 -07:00
def test_resize_recovery_skips_clear_when_width_unchanged(self, bare_cli, monkeypatch):
"""A rows-only resize (same width) must NOT clear the screen.
prompt_toolkit's built-in Application._on_resize() starts with
renderer.erase(leave_alternate_screen=False), which uses the renderer's
cached cursor position to move back to the live prompt origin before
fix(cli): clear viewport on width-change resize so the status bar can't duplicate (#49120) The classic CLI status bar could appear twice after a horizontal terminal resize — two bars at two widths with two different elapsed readings. Root cause: prompt_toolkit's Application._on_resize() calls renderer.erase(), which does cursor_up(_cursor_pos.y) + erase_down() using the _cursor_pos.y cached from the LAST render at the OLD width (renderer.py:745). On a column shrink the terminal reflows the already-painted full-width chrome into extra physical rows, so the cached y undershoots: cursor_up doesn't climb past the reflowed rows and erase_down leaves the old bar stranded ABOVE the live origin. The next paint stacks a fresh bar below it. The existing post-resize suppression hides the NEW bar for ~0.35s but never erases the already-reflowed OLD one, so the ghost survives the whole window. Ctrl+L / /redraw clears it, confirming a viewport wipe is the fix. Fix: on a WIDTH change, _recover_after_resize now routes through the same recovery as Ctrl+L — _clear_prompt_toolkit_screen(rebuild_scrollback=False) (CSI 2J, visible viewport only) + _replay_output_history() — BEFORE delegating to prompt_toolkit's resize. Banner-safe: 2J never touches scrollback history (that's CSI 3J, which we don't send here), so the startup banner is preserved. Rows-only resizes skip the clear (no reflow → no ghost) to avoid an extra repaint. Tracks _last_resize_width to distinguish the two. Tests: replace the now-obsolete 'never clears on resize' assertion with two tests — rows-only resize delegates without clearing; width change clears the viewport + replays and never wipes scrollback.
2026-06-19 08:43:42 -07:00
erase_down(). With no column reflow there is no ghost chrome to wipe,
so we delegate straight to prompt_toolkit and avoid an extra repaint.
"""
app = MagicMock()
events = []
app.renderer.reset.side_effect = lambda **_: events.append("renderer_reset")
app.invalidate.side_effect = lambda: events.append("invalidate")
original_on_resize = lambda: events.append("original_resize")
fix(cli): clear viewport on width-change resize so the status bar can't duplicate (#49120) The classic CLI status bar could appear twice after a horizontal terminal resize — two bars at two widths with two different elapsed readings. Root cause: prompt_toolkit's Application._on_resize() calls renderer.erase(), which does cursor_up(_cursor_pos.y) + erase_down() using the _cursor_pos.y cached from the LAST render at the OLD width (renderer.py:745). On a column shrink the terminal reflows the already-painted full-width chrome into extra physical rows, so the cached y undershoots: cursor_up doesn't climb past the reflowed rows and erase_down leaves the old bar stranded ABOVE the live origin. The next paint stacks a fresh bar below it. The existing post-resize suppression hides the NEW bar for ~0.35s but never erases the already-reflowed OLD one, so the ghost survives the whole window. Ctrl+L / /redraw clears it, confirming a viewport wipe is the fix. Fix: on a WIDTH change, _recover_after_resize now routes through the same recovery as Ctrl+L — _clear_prompt_toolkit_screen(rebuild_scrollback=False) (CSI 2J, visible viewport only) + _replay_output_history() — BEFORE delegating to prompt_toolkit's resize. Banner-safe: 2J never touches scrollback history (that's CSI 3J, which we don't send here), so the startup banner is preserved. Rows-only resizes skip the clear (no reflow → no ghost) to avoid an extra repaint. Tracks _last_resize_width to distinguish the two. Tests: replace the now-obsolete 'never clears on resize' assertion with two tests — rows-only resize delegates without clearing; width change clears the viewport + replays and never wipes scrollback.
2026-06-19 08:43:42 -07:00
# bare_cli skips __init__, so seed attributes the way __init__ would.
fix(cli): clamp scrollback box widths + suppress status bar after resize (#25975) When the terminal shrinks, already-printed box-drawing rules (response, reasoning, streaming TTS, background-task Panels) reflow into multiple narrower rows — visible as duplicated horizontal separators / ghost lines in scrollback. Similarly, prompt_toolkit redraws a fresh status bar on SIGWINCH on top of one the terminal just reflowed, producing double-bar artifacts on column shrink. Two surgical changes: 1. Decorative scrollback boxes now use a new `HermesCLI._scrollback_box_width()` helper that clamps to `max(32, min(width, 56))`. The live TUI footer is unaffected and still uses the full width. Covers: streaming response box (open + close), reasoning box (open + close, both streaming and post-stream paths), streaming-TTS box close, final-response Rich Panel, and the background-task Rich Panel. 2. `_recover_after_resize()` now also sets a new `_status_bar_suppressed_after_resize` flag so the dynamic status bar and both input separator rules stay hidden until the next user input. The flag is cleared in the process loop the moment the user submits their next prompt, restoring chrome cleanly. Tests: - New `test_input_rules_hide_after_resize_until_next_input` covers the flag's effect on rule heights. - New `test_scrollback_box_width_caps_to_resize_safe_value` covers the helper at floor / cap / mid-range / overflow. - Existing resize-recovery test extended to assert the flag flips. Refs: #18449 #19280 #22976 Salvage of #24403. Co-authored-by: Szymonclawd <szymonclawd@mac.home>
2026-05-14 15:22:44 -07:00
bare_cli._status_bar_suppressed_after_resize = False
fix(cli): clear viewport on width-change resize so the status bar can't duplicate (#49120) The classic CLI status bar could appear twice after a horizontal terminal resize — two bars at two widths with two different elapsed readings. Root cause: prompt_toolkit's Application._on_resize() calls renderer.erase(), which does cursor_up(_cursor_pos.y) + erase_down() using the _cursor_pos.y cached from the LAST render at the OLD width (renderer.py:745). On a column shrink the terminal reflows the already-painted full-width chrome into extra physical rows, so the cached y undershoots: cursor_up doesn't climb past the reflowed rows and erase_down leaves the old bar stranded ABOVE the live origin. The next paint stacks a fresh bar below it. The existing post-resize suppression hides the NEW bar for ~0.35s but never erases the already-reflowed OLD one, so the ghost survives the whole window. Ctrl+L / /redraw clears it, confirming a viewport wipe is the fix. Fix: on a WIDTH change, _recover_after_resize now routes through the same recovery as Ctrl+L — _clear_prompt_toolkit_screen(rebuild_scrollback=False) (CSI 2J, visible viewport only) + _replay_output_history() — BEFORE delegating to prompt_toolkit's resize. Banner-safe: 2J never touches scrollback history (that's CSI 3J, which we don't send here), so the startup banner is preserved. Rows-only resizes skip the clear (no reflow → no ghost) to avoid an extra repaint. Tracks _last_resize_width to distinguish the two. Tests: replace the now-obsolete 'never clears on resize' assertion with two tests — rows-only resize delegates without clearing; width change clears the viewport + replays and never wipes scrollback.
2026-06-19 08:43:42 -07:00
bare_cli._last_resize_width = 120
# Same width on this resize → rows-only change.
monkeypatch.setattr(bare_cli, "_get_tui_terminal_width", lambda: 120)
monkeypatch.setattr(bare_cli, "_schedule_status_bar_unsuppress", lambda *_: None)
bare_cli._recover_after_resize(app, original_on_resize)
assert events == ["original_resize"]
app.renderer.reset.assert_not_called()
app.invalidate.assert_not_called()
# Must NOT clear the screen or scrollback — those destroy the banner.
app.renderer.output.erase_screen.assert_not_called()
app.renderer.output.write_raw.assert_not_called()
app.renderer.output.cursor_goto.assert_not_called()
fix(cli): clamp scrollback box widths + suppress status bar after resize (#25975) When the terminal shrinks, already-printed box-drawing rules (response, reasoning, streaming TTS, background-task Panels) reflow into multiple narrower rows — visible as duplicated horizontal separators / ghost lines in scrollback. Similarly, prompt_toolkit redraws a fresh status bar on SIGWINCH on top of one the terminal just reflowed, producing double-bar artifacts on column shrink. Two surgical changes: 1. Decorative scrollback boxes now use a new `HermesCLI._scrollback_box_width()` helper that clamps to `max(32, min(width, 56))`. The live TUI footer is unaffected and still uses the full width. Covers: streaming response box (open + close), reasoning box (open + close, both streaming and post-stream paths), streaming-TTS box close, final-response Rich Panel, and the background-task Rich Panel. 2. `_recover_after_resize()` now also sets a new `_status_bar_suppressed_after_resize` flag so the dynamic status bar and both input separator rules stay hidden until the next user input. The flag is cleared in the process loop the moment the user submits their next prompt, restoring chrome cleanly. Tests: - New `test_input_rules_hide_after_resize_until_next_input` covers the flag's effect on rule heights. - New `test_scrollback_box_width_caps_to_resize_safe_value` covers the helper at floor / cap / mid-range / overflow. - Existing resize-recovery test extended to assert the flag flips. Refs: #18449 #19280 #22976 Salvage of #24403. Co-authored-by: Szymonclawd <szymonclawd@mac.home>
2026-05-14 15:22:44 -07:00
# Status bar / input rules must be suppressed until the next prompt.
assert bare_cli._status_bar_suppressed_after_resize is True
fix(cli): clear viewport on width-change resize so the status bar can't duplicate (#49120) The classic CLI status bar could appear twice after a horizontal terminal resize — two bars at two widths with two different elapsed readings. Root cause: prompt_toolkit's Application._on_resize() calls renderer.erase(), which does cursor_up(_cursor_pos.y) + erase_down() using the _cursor_pos.y cached from the LAST render at the OLD width (renderer.py:745). On a column shrink the terminal reflows the already-painted full-width chrome into extra physical rows, so the cached y undershoots: cursor_up doesn't climb past the reflowed rows and erase_down leaves the old bar stranded ABOVE the live origin. The next paint stacks a fresh bar below it. The existing post-resize suppression hides the NEW bar for ~0.35s but never erases the already-reflowed OLD one, so the ghost survives the whole window. Ctrl+L / /redraw clears it, confirming a viewport wipe is the fix. Fix: on a WIDTH change, _recover_after_resize now routes through the same recovery as Ctrl+L — _clear_prompt_toolkit_screen(rebuild_scrollback=False) (CSI 2J, visible viewport only) + _replay_output_history() — BEFORE delegating to prompt_toolkit's resize. Banner-safe: 2J never touches scrollback history (that's CSI 3J, which we don't send here), so the startup banner is preserved. Rows-only resizes skip the clear (no reflow → no ghost) to avoid an extra repaint. Tracks _last_resize_width to distinguish the two. Tests: replace the now-obsolete 'never clears on resize' assertion with two tests — rows-only resize delegates without clearing; width change clears the viewport + replays and never wipes scrollback.
2026-06-19 08:43:42 -07:00
def test_resize_recovery_clears_viewport_on_width_change(self, bare_cli, monkeypatch):
"""A WIDTH change must wipe the visible viewport (CSI 2J) and replay.
On column shrink the terminal reflows the old full-width chrome into
extra rows that prompt_toolkit's stale-cursor erase cannot reach,
leaving a duplicated status bar (#19280/#5474 class). We route through
the same recovery as Ctrl+L: erase_screen (2J) + replay transcript.
It must be banner-safe CSI 3J (write_raw) must NOT fire.
"""
app = MagicMock()
events = []
app.renderer.output.erase_screen.side_effect = lambda: events.append("erase")
app.renderer.output.write_raw.side_effect = lambda *_: events.append("scrollback_wipe")
original_on_resize = lambda: events.append("original_resize")
bare_cli._status_bar_suppressed_after_resize = False
bare_cli._last_resize_width = 200
monkeypatch.setattr(bare_cli, "_get_tui_terminal_width", lambda: 90)
monkeypatch.setattr(bare_cli, "_schedule_status_bar_unsuppress", lambda *_: None)
monkeypatch.setattr(cli_mod, "_replay_output_history", lambda: events.append("replay"))
bare_cli._recover_after_resize(app, original_on_resize)
# Viewport cleared and transcript replayed BEFORE prompt_toolkit's resize.
assert "erase" in events
assert "replay" in events
assert events.index("erase") < events.index("original_resize")
# Banner-safe: scrollback (CSI 3J) must never be wiped on a resize.
assert "scrollback_wipe" not in events
# New width recorded for the next comparison.
assert bare_cli._last_resize_width == 90
assert bare_cli._status_bar_suppressed_after_resize is True
def test_force_redraw_uses_full_screen_clear_without_scrollback_clear(self, bare_cli):
app = MagicMock()
bare_cli._app = app
bare_cli._force_full_redraw()
app.renderer.output.erase_screen.assert_called_once()
app.renderer.output.cursor_goto.assert_called_once_with(0, 0)
app.renderer.output.write_raw.assert_not_called()
def test_resize_recovery_is_debounced(self, bare_cli, monkeypatch):
timers = []
calls = []
class FakeTimer:
def __init__(self, delay, callback):
self.delay = delay
self.callback = callback
self.cancelled = False
self.daemon = False
timers.append(self)
def start(self):
calls.append(("start", self.delay))
def cancel(self):
self.cancelled = True
calls.append(("cancel", self.delay))
def fire(self):
self.callback()
app = MagicMock()
app.loop.call_soon_threadsafe.side_effect = lambda cb: cb()
monkeypatch.setattr(cli_mod.threading, "Timer", FakeTimer)
monkeypatch.setattr(
bare_cli,
"_recover_after_resize",
lambda _app, _orig: calls.append(("recover", _orig())),
)
original_one = lambda: "first"
original_two = lambda: "second"
bare_cli._schedule_resize_recovery(app, original_one, delay=0.25)
assert bare_cli._resize_recovery_pending is True
bare_cli._schedule_resize_recovery(app, original_two, delay=0.25)
assert len(timers) == 2
assert timers[0].cancelled is True
timers[0].fire()
assert ("recover", "first") not in calls
timers[1].fire()
assert ("recover", "second") in calls
assert bare_cli._resize_recovery_pending is False
def test_invalidate_is_suppressed_while_resize_recovery_is_pending(self, bare_cli):
app = MagicMock()
bare_cli._app = app
bare_cli._last_invalidate = 0.0
bare_cli._resize_recovery_pending = True
bare_cli._invalidate(min_interval=0)
app.invalidate.assert_not_called()
2026-04-27 04:57:39 -07:00
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