fix(cli): handle CJK wide chars in TUI input height

This commit is contained in:
Tianxiao
2026-04-06 16:30:58 +02:00
committed by Teknium
parent 05f9267938
commit 8a29b49036
2 changed files with 100 additions and 5 deletions

18
cli.py
View File

@@ -7469,18 +7469,26 @@ class HermesCLI:
# wrapping of long lines so the input area always fits its content. # wrapping of long lines so the input area always fits its content.
def _input_height(): def _input_height():
try: try:
from prompt_toolkit.application import get_app
from prompt_toolkit.utils import get_cwidth
doc = input_area.buffer.document doc = input_area.buffer.document
prompt_width = max(2, len(self._get_tui_prompt_text())) prompt_width = max(2, get_cwidth(self._get_tui_prompt_text()))
available_width = shutil.get_terminal_size().columns - prompt_width 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: if available_width < 10:
available_width = 40 available_width = 40
visual_lines = 0 visual_lines = 0
for line in doc.lines: for line in doc.lines:
# Each logical line takes at least 1 visual row; long lines wrap # Each logical line takes at least 1 visual row; long lines wrap.
if len(line) == 0: # 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 visual_lines += 1
else: 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) return min(max(visual_lines, 1), 8)
except Exception: except Exception:
return 1 return 1

View File

@@ -1,5 +1,6 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from cli import HermesCLI from cli import HermesCLI
@@ -78,6 +79,92 @@ class TestCLIStatusBar:
assert "$0.06" not in text # cost hidden by default assert "$0.06" not in text # cost hidden by default
assert "15m" in text 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): def test_build_status_bar_text_no_cost_in_status_bar(self):
cli_obj = _attach_agent( cli_obj = _attach_agent(
_make_cli(), _make_cli(),