From 88a1a9fd95673bcb508174855bcd591067ed8c62 Mon Sep 17 00:00:00 2001 From: angeon Date: Tue, 7 Jul 2026 01:34:29 +0900 Subject: [PATCH] fix(cli): let redraw recovery rebuild scrollback --- cli.py | 34 ++++++++++++++++-- hermes_cli/config_defaults.py | 6 ++++ tests/cli/test_cli_force_redraw.py | 56 ++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/cli.py b/cli.py index 0cb5da6bd1..31d9338774 100644 --- a/cli.py +++ b/cli.py @@ -501,6 +501,12 @@ def load_cli_config() -> Dict[str, Any]: "busy_input_mode": "interrupt", "persistent_output": True, "persistent_output_max_lines": 200, + # Clear terminal scrollback as well as the visible viewport when the + # classic CLI performs a full redraw/resize recovery. Disabled by + # default because some users prefer preserving terminal history; + # enable when a terminal/tmux stack stamps stale prompt chrome into + # scrollback during fullscreen/restore resizes. + "cli_rebuild_scrollback_on_redraw": False, # Print a one-line summary of resolved modal prompts (approval / # clarify) into scrollback so the decision survives the repaint. "persist_prompts": True, @@ -5013,13 +5019,34 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): app = getattr(self, "_app", None) if not app: return - self._clear_prompt_toolkit_screen(app) + self._clear_prompt_toolkit_screen( + app, + rebuild_scrollback=self._redraw_rebuilds_scrollback(), + ) _replay_output_history() try: app.invalidate() except Exception: pass + @staticmethod + def _redraw_rebuilds_scrollback() -> bool: + """Return whether CLI redraw/resize recovery should clear scrollback. + + Some terminal/tmux stacks move prompt_toolkit's non-fullscreen bottom + chrome into scrollback when the window is maximized/restored. A normal + CSI 2J viewport clear cannot remove those stale prompt/input-rule rows, + so users who hit that class of bug need CSI 3J as well, followed by the + existing bounded output-history replay. + """ + display_config = CLI_CONFIG.get("display") if isinstance(CLI_CONFIG, dict) else {} + if not isinstance(display_config, dict): + display_config = {} + raw = display_config.get("cli_rebuild_scrollback_on_redraw", False) + if isinstance(raw, str): + return raw.strip().lower() in {"1", "true", "yes", "on", "always"} + return bool(raw) + def _recover_terminal_after_interrupt(self) -> None: """Recover the terminal after an interrupted agent turn (#33271). @@ -5160,7 +5187,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): ) if width_changed: try: - self._clear_prompt_toolkit_screen(app, rebuild_scrollback=False) + self._clear_prompt_toolkit_screen( + app, + rebuild_scrollback=self._redraw_rebuilds_scrollback(), + ) _replay_output_history() except Exception: pass diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 5fa6dfd2a1..8bc7f57403 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -1214,6 +1214,12 @@ DEFAULT_CONFIG = { # behaves badly with replayed scrollback. "persistent_output": True, "persistent_output_max_lines": 200, + # Clear terminal scrollback as well as the visible viewport when the + # classic CLI performs a full redraw/resize recovery. Disabled by + # default because some users prefer preserving terminal history; + # enable when a terminal/tmux stack stamps stale prompt chrome into + # scrollback during fullscreen/restore window transitions. + "cli_rebuild_scrollback_on_redraw": False, # Print a one-line summary of resolved modal prompts (approval / # clarify) into scrollback so the question and decision survive the # panel repaint. Set false to keep scrollback untouched. diff --git a/tests/cli/test_cli_force_redraw.py b/tests/cli/test_cli_force_redraw.py index 5e4dd1201e..a2fe7abcea 100644 --- a/tests/cli/test_cli_force_redraw.py +++ b/tests/cli/test_cli_force_redraw.py @@ -53,6 +53,11 @@ class TestForceFullRedraw: 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")) + monkeypatch.setattr( + cli_mod, + "CLI_CONFIG", + {"display": {"cli_rebuild_scrollback_on_redraw": False}}, + ) bare_cli._recover_after_resize(app, original_on_resize) @@ -66,6 +71,57 @@ class TestForceFullRedraw: 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, monkeypatch): + app = MagicMock() + bare_cli._app = app + monkeypatch.setattr( + cli_mod, + "CLI_CONFIG", + {"display": {"cli_rebuild_scrollback_on_redraw": False}}, + ) + + 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_force_redraw_can_clear_scrollback_when_configured(self, bare_cli, monkeypatch): + app = MagicMock() + bare_cli._app = app + monkeypatch.setattr( + cli_mod, + "CLI_CONFIG", + {"display": {"cli_rebuild_scrollback_on_redraw": True}}, + ) + + bare_cli._force_full_redraw() + + app.renderer.output.erase_screen.assert_called_once() + app.renderer.output.write_raw.assert_called_once_with("\x1b[3J") + + def test_resize_recovery_can_clear_scrollback_when_configured(self, bare_cli, monkeypatch): + 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")) + monkeypatch.setattr( + cli_mod, + "CLI_CONFIG", + {"display": {"cli_rebuild_scrollback_on_redraw": "true"}}, + ) + + bare_cli._recover_after_resize(app, original_on_resize) + + assert events[:3] == ["erase", "scrollback_wipe", "replay"] + assert events.index("scrollback_wipe") < events.index("original_resize") def test_resize_recovery_is_debounced(self, bare_cli, monkeypatch): timers = []