fix(cli): recover classic CLI output after resize

This commit is contained in:
helix4u 2026-05-05 16:10:26 -06:00 committed by Teknium
parent 17687911b7
commit 76074d9ee6
5 changed files with 389 additions and 21 deletions

View file

@ -13,6 +13,7 @@ from unittest.mock import MagicMock
import pytest
import cli as cli_mod
from cli import HermesCLI
@ -33,10 +34,18 @@ class TestForceFullRedraw:
# 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):
def test_sends_full_clear_replays_then_invalidates(self, bare_cli, monkeypatch):
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")
bare_cli._force_full_redraw()
@ -52,6 +61,109 @@ class TestForceFullRedraw:
# Must schedule a repaint.
app.invalidate.assert_called_once()
assert events == [
"reset_attrs",
"erase",
"home",
"flush",
"renderer_reset",
"replay",
"invalidate",
]
def test_resize_rebuilds_scrollback_before_prompt_toolkit_redraw(self, bare_cli, monkeypatch):
app = MagicMock()
out = app.renderer.output
events = []
out.reset_attributes.side_effect = lambda: events.append("reset_attrs")
out.erase_screen.side_effect = lambda: events.append("erase")
out.write_raw.side_effect = lambda text: events.append(("raw", text))
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"))
original_on_resize = lambda: events.append("original_resize")
bare_cli._recover_after_resize(app, original_on_resize)
assert events == [
"reset_attrs",
"erase",
("raw", "\x1b[3J"),
"home",
"flush",
"renderer_reset",
"replay",
"original_resize",
]
app.invalidate.assert_not_called()
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()
def test_swallows_renderer_exceptions(self, bare_cli):
# If the renderer blows up for any reason, the helper must not

View file

@ -16,9 +16,18 @@ import sys
import types
from types import SimpleNamespace
import pytest
import cli
@pytest.fixture(autouse=True)
def reset_output_history():
cli._configure_output_history(False, 200)
yield
cli._configure_output_history(True, 200)
def test_cprint_no_app_direct_print(monkeypatch):
"""No active app → direct _pt_print, no run_in_terminal involvement."""
calls = []
@ -204,3 +213,69 @@ def test_cprint_swallows_prompt_toolkit_import_error(monkeypatch):
sys.meta_path.remove(blocker)
assert direct_prints == ["fallback2"]
def test_output_history_strips_ansi_and_keeps_recent_lines():
cli._configure_output_history(True, 10)
for idx in range(12):
cli._record_output_history(f"\x1b[31mline-{idx}\x1b[0m")
assert list(cli._OUTPUT_HISTORY) == [f"line-{idx}" for idx in range(2, 12)]
def test_replay_output_history_does_not_record_replayed_lines(monkeypatch):
cli._configure_output_history(True, 10)
cli._record_output_history("visible output")
printed = []
def _fake_print(value):
printed.append(value)
cli._record_output_history("duplicated replay")
monkeypatch.setattr(cli, "_pt_print", _fake_print)
monkeypatch.setattr(cli, "_PT_ANSI", lambda text: text)
cli._replay_output_history()
assert printed == ["visible output"]
assert list(cli._OUTPUT_HISTORY) == ["visible output"]
def test_replay_output_history_rerenders_callable_entries(monkeypatch):
cli._configure_output_history(True, 10)
widths_seen = []
printed = []
def _render_current_width():
widths_seen.append("called")
return ["top border", "body"]
cli._record_output_history_entry(_render_current_width)
monkeypatch.setattr(cli, "_pt_print", lambda value: printed.append(value))
monkeypatch.setattr(cli, "_PT_ANSI", lambda text: text)
cli._replay_output_history()
assert widths_seen == ["called"]
assert printed == ["top border", "body"]
assert list(cli._OUTPUT_HISTORY) == [_render_current_width]
def test_suspend_output_history_blocks_recording():
cli._configure_output_history(True, 10)
with cli._suspend_output_history():
cli._record_output_history("hidden")
cli._record_output_history_entry("also hidden")
assert list(cli._OUTPUT_HISTORY) == []
def test_clear_output_history_removes_replayable_lines():
cli._configure_output_history(True, 10)
cli._record_output_history("before clear")
cli._clear_output_history()
assert list(cli._OUTPUT_HISTORY) == []

View file

@ -11,6 +11,7 @@ from io import StringIO
from unittest.mock import MagicMock, patch
import pytest
import cli as cli_mod
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
@ -286,6 +287,21 @@ class TestDisplayResumedHistory:
assert "Previous Conversation" in output
def test_panel_is_stored_as_resize_aware_history_entry(self):
cli = _make_cli()
cli.conversation_history = _simple_history()
cli_mod._configure_output_history(True, 10)
cli_mod._clear_output_history()
try:
output = self._capture_display(cli)
assert "Previous Conversation" in output
assert len(cli_mod._OUTPUT_HISTORY) == 1
assert callable(cli_mod._OUTPUT_HISTORY[0])
finally:
cli_mod._configure_output_history(True, 200)
def test_assistant_with_no_content_no_tools_skipped(self):
"""Assistant messages with no visible output (e.g. pure reasoning)
are skipped in the recap."""