Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 60 additions & 27 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2648,6 +2648,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 ""
Expand Down Expand Up @@ -3410,9 +3457,12 @@ def _recover_after_resize(self, app, original_on_resize) -> None:
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
Expand All @@ -3423,14 +3473,6 @@ def _recover_after_resize(self, app, original_on_resize) -> None:
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:
Expand Down Expand Up @@ -13936,26 +13978,17 @@ def get_prompt():
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

Expand Down
28 changes: 11 additions & 17 deletions tests/cli/test_cli_force_redraw.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,18 +71,14 @@ def test_sends_full_clear_replays_then_invalidates(self, bare_cli, monkeypatch):
"invalidate",
]

def test_resize_preserves_scrollback_and_resets_renderer(self, bare_cli, monkeypatch):
"""Resize recovery must NOT erase screen or scrollback.

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).
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.

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 = []
Expand All @@ -94,11 +90,9 @@ def test_resize_preserves_scrollback_and_resets_renderer(self, bare_cli, monkeyp
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()
Expand Down
102 changes: 18 additions & 84 deletions tests/cli/test_cli_status_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch

import cli as cli_mod
from cli import HermesCLI


Expand Down Expand Up @@ -104,91 +105,24 @@ def test_post_compression_sentinel_does_not_render_negative(self):
assert "-1" not in text
assert "0/200K" in text

def test_input_height_counts_wide_characters_using_cell_width(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):
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
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

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_counts_wide_characters_using_cell_width(self):
# 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

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(
Expand Down
Loading