TUI status bar duplication and rendering bugs
NousResearch/hermes-agent · cluster 3130 · hermes cli
Data as of
Nothing open on GitHub. Fix merged in #3291. Check it covers your case.
The short answer
- Known?
- Yes. 5 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #3291. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PRs #3291, #16527. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Multiple issues and fixes addressing status bar duplication, ghost rows, and in-place update failures during CLI/TUI sessions. Also includes a related fix to skip a spinner component when the TUI handles tool progress itself.
Open issues0
Open PRs0
Fixes on record
-
#3291 fix(tui): status bar duplicates and degrades during long sessions
files:
cli.py,tests/test_cli_status_bar.pyShow the diff
-
cli.py@@ -1329,7 +1329,12 @@ def _get_status_bar_snapshot(self) -> Dict[str, Any]: 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 @@ def _get_status_bar_fragments(self): 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 @@ def _get_voice_status(): 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), ) -
tests/test_cli_status_bar.py@@ -182,3 +182,94 @@ def test_zero_priced_provider_models_stay_unknown(self, capsys): 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=[redacted:secret-assignment] + completion_tokens=[redacted:secret-assignment] + total_tokens=[redacted:secret-assignment] + api_calls=20, + context_tokens=[redacted:secret-assignment] + 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
-
-
#16527 fix(cli): eliminate ghost status-bar + DSR input leaks from terminal drift
files:
cli.py,hermes_cli/commands.py,scripts/release.py,tests/cli/test_cli_force_redraw.py,tests/cli/test_cli_terminal_response_sanitizer.pyShow the diff
-
cli.py@@ -1574,6 +1574,34 @@ def _strip_leaked_bracketed_paste_wrappers(text: str) -> str: return text +# Cursor Position Report (CPR / DSR) response, format ``ESC[<row>;<col>R``. +# prompt_toolkit's _on_resize() + renderer send ``ESC[6n`` queries to the +# terminal; under resize storms or tab switches the terminal's reply can +# race past the input parser and end up in the input buffer as literal +# text (see issue #14692). Also matches the visible-form ``^[[<row>;<col>R`` +# that appears when the ESC byte was stripped by a prior filter. +_DSR_CPR_ESC_RE = re.compile(r"\x1b\[\d+;\d+R") +_DSR_CPR_VISIBLE_RE = re.compile(r"\^\[\[\d+;\d+R") + + +def _strip_leaked_terminal_responses(text: str) -> str: + """Strip leaked terminal control-response sequences from user input. + + Covers Cursor Position Report (CPR / DSR) responses — ``ESC[<row>;<col>R`` + and the visible ``^[[<row>;<col>R`` form. These are replies the terminal + sends back to queries prompt_toolkit makes during ``_on_resize`` / + ``_request_absolute_cursor_position``. When the input parser drops one + (resize storms, multiplexer focus changes, slow PTYs) the response + lands in the input buffer as literal text and corrupts what the user + typed. + """ + if not text: + return text + text = _DSR_CPR_ESC_RE.sub("", text) + text = _DSR_CPR_VISIBLE_RE.sub("", text) + return text + + 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 "" @@ -2182,6 +2210,42 @@ def _invalidate(self, min_interval: float = 0.25) -> None: self._last_invalidate = now self._app.invalidate() + def _force_full_redraw(self) -> None: + """Force a clean full-screen repaint of the prompt_toolkit UI. + + Used to recover from terminal buffer drift caused by external + redraws we can't detect — e.g. macOS cmux / tmux tab switches, + ``clear`` issued from a subshell, or SSH window restores. These + wipe or repaint the terminal without firing SIGWINCH, so + prompt_toolkit's tracked ``_cursor_pos`` no longer matches reality + and the next incremental redraw stacks on top of stale content + (ghost status bars, duplicated prompts). + + Bound to Ctrl+L and exposed as the ``/redraw`` slash command, + matching the standard terminal-UX convention (bash, zsh, fish, + vim, htop). + """ + app = getattr(self, "_app", None) + if not app: + return + try: + renderer = app.renderer + out = renderer.output + out.reset_attributes() + out.erase_screen() + out.cursor_goto(0, 0) + out.flush() + # Drop prompt_toolkit's cached screen + cursor state so the + # next _redraw() starts from a known (0, 0) origin and + # re-renders every cell rather than diffing against stale. + renderer.reset(leave_alternate_screen=False) + except Exception: + pass + try: + app.invalidate() + except Exception: + pass + def _status_bar_context_style(self, percent_used: Optional[int]) -> str: if percent_used is None: return "class:status-bar-dim" @@ -5998,6 +6062,12 @@ def process_command(self, command: str) -> bool: self.show_toolsets() elif canonical == "config": self.show_config() + elif canonical == "redraw": + # Manual recovery for terminal buffer drift from multiplexer + # tab switches, subshell ``clear``, SSH window restores, etc. + # See issue #8688 (cmux). Ctrl+L is bound to the same helper. + self._force_full_redraw() + _cprint(f" {_DIM}✓ UI redrawn{_RST}") elif canonical == "clear": self.new_session(silent=True) # Clear terminal screen. Inside the TUI, Rich's console.clear() @@ -9563,6 +9633,17 @@ def history_down(event): """Down arrow: browse history when on last line, else move cursor down.""" event.app.current_buffer.auto_down(count=event.arg) + @kb.add('c-l') + def handle_ctrl_l(event): + """Ctrl+L: force a clean full-screen repaint. + + Recovers the UI after external terminal buffer drift — tmux / + cmux tab switches, ``clear`` from a subshell, SSH window + restores, etc. — that prompt_toolkit can't detect on its own. + Matches the universal bash/zsh/fish/vim/htop convention. + """ + self._force_full_redraw() + @kb.add('c-c') def handle_ctrl_c(event): """Handle Ctrl+C - cancel interactive prompts, interrupt agent, or exit. @@ -9795,6 +9876,7 @@ def handle_paste(event): # so the 5-line collapse threshold and display are consistent. pasted_text = pasted_text.replace('\r\n', '\n').replace('\r', '\n') pasted_text = _strip_leaked_bracketed_paste_wrappers(pasted_text) + pasted_text = _strip_leaked_terminal_responses(pasted_text) if _should_auto_attach_clipboard_image_on_paste(pasted_text) and self._try_attach_clipboard_image(): event.app.invalidate() if pasted_text: @@ -9937,6 +10019,7 @@ def _on_text_changed(buf): event so it never triggers this. """ text = _strip_leaked_bracketed_paste_wrappers(buf.text) + text = _strip_leaked_terminal_responses(text) if text != buf.text: cursor = min(buf.cursor_position, len(text)) _paste_just_collapsed[0] = True @@ -10601,36 +10684,30 @@ def _get_voice_status(): # only cursor_up()s by the stored layout height, missing the extra # rows created by reflow — leaving ghost duplicates visible. # - # Fix: before the standard erase, inflate _cursor_pos.y so the - # cursor moves up far enough to cover the reflowed ghost content. + # It's not just column-shrink: widening, row-shrinking, and + # multiplexer-driven SIGWINCH-less redraws (cmux / tmux tab switch) + # all produce the same class of drift, where the renderer's tracked + # _cursor_pos.y no longer matches terminal reality. The only reliable + # recovery is a full screen-clear (\x1b[2J\x1b[H) before the next + # redraw, so we force one on every resize rather than trying to + # compute the exact drift. _original_on_resize = app._on_resize def _resize_clear_ghosts(): - from prompt_toolkit.data_structures import Point as _Pt renderer = app.renderer try: - old_size = renderer._last_size - new_size = renderer.output.get_size() - if ( - old_size - and new_size.columns < old_size.columns - and new_size.columns > 0 - ): - reflow_factor = ( - (old_size.columns + new_size.columns - 1) - // new_size.columns - ) - last_h = ( - renderer._last_screen.height - if renderer._last_screen - else 0 - ) - extra = last_h * (reflow_factor - 1) - if extra > 0: - renderer._cursor_pos = _Pt( - x=renderer._cursor_pos.x, - y=renderer._cursor_pos.y + extra, - ) + out = renderer.output + # Reset attributes, erase the entire screen, and home the + # cursor. This overwrites any reflowed status-bar rows or + # stale content the terminal kept from the prior layout. + out.reset_attributes() + out.erase_screen() + out.cursor_goto(0, 0) + out.flush() + # Tell the renderer its tracked position is fresh so its + # own erase() inside _on_resize doesn't cursor_up() past + # the top of the screen. + renderer.reset(leave_alternate_screen=False) except Exception: pass # never break resize handling _original_on_resize() @@ -10638,7 +10715,6 @@ def _resize_clear_ghosts(): app._on_resize = _resize_clear_ghosts def spinner_loop(): - last_idle_refresh = 0.0 while not self._should_exit: if not self._app: time.sleep(0.1) @@ -10647,10 +10723,11 @@ def spinner_loop(): self._invalidate(min_interval=0.1) time.sleep(0.1) else: - now = time.monotonic() - if now - last_idle_refresh >= 1.0: - last_idle_refresh = now - self._invalidate(min_interval=1.0) + # Do not repaint the idle prompt every second. In non-full-screen + # prompt_toolkit mode, background redraws can fight tmux/Ghostty/cmux + # viewport restoration after focus changes and visually move the + # command input area. Keep idle stable; input/agent events still + # invalidate explicitly when the UI actually changes. time.sleep(0.2) spinner_thread = threading.Thread(target=spinner_loop, daemon=True) @@ -10695,6 +10772,7 @@ def process_loop(): if isinstance(user_input, str): user_input = _strip_leaked_bracketed_paste_wrappers(user_input) + user_input = _strip_leaked_terminal_responses(user_input) # Check for commands — but detect dragged/pasted file paths first. # See _detect_file_drop() for details. -
hermes_cli/commands.py@@ -62,6 +62,8 @@ class CommandDef: aliases=("reset",)), CommandDef("clear", "Clear screen and start a new session", "Session", cli_only=True), + CommandDef("redraw", "Force a full UI repaint (recovers from terminal drift)", "Session", + cli_only=True), CommandDef("history", "Show conversation history", "Session", cli_only=True), CommandDef("save", "Save the current conversation", "Session", -
scripts/release.py@@ -61,6 +61,7 @@ "[redacted:email]": "BadTechBandit", "[redacted:email]": "Yoimex", "[redacted:email]": "romanornr", + "[redacted:email]": "foxion37", # contributors (from noreply pattern) "[redacted:email]": "davidvv", "[redacted:email]": "xiaoqiang243", -
tests/cli/test_cli_force_redraw.py@@ -0,0 +1,73 @@ +"""Tests for CLI redraw helpers used to recover from terminal buffer drift. + +Covers: + - _force_full_redraw (#8688 cmux tab switch, /redraw, Ctrl+L) + - the resize handler we install over prompt_toolkit's _on_resize (#5474) + +Both behaviors are exercised against fake prompt_toolkit renderer/output +objects — we're asserting the escape sequences the CLI sends, not that +the terminal physically repainted. +""" + +from unittest.mock import MagicMock + +import pytest + +from cli import HermesCLI + + [redacted:email] +def bare_cli(): + """A HermesCLI with no __init__ — we only exercise the redraw helper.""" + cli = object.__new__(HermesCLI) + return cli + + +class TestForceFullRedraw: + def test_no_app_is_safe(self, bare_cli): + # _force_full_redraw must be a no-op when the TUI isn't running. + bare_cli._app = None + bare_cli._force_full_redraw() # must not raise + + def test_missing_app_attr_is_safe(self, bare_cli): + # Simulate HermesCLI before the TUI has ever been constructed. + bare_cli._force_full_redraw() # must not raise + + def test_sends_full_clear_and_invalidates(self, bare_cli): + app = MagicMock() + out = app.renderer.output + bare_cli._app = app + + bare_cli._force_full_redraw() + + # Must erase screen, home cursor, and flush — in that order. + out.reset_attributes.assert_called_once() + out.erase_screen.assert_called_once() + out.cursor_goto.assert_called_once_with(0, 0) + out.flush.assert_called_once() + + # Must reset prompt_toolkit's tracked screen/cursor state so the + # next incremental redraw starts from a clean (0, 0) origin. + app.renderer.reset.assert_called_once_with(leave_alternate_screen=False) + + # Must schedule a repaint. + app.invalidate.assert_called_once() + + def test_swallows_renderer_exceptions(self, bare_cli): + # If the renderer blows up for any reason, the helper must not + # propagate — otherwise a stray Ctrl+L would crash the CLI. + app = MagicMock() + app.renderer.output.erase_screen.side_effect = RuntimeError("boom") + bare_cli._app = app + + bare_cli._force_full_redraw() # must not raise + + # invalidate() is still attempted after a renderer failure. + app.invalidate.assert_called_once() + + def test_swallows_invalidate_exceptions(self, bare_cli): + app = MagicMock() + app.invalidate.side_effect = RuntimeError("boom") + bare_cli._app = app + + bare_cli._force_full_redraw() # must not raise -
tests/cli/test_cli_terminal_response_sanitizer.py@@ -0,0 +1,57 @@ +"""Tests for defensive terminal control-response stripping in the CLI. + +Covers Cursor Position Report (CPR / DSR) responses that occasionally +leak into the input buffer after terminal resize storms or multiplexer +tab switches — see issue #14692. +""" + +from cli import _strip_leaked_terminal_responses + + +class TestStripLeakedTerminalResponses: + def test_plain_text_unchanged(self): + text = "hello world" + assert _strip_leaked_terminal_responses(text) == text + + def test_empty_text(self): + assert _strip_leaked_terminal_responses("") == "" + + def test_strips_canonical_dsr_response(self): + # Reports from issue #14692 + text = "\x1b[53;1R" + assert _strip_leaked_terminal_responses(text) == "" + + def test_strips_dsr_response_in_middle_of_text(self): + text = "hello\x1b[53;1Rworld" + assert _strip_leaked_terminal_responses(text) == "helloworld" + + def test_strips_multiple_dsr_responses(self): + text = "a\x1b[53;1Rb\x1b[51;1Rc\x1b[50;9Rd" + assert _strip_leaked_terminal_responses(text) == "abcd" + + def test_strips_visible_form_dsr(self): + # When an upstream filter has already stripped the ESC byte and + # left the caret-escape representation in place. + text = "^[[53;1R" + assert _strip_leaked_terminal_responses(text) == "" + + def test_strips_visible_form_dsr_in_middle_of_text(self): + text = "typed^[[53;1Rmore" + assert _strip_leaked_terminal_responses(text) == "typedmore" + + def test_does_not_strip_user_text_with_R(self): + # Don't over-match; user might genuinely type text containing [N;NR patterns. + # Our regex requires the leading ESC or caret-escape, so bare + # "[53;1R" as user text is preserved. + text = "see section [53;1R for details" + assert _strip_leaked_terminal_responses(text) == text + + def test_does_not_strip_sgr_sequences(self): + # Sanity: don't wipe legitimate terminal control sequences that + # aren't DSR responses. + text = "\x1b[31mred\x1b[0m" + assert _strip_leaked_terminal_responses(text) == text + + def test_preserves_multiline_content(self): + text = "line 1\n\x1b[53;1Rline 2" + assert _strip_leaked_terminal_responses(text) == "line 1\nline 2"
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#3181 fix(tui): status bar duplicates and degrades during long sessions · merged via another change →
#3291
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.34: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab