diff --git a/cli.py b/cli.py index 1285ba6d205..75506adc655 100644 --- a/cli.py +++ b/cli.py @@ -2644,6 +2644,12 @@ class HermesCLI: # Status bar visibility (toggled via /statusbar) self._status_bar_visible = True + # When True, the input separator rules and the dynamic status bar are + # hidden until the next user input. Set by _recover_after_resize() so a + # SIGWINCH cannot stamp a freshly-drawn status bar on top of one that + # the terminal just reflowed into scrollback — the cause of duplicated + # bars / "blank line flooding" reports (#19280, #22976). + self._status_bar_suppressed_after_resize = False self._resize_recovery_lock = threading.Lock() self._resize_recovery_timer = None self._resize_recovery_pending = False @@ -2720,7 +2726,16 @@ class HermesCLI: 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. + + We also flag ``_status_bar_suppressed_after_resize`` so the dynamic + status bar and input separator rules stay hidden until the next user + input. On column shrink the terminal reflows already-rendered status + bar rows into scrollback before prompt_toolkit can erase them; drawing + a fresh full-width bar immediately makes the old and new versions + look duplicated (#19280, #22976). Clearing the suppression on the + next prompt restores the bar cleanly. """ + self._status_bar_suppressed_after_resize = True try: app.renderer.reset(leave_alternate_screen=False) except Exception: @@ -2963,10 +2978,34 @@ class HermesCLI: width = self._get_tui_terminal_width() return width < 64 + @staticmethod + def _scrollback_box_width(width: Optional[int] = None) -> int: + """Return a resize-safe width for printed scrollback box rules. + + Lines already printed to terminal scrollback are reflowed by the + terminal emulator when the column count shrinks. A full-width response + border drawn at, say, 200 columns will wrap into two or three rows of + dashes after the user resizes to 80 columns, looking like duplicated + separator lines (the family of bugs tracked by #18449, #19280, #22976). + + Keep decorative scrollback boxes intentionally narrower than the + viewport so a moderate resize never triggers reflow. The live TUI + footer (status bar, input rule) still uses the full width — only + content that is *stamped into scrollback* needs this clamp. + """ + if width is None: + try: + width = shutil.get_terminal_size((80, 24)).columns + except Exception: + width = 80 + return max(32, min(int(width or 80), 56)) + def _tui_input_rule_height(self, position: str, width: Optional[int] = None) -> int: """Return the visible height for the top/bottom input separator rules.""" if position not in {"top", "bottom"}: raise ValueError(f"Unknown input rule position: {position}") + if getattr(self, "_status_bar_suppressed_after_resize", False): + return 0 if position == "top": return 1 return 0 if self._use_minimal_tui_chrome(width=width) else 1 @@ -3476,7 +3515,7 @@ class HermesCLI: # Open reasoning box on first reasoning token if not getattr(self, "_reasoning_box_opened", False): self._reasoning_box_opened = True - w = shutil.get_terminal_size().columns + w = self._scrollback_box_width() r_label = " Reasoning " r_fill = w - 2 - len(r_label) _cprint(f"\n{_DIM}┌─{r_label}{'─' * max(r_fill - 1, 0)}┐{_RST}") @@ -3500,7 +3539,7 @@ class HermesCLI: if buf: _cprint(f"{_DIM}{buf}{_RST}") self._reasoning_buf = "" - w = shutil.get_terminal_size().columns + w = self._scrollback_box_width() _cprint(f"{_DIM}└{'─' * (w - 2)}┘{_RST}") self._reasoning_box_opened = False @@ -3691,7 +3730,7 @@ class HermesCLI: self._stream_text_ansi = "" if self.show_timestamps: label = f"{label} {datetime.now().strftime('%H:%M')}" - w = shutil.get_terminal_size().columns + w = self._scrollback_box_width() fill = w - 2 - HermesCLI._status_bar_display_width(label) _cprint(f"\n{_ACCENT}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}") @@ -3792,7 +3831,7 @@ class HermesCLI: # Close the response box if self._stream_box_opened: - w = shutil.get_terminal_size().columns + w = self._scrollback_box_width() _cprint(f"{_ACCENT}╰{'─' * (w - 2)}╯{_RST}") def _reset_stream_state(self) -> None: @@ -7890,6 +7929,7 @@ class HermesCLI: style=_resp_text, box=rich_box.HORIZONTALS, padding=(1, 4), + width=self._scrollback_box_width(), )) else: _cprint(" (No response generated)") @@ -10549,7 +10589,7 @@ class HermesCLI: nonlocal _streaming_box_opened if not _streaming_box_opened: _streaming_box_opened = True - w = self.console.width + w = self._scrollback_box_width(getattr(self.console, "width", 80)) label = " ⚕ Hermes " if self.show_timestamps: label = f"{label}{datetime.now().strftime('%H:%M')} " @@ -10834,7 +10874,7 @@ class HermesCLI: if self.show_reasoning and result and not _reasoning_already_shown: reasoning = result.get("last_reasoning") if reasoning: - w = shutil.get_terminal_size().columns + w = self._scrollback_box_width() r_label = " Reasoning " r_fill = w - 2 - len(r_label) r_top = f"{_DIM}┌─{r_label}{'─' * max(r_fill - 1, 0)}┐{_RST}" @@ -10865,7 +10905,7 @@ class HermesCLI: already_streamed = self._stream_started and self._stream_box_opened and not is_error_response if use_streaming_tts and _streaming_box_opened and not is_error_response: # Text was already printed sentence-by-sentence; just close the box - w = shutil.get_terminal_size().columns + w = self._scrollback_box_width() _cprint(f"\n{_ACCENT}╰{'─' * (w - 2)}╯{_RST}") elif already_streamed: # Response was already streamed token-by-token with box framing; @@ -10881,6 +10921,7 @@ class HermesCLI: style=_resp_text, box=rich_box.HORIZONTALS, padding=(1, 4), + width=self._scrollback_box_width(), )) @@ -12914,7 +12955,10 @@ class HermesCLI: # guard against any future width mismatch. wrap_lines=False, ), - filter=Condition(lambda: cli_ref._status_bar_visible), + filter=Condition( + lambda: cli_ref._status_bar_visible + and not getattr(cli_ref, "_status_bar_suppressed_after_resize", False) + ), ) # Allow wrapper CLIs to register extra keybindings. @@ -13083,6 +13127,10 @@ class HermesCLI: if not user_input: continue + # The user has typed and submitted something, so any + # post-resize transient suppression should end here. + self._status_bar_suppressed_after_resize = False + # Unpack image payload: (text, [Path, ...]) or plain str submit_images = [] if isinstance(user_input, tuple): diff --git a/scripts/release.py b/scripts/release.py index 16835ac1170..8dca03515ef 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -71,6 +71,8 @@ AUTHOR_MAP = { "kyanam.preetham@gmail.com": "pkyanam", "zhizhong.xu@shopee.com": "1000Delta", "30397170+1000Delta@users.noreply.github.com": "1000Delta", + "szymonclawd@mac.home": "szymonclawd", + "257759490+szymonclawd@users.noreply.github.com": "szymonclawd", "127238744+teknium1@users.noreply.github.com": "teknium1", "147827411+EloquentBrush@users.noreply.github.com": "AhmetArif0", "97489706+purzbeats@users.noreply.github.com": "purzbeats", diff --git a/tests/cli/test_cli_force_redraw.py b/tests/cli/test_cli_force_redraw.py index ba5b0a75534..34f5cefe06e 100644 --- a/tests/cli/test_cli_force_redraw.py +++ b/tests/cli/test_cli_force_redraw.py @@ -79,6 +79,10 @@ class TestForceFullRedraw: 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). """ app = MagicMock() events = [] @@ -86,6 +90,8 @@ class TestForceFullRedraw: app.invalidate.side_effect = lambda: events.append("invalidate") original_on_resize = lambda: events.append("original_resize") + # bare_cli skips __init__, so seed the attribute the way __init__ would. + bare_cli._status_bar_suppressed_after_resize = False bare_cli._recover_after_resize(app, original_on_resize) assert events == [ @@ -97,6 +103,8 @@ class TestForceFullRedraw: app.renderer.output.erase_screen.assert_not_called() app.renderer.output.write_raw.assert_not_called() app.renderer.output.cursor_goto.assert_not_called() + # Status bar / input rules must be suppressed until the next prompt. + assert bare_cli._status_bar_suppressed_after_resize is True def test_force_redraw_uses_full_screen_clear_without_scrollback_clear(self, bare_cli): app = MagicMock() diff --git a/tests/cli/test_cli_status_bar.py b/tests/cli/test_cli_status_bar.py index 16e6699aaac..445626fac9b 100644 --- a/tests/cli/test_cli_status_bar.py +++ b/tests/cli/test_cli_status_bar.py @@ -332,6 +332,38 @@ class TestCLIStatusBar: assert cli_obj._tui_input_rule_height("bottom", width=50) == 0 assert cli_obj._tui_input_rule_height("bottom", width=90) == 1 + def test_input_rules_hide_after_resize_until_next_input(self): + """When _status_bar_suppressed_after_resize is set, both rules hide. + + See _recover_after_resize — column shrink reflows already-rendered + bars into scrollback, so we hide the separators until the user + submits the next input, at which point the flag is cleared. + """ + cli_obj = _make_cli() + cli_obj._status_bar_suppressed_after_resize = True + + assert cli_obj._tui_input_rule_height("top", width=90) == 0 + assert cli_obj._tui_input_rule_height("bottom", width=90) == 0 + + cli_obj._status_bar_suppressed_after_resize = False + assert cli_obj._tui_input_rule_height("top", width=90) == 1 + assert cli_obj._tui_input_rule_height("bottom", width=90) == 1 + + def test_scrollback_box_width_caps_to_resize_safe_value(self): + """Decorative scrollback boxes clamp to a width small enough that + moderate terminal shrinks don't cause reflow into scrollback.""" + from cli import HermesCLI + + # Floor at 32 — narrow terminals still get something usable. + assert HermesCLI._scrollback_box_width(20) == 32 + assert HermesCLI._scrollback_box_width(32) == 32 + # Cap at 56 — wide terminals don't get full-width boxes. + assert HermesCLI._scrollback_box_width(80) == 56 + assert HermesCLI._scrollback_box_width(120) == 56 + assert HermesCLI._scrollback_box_width(200) == 56 + # Mid-range passes through up to the cap. + assert HermesCLI._scrollback_box_width(48) == 48 + def test_agent_spacer_reclaimed_on_narrow_terminals(self): cli_obj = _make_cli() cli_obj._agent_running = True