fix(cli): let redraw recovery rebuild scrollback

This commit is contained in:
angeon
2026-07-07 01:34:29 +09:00
committed by Teknium
parent 0e1cba326b
commit 88a1a9fd95
3 changed files with 94 additions and 2 deletions
+32 -2
View File
@@ -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
+6
View File
@@ -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.
+56
View File
@@ -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 = []