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
|
|
|
|
|
|
2026-05-05 16:10:26 -06:00
|
|
|
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
|
|
|
|
|
|
2026-05-05 16:10:26 -06:00
|
|
|
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
|
2026-05-05 16:10:26 -06:00
|
|
|
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()
|
2026-05-05 16:10:26 -06:00
|
|
|
assert events == [
|
|
|
|
|
"reset_attrs",
|
|
|
|
|
"erase",
|
|
|
|
|
"home",
|
|
|
|
|
"flush",
|
|
|
|
|
"renderer_reset",
|
|
|
|
|
"replay",
|
|
|
|
|
"invalidate",
|
|
|
|
|
]
|
|
|
|
|
|
2026-06-01 18:00:22 -04:00
|
|
|
def test_resize_recovery_uses_prompt_toolkit_original_resize_before_reset(self, bare_cli, monkeypatch):
|
|
|
|
|
"""Resize recovery must preserve prompt_toolkit's tracked cursor state.
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
erase_down(). If Hermes resets the renderer first, that cursor position
|
|
|
|
|
is lost and stale prompt glyphs can remain after a narrow resize.
|
2026-05-10 14:09:22 +07:00
|
|
|
"""
|
2026-05-05 16:10:26 -06:00
|
|
|
app = MagicMock()
|
|
|
|
|
events = []
|
|
|
|
|
app.renderer.reset.side_effect = lambda **_: events.append("renderer_reset")
|
2026-05-10 14:09:22 +07:00
|
|
|
app.invalidate.side_effect = lambda: events.append("invalidate")
|
2026-05-05 16:10:26 -06:00
|
|
|
original_on_resize = lambda: events.append("original_resize")
|
|
|
|
|
|
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 skips __init__, so seed the attribute the way __init__ would.
|
|
|
|
|
bare_cli._status_bar_suppressed_after_resize = False
|
2026-05-05 16:10:26 -06:00
|
|
|
bare_cli._recover_after_resize(app, original_on_resize)
|
|
|
|
|
|
2026-06-01 18:00:22 -04:00
|
|
|
assert events == ["original_resize"]
|
|
|
|
|
app.renderer.reset.assert_not_called()
|
|
|
|
|
app.invalidate.assert_not_called()
|
2026-05-10 14:09:22 +07:00
|
|
|
# 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
|
2026-05-05 16:10:26 -06:00
|
|
|
|
|
|
|
|
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
|