mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-15 09:21:36 +00:00
fix(cli): preserve renderer state on resize
This commit is contained in:
parent
c814d3d1dd
commit
2abcae9678
3 changed files with 87 additions and 126 deletions
|
|
@ -71,18 +71,14 @@ class TestForceFullRedraw:
|
|||
"invalidate",
|
||||
]
|
||||
|
||||
def test_resize_preserves_scrollback_and_resets_renderer(self, bare_cli, monkeypatch):
|
||||
"""Resize recovery must NOT erase screen or scrollback.
|
||||
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.
|
||||
|
||||
The startup banner lives in normal terminal scrollback (printed
|
||||
before prompt_toolkit owns the chrome). Clearing scrollback on
|
||||
SIGWINCH removes it and ``_replay_output_history`` cannot
|
||||
reconstruct it. The fix is to only reset the renderer cache and
|
||||
let ``original_on_resize`` recalculate layout.
|
||||
|
||||
Additionally, ``_status_bar_suppressed_after_resize`` must be set
|
||||
so the input rules and status bar hide until the next user input,
|
||||
preventing duplicated-bar artifacts on column shrink (#19280).
|
||||
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.
|
||||
"""
|
||||
app = MagicMock()
|
||||
events = []
|
||||
|
|
@ -94,11 +90,9 @@ class TestForceFullRedraw:
|
|||
bare_cli._status_bar_suppressed_after_resize = False
|
||||
bare_cli._recover_after_resize(app, original_on_resize)
|
||||
|
||||
assert events == [
|
||||
"renderer_reset",
|
||||
"invalidate",
|
||||
"original_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()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from datetime import datetime, timedelta
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import cli as cli_mod
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
|
|
@ -104,91 +105,24 @@ class TestCLIStatusBar:
|
|||
assert "-1" not in text
|
||||
assert "0/200K" in text
|
||||
|
||||
def test_input_height_counts_prompt_only_on_first_wrapped_row(self):
|
||||
# Regression for prompt_toolkit classic CLI resize glitches: the prompt
|
||||
# is inserted by BeforeInput only on logical line 0. At three terminal
|
||||
# cells, "⚔ " leaves one cell for the first input character, but
|
||||
# wrapped continuation rows use the full three cells. Estimating every
|
||||
# wrapped row as one-cell wide over-allocates the TextArea and can leave
|
||||
# stale prompt/input cells visible after resize.
|
||||
assert cli_mod._estimate_tui_input_height(["abcdef"], "⚔ ", 3) == 3
|
||||
|
||||
def test_input_height_counts_wide_characters_using_cell_width(self):
|
||||
cli_obj = _make_cli()
|
||||
# Prompt width (2 cells) + ten CJK chars (20 cells) = 22 display cells,
|
||||
# which wraps to two rows at 14 terminal columns.
|
||||
assert cli_mod._estimate_tui_input_height(["你" * 10], "❯ ", 14) == 2
|
||||
|
||||
class _Doc:
|
||||
lines = ["你" * 10]
|
||||
|
||||
class _Buffer:
|
||||
document = _Doc()
|
||||
|
||||
input_area = SimpleNamespace(buffer=_Buffer())
|
||||
|
||||
def _input_height():
|
||||
try:
|
||||
from prompt_toolkit.application import get_app
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
|
||||
doc = input_area.buffer.document
|
||||
prompt_width = max(2, get_cwidth(cli_obj._get_tui_prompt_text()))
|
||||
try:
|
||||
available_width = get_app().output.get_size().columns - prompt_width
|
||||
except Exception:
|
||||
import shutil
|
||||
available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width
|
||||
if available_width < 10:
|
||||
available_width = 40
|
||||
visual_lines = 0
|
||||
for line in doc.lines:
|
||||
line_width = get_cwidth(line)
|
||||
if line_width <= 0:
|
||||
visual_lines += 1
|
||||
else:
|
||||
visual_lines += max(1, -(-line_width // available_width))
|
||||
return min(max(visual_lines, 1), 8)
|
||||
except Exception:
|
||||
return 1
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.output.get_size.return_value = MagicMock(columns=14)
|
||||
with patch.object(HermesCLI, "_get_tui_prompt_text", return_value="❯ "), \
|
||||
patch("prompt_toolkit.application.get_app", return_value=mock_app):
|
||||
assert _input_height() == 2
|
||||
|
||||
def test_input_height_uses_prompt_toolkit_width_over_shutil(self):
|
||||
cli_obj = _make_cli()
|
||||
|
||||
class _Doc:
|
||||
lines = ["你" * 10]
|
||||
|
||||
class _Buffer:
|
||||
document = _Doc()
|
||||
|
||||
input_area = SimpleNamespace(buffer=_Buffer())
|
||||
|
||||
def _input_height():
|
||||
try:
|
||||
from prompt_toolkit.application import get_app
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
|
||||
doc = input_area.buffer.document
|
||||
prompt_width = max(2, get_cwidth(cli_obj._get_tui_prompt_text()))
|
||||
try:
|
||||
available_width = get_app().output.get_size().columns - prompt_width
|
||||
except Exception:
|
||||
import shutil
|
||||
available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width
|
||||
if available_width < 10:
|
||||
available_width = 40
|
||||
visual_lines = 0
|
||||
for line in doc.lines:
|
||||
line_width = get_cwidth(line)
|
||||
if line_width <= 0:
|
||||
visual_lines += 1
|
||||
else:
|
||||
visual_lines += max(1, -(-line_width // available_width))
|
||||
return min(max(visual_lines, 1), 8)
|
||||
except Exception:
|
||||
return 1
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.output.get_size.return_value = MagicMock(columns=14)
|
||||
with patch.object(HermesCLI, "_get_tui_prompt_text", return_value="❯ "), \
|
||||
patch("prompt_toolkit.application.get_app", return_value=mock_app), \
|
||||
patch("shutil.get_terminal_size") as mock_shutil:
|
||||
assert _input_height() == 2
|
||||
mock_shutil.assert_not_called()
|
||||
def test_input_height_clamps_zero_width_to_one_cell(self):
|
||||
# Some terminals briefly report zero columns during resize. Treat that
|
||||
# as a one-cell terminal rather than falling back to a fake wide width.
|
||||
assert cli_mod._estimate_tui_input_height(["abcd"], "", 0) == 4
|
||||
|
||||
def test_build_status_bar_text_no_cost_in_status_bar(self):
|
||||
cli_obj = _attach_agent(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue