Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 56 additions & 8 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2644,6 +2644,12 @@ def __init__(

# 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
Expand Down Expand Up @@ -2720,7 +2726,16 @@ def _recover_after_resize(self, app, original_on_resize) -> None:
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:
Expand Down Expand Up @@ -2963,10 +2978,34 @@ def _use_minimal_tui_chrome(self, width: Optional[int] = None) -> bool:
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
Expand Down Expand Up @@ -3476,7 +3515,7 @@ def _stream_reasoning_delta(self, text: str) -> None:
# 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}")
Expand All @@ -3500,7 +3539,7 @@ def _close_reasoning_box(self) -> None:
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

Expand Down Expand Up @@ -3691,7 +3730,7 @@ def _emit_stream_text(self, text: str) -> None:
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}")

Expand Down Expand Up @@ -3792,7 +3831,7 @@ def _flush_stream(self) -> None:

# 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:
Expand Down Expand Up @@ -7890,6 +7929,7 @@ def _bg_thinking(text: str) -> None:
style=_resp_text,
box=rich_box.HORIZONTALS,
padding=(1, 4),
width=self._scrollback_box_width(),
))
else:
_cprint(" (No response generated)")
Expand Down Expand Up @@ -10549,7 +10589,7 @@ def display_callback(sentence: str):
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')} "
Expand Down Expand Up @@ -10834,7 +10874,7 @@ def run_agent():
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}"
Expand Down Expand Up @@ -10865,7 +10905,7 @@ def run_agent():
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;
Expand All @@ -10881,6 +10921,7 @@ def run_agent():
style=_resp_text,
box=rich_box.HORIZONTALS,
padding=(1, 4),
width=self._scrollback_box_width(),
))


Expand Down Expand Up @@ -12914,7 +12955,10 @@ def _get_voice_status():
# 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.
Expand Down Expand Up @@ -13083,6 +13127,10 @@ def process_loop():
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):
Expand Down
2 changes: 2 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@
"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",
Expand Down
8 changes: 8 additions & 0 deletions tests/cli/test_cli_force_redraw.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,19 @@ def test_resize_preserves_scrollback_and_resets_renderer(self, bare_cli, monkeyp
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 = []
app.renderer.reset.side_effect = lambda **_: events.append("renderer_reset")
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 == [
Expand All @@ -97,6 +103,8 @@ def test_resize_preserves_scrollback_and_resets_renderer(self, bare_cli, monkeyp
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()
Expand Down
32 changes: 32 additions & 0 deletions tests/cli/test_cli_status_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,38 @@ def test_bottom_input_rule_hides_on_narrow_terminals(self):
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
Expand Down
Loading