diff --git a/cli.py b/cli.py index ff097532c..ed9e08131 100644 --- a/cli.py +++ b/cli.py @@ -7469,18 +7469,26 @@ class HermesCLI: # wrapping of long lines so the input area always fits its content. 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, len(self._get_tui_prompt_text())) - available_width = shutil.get_terminal_size().columns - prompt_width + prompt_width = max(2, get_cwidth(self._get_tui_prompt_text())) + try: + available_width = get_app().output.get_size().columns - prompt_width + 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 - if len(line) == 0: + # 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, -(-len(line) // available_width)) # ceil division + visual_lines += max(1, -(-line_width // available_width)) # ceil division return min(max(visual_lines, 1), 8) except Exception: return 1 diff --git a/tests/test_cli_status_bar.py b/tests/test_cli_status_bar.py index 104c58b1f..e728328b8 100644 --- a/tests/test_cli_status_bar.py +++ b/tests/test_cli_status_bar.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta from types import SimpleNamespace +from unittest.mock import MagicMock, patch from cli import HermesCLI @@ -78,6 +79,92 @@ class TestCLIStatusBar: assert "$0.06" not in text # cost hidden by default assert "15m" 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 + + 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_build_status_bar_text_no_cost_in_status_bar(self): cli_obj = _attach_agent( _make_cli(),