From 716e616d28ce108c3358b102296dee738d23be50 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:33:11 -0700 Subject: [PATCH] fix(tui): status bar duplicates and degrades during long sessions (#3291) shutil.get_terminal_size() can return stale/fallback values on SSH that differ from prompt_toolkit's actual terminal width. Fragments built for the wrong width overflow and wrap onto a second line (wrap_lines=True default), appearing as progressively degrading duplicates. - Read width from get_app().output.get_size().columns when inside a prompt_toolkit TUI, falling back to shutil outside TUI context - Add wrap_lines=False on the status bar Window as belt-and-suspenders guard against any future width mismatch Closes #3130 Co-authored-by: Mibayy --- cli.py | 27 ++++++++++- tests/test_cli_status_bar.py | 91 ++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/cli.py b/cli.py index e6ce2a95b..652cc1ec1 100644 --- a/cli.py +++ b/cli.py @@ -1329,7 +1329,12 @@ class HermesCLI: def _build_status_bar_text(self, width: Optional[int] = None) -> str: try: snapshot = self._get_status_bar_snapshot() - width = width or shutil.get_terminal_size((80, 24)).columns + if width is None: + try: + from prompt_toolkit.application import get_app + width = get_app().output.get_size().columns + except Exception: + width = shutil.get_terminal_size((80, 24)).columns percent = snapshot["context_percent"] percent_label = f"{percent}%" if percent is not None else "--" duration_label = snapshot["duration"] @@ -1359,7 +1364,16 @@ class HermesCLI: return [] try: snapshot = self._get_status_bar_snapshot() - width = shutil.get_terminal_size((80, 24)).columns + # Use prompt_toolkit's own terminal width when running inside the + # TUI — shutil.get_terminal_size() can return stale or fallback + # values (especially on SSH) that differ from what prompt_toolkit + # actually renders, causing the fragments to overflow to a second + # line and produce duplicated status bar rows over long sessions. + try: + from prompt_toolkit.application import get_app + width = get_app().output.get_size().columns + except Exception: + width = shutil.get_terminal_size((80, 24)).columns duration_label = snapshot["duration"] if width < 52: @@ -6894,6 +6908,15 @@ class HermesCLI: Window( content=FormattedTextControl(lambda: cli_ref._get_status_bar_fragments()), height=1, + # Prevent fragments that overflow the terminal width from + # wrapping onto a second line, which causes the status bar to + # appear duplicated (one full + one partial row) during long + # sessions, especially on SSH where shutil.get_terminal_size + # may return stale values. _get_status_bar_fragments now reads + # width from prompt_toolkit's own output object, so fragments + # will always fit; wrap_lines=False is the belt-and-suspenders + # guard against any future width mismatch. + wrap_lines=False, ), filter=Condition(lambda: cli_ref._status_bar_visible), ) diff --git a/tests/test_cli_status_bar.py b/tests/test_cli_status_bar.py index c1dd4b35b..936ec2190 100644 --- a/tests/test_cli_status_bar.py +++ b/tests/test_cli_status_bar.py @@ -182,3 +182,94 @@ class TestCLIUsageReport: assert "Total cost:" in output assert "n/a" in output assert "Pricing unknown for glm-5" in output + + +class TestStatusBarWidthSource: + """Ensure status bar fragments don't overflow the terminal width.""" + + def _make_wide_cli(self): + from datetime import datetime, timedelta + cli_obj = _attach_agent( + _make_cli(), + prompt_tokens=100_000, + completion_tokens=5_000, + total_tokens=105_000, + api_calls=20, + context_tokens=100_000, + context_length=200_000, + ) + cli_obj._status_bar_visible = True + return cli_obj + + def test_fragments_fit_within_announced_width(self): + """Total fragment text length must not exceed the width used to build them.""" + from unittest.mock import MagicMock, patch + cli_obj = self._make_wide_cli() + + for width in (40, 52, 76, 80, 120, 200): + mock_app = MagicMock() + mock_app.output.get_size.return_value = MagicMock(columns=width) + + with patch("prompt_toolkit.application.get_app", return_value=mock_app): + frags = cli_obj._get_status_bar_fragments() + + total_text = "".join(text for _, text in frags) + assert len(total_text) <= width + 4, ( # +4 for minor padding chars + f"At width={width}, fragment total {len(total_text)} chars overflows " + f"({total_text!r})" + ) + + def test_fragments_use_pt_width_over_shutil(self): + """When prompt_toolkit reports a width, shutil.get_terminal_size must not be used.""" + from unittest.mock import MagicMock, patch + cli_obj = self._make_wide_cli() + + mock_app = MagicMock() + mock_app.output.get_size.return_value = MagicMock(columns=120) + + with patch("prompt_toolkit.application.get_app", return_value=mock_app) as mock_get_app, \ + patch("shutil.get_terminal_size") as mock_shutil: + cli_obj._get_status_bar_fragments() + + mock_shutil.assert_not_called() + + def test_fragments_fall_back_to_shutil_when_no_app(self): + """Outside a TUI context (no running app), shutil must be used as fallback.""" + from unittest.mock import MagicMock, patch + cli_obj = self._make_wide_cli() + + with patch("prompt_toolkit.application.get_app", side_effect=Exception("no app")), \ + patch("shutil.get_terminal_size", return_value=MagicMock(columns=100)) as mock_shutil: + frags = cli_obj._get_status_bar_fragments() + + mock_shutil.assert_called() + assert len(frags) > 0 + + def test_build_status_bar_text_uses_pt_width(self): + """_build_status_bar_text() must also prefer prompt_toolkit width.""" + from unittest.mock import MagicMock, patch + cli_obj = self._make_wide_cli() + + mock_app = MagicMock() + mock_app.output.get_size.return_value = MagicMock(columns=80) + + with patch("prompt_toolkit.application.get_app", return_value=mock_app), \ + patch("shutil.get_terminal_size") as mock_shutil: + text = cli_obj._build_status_bar_text() # no explicit width + + mock_shutil.assert_not_called() + assert isinstance(text, str) + assert len(text) > 0 + + def test_explicit_width_skips_pt_lookup(self): + """An explicit width= argument must bypass both PT and shutil lookups.""" + from unittest.mock import patch + cli_obj = self._make_wide_cli() + + with patch("prompt_toolkit.application.get_app") as mock_get_app, \ + patch("shutil.get_terminal_size") as mock_shutil: + text = cli_obj._build_status_bar_text(width=100) + + mock_get_app.assert_not_called() + mock_shutil.assert_not_called() + assert len(text) > 0