mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
fix(cli): clamp scrollback box widths + suppress status bar after resize (#25975)
When the terminal shrinks, already-printed box-drawing rules (response, reasoning, streaming TTS, background-task Panels) reflow into multiple narrower rows — visible as duplicated horizontal separators / ghost lines in scrollback. Similarly, prompt_toolkit redraws a fresh status bar on SIGWINCH on top of one the terminal just reflowed, producing double-bar artifacts on column shrink. Two surgical changes: 1. Decorative scrollback boxes now use a new `HermesCLI._scrollback_box_width()` helper that clamps to `max(32, min(width, 56))`. The live TUI footer is unaffected and still uses the full width. Covers: streaming response box (open + close), reasoning box (open + close, both streaming and post-stream paths), streaming-TTS box close, final-response Rich Panel, and the background-task Rich Panel. 2. `_recover_after_resize()` now also sets a new `_status_bar_suppressed_after_resize` flag so the dynamic status bar and both input separator rules stay hidden until the next user input. The flag is cleared in the process loop the moment the user submits their next prompt, restoring chrome cleanly. Tests: - New `test_input_rules_hide_after_resize_until_next_input` covers the flag's effect on rule heights. - New `test_scrollback_box_width_caps_to_resize_safe_value` covers the helper at floor / cap / mid-range / overflow. - Existing resize-recovery test extended to assert the flag flips. Refs: #18449 #19280 #22976 Salvage of #24403. Co-authored-by: Szymonclawd <szymonclawd@mac.home>
This commit is contained in:
parent
f491b07cb2
commit
2844c888f1
4 changed files with 98 additions and 8 deletions
64
cli.py
64
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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue