diff --git a/cli.py b/cli.py index bc5ced017ad..7b26ccadf4e 100644 --- a/cli.py +++ b/cli.py @@ -2827,6 +2827,53 @@ def _strip_leaked_terminal_responses(text: str) -> str: return cleaned +def _estimate_tui_input_height( + lines: list[str] | tuple[str, ...], + prompt_text: str, + terminal_columns: int, + *, + max_height: int = 8, +) -> int: + """Estimate classic prompt_toolkit input rows using live terminal cells. + + The TextArea prompt is injected with prompt_toolkit's BeforeInput + processor, which means it consumes cells only on logical line 0. After a + narrow resize, that first row can leave only one input cell beside an icon + prompt such as ``⚔ ``, while continuation rows use the full terminal width. + Never substitute a fake wide fallback here: under- or over-allocating the + TextArea height leaves stale prompt/input cells visible at the bottom of the + terminal. + """ + try: + from prompt_toolkit.utils import get_cwidth + except Exception: + get_cwidth = lambda value: len(value or "") # type: ignore[assignment] + + try: + columns = int(terminal_columns or 0) + except (TypeError, ValueError): + columns = 0 + + columns = max(1, columns) + prompt_width = max(0, get_cwidth(prompt_text or "")) + + visual_lines = 0 + for index, line in enumerate(lines or [""]): + # prompt_toolkit's TextArea injects ``prompt`` via BeforeInput, which + # applies only to logical line 0. Wrapped continuation rows, and later + # logical lines, use the full terminal width. Count the display cells + # after that same transformation rather than subtracting the prompt from + # every wrapped row. + line_width = get_cwidth(line or "") + display_width = line_width + (prompt_width if index == 0 else 0) + if display_width <= 0: + visual_lines += 1 + else: + visual_lines += max(1, -(-display_width // columns)) + + return min(max(visual_lines, 1), max(1, int(max_height or 1))) + + def _collect_query_images(query: str | None, image_arg: str | None = None) -> tuple[str, list[Path]]: """Collect local image attachments for single-query CLI flows.""" message = query or "" @@ -3689,9 +3736,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): startup UI and ``_replay_output_history`` cannot reconstruct it (the banner was never added to ``_OUTPUT_HISTORY``). - Instead we just reset prompt_toolkit's renderer cache so the next - incremental redraw starts from a clean slate, then let - ``original_on_resize`` recalculate layout for the new size. + Let prompt_toolkit's own resize path run with its renderer cursor + cache intact. Its Application._on_resize() starts with + renderer.erase(leave_alternate_screen=False), which needs the cached + cursor position to move back to the live prompt origin before + erase_down(). Resetting the renderer before that erase loses the + origin and can leave stale prompt glyphs after a narrow resize. We also flag ``_status_bar_suppressed_after_resize`` so the dynamic status bar and input separator rules stay hidden until the next user @@ -3702,14 +3752,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): next prompt restores the bar cleanly. """ self._status_bar_suppressed_after_resize = True - try: - app.renderer.reset(leave_alternate_screen=False) - except Exception: - pass - try: - app.invalidate() - except Exception: - pass original_on_resize() def _schedule_resize_recovery(self, app, original_on_resize, delay: float = 0.12) -> None: @@ -12004,26 +12046,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): 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(self._get_tui_prompt_text())) try: - available_width = get_app().output.get_size().columns - prompt_width + terminal_columns = get_app().output.get_size().columns except Exception: - 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: - # Each logical line takes at least 1 visual row; long lines wrap. - # Use prompt_toolkit's cell width so CJK wide characters count as 2. - line_width = get_cwidth(line) - if line_width <= 0: - visual_lines += 1 - else: - visual_lines += max(1, -(-line_width // available_width)) # ceil division - return min(max(visual_lines, 1), 8) + terminal_columns = shutil.get_terminal_size((80, 24)).columns + return _estimate_tui_input_height( + doc.lines, + self._get_tui_prompt_text(), + terminal_columns, + ) except Exception: return 1 diff --git a/tests/cli/test_cli_force_redraw.py b/tests/cli/test_cli_force_redraw.py index 34f5cefe06e..489105f2f20 100644 --- a/tests/cli/test_cli_force_redraw.py +++ b/tests/cli/test_cli_force_redraw.py @@ -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() diff --git a/tests/cli/test_cli_status_bar.py b/tests/cli/test_cli_status_bar.py index c6a131a5131..36587bff722 100644 --- a/tests/cli/test_cli_status_bar.py +++ b/tests/cli/test_cli_status_bar.py @@ -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(