Files
hermes-agent/tests/hermes_cli/test_cli_footer_split.py
teknium1 3b51213c03 fix(cli): footer no longer collapses into "Window too small..." mid-turn (#57393)
Symptom: the classic CLI's whole footer (status bar, rules, composer) is
replaced by prompt_toolkit's " Window too small... " placeholder on
terminals that are plainly large enough — a 199x48 tmux pane, Windows 10,
a fullscreen 14" Mac.

Root cause: in non-full-screen mode prompt_toolkit renders the footer into
exactly the height it measured a moment earlier (Renderer.render ->
HSplit.preferred_height), then HSplit._divide_heights measures every child
again while painting. Every footer row has a callable height that the agent
thread flips at turn start (_agent_running -> spacer, _spinner_text ->
spinner), so a row appearing between the two passes makes sum(min) exceed
the budget by one and stock HSplit gives up on the whole container.
Hermes's throttled _invalidate can then drop the healing repaint for the
length of a tool call, so the placeholder sits there looking like a
geometry problem. Terminal size never mattered.

Fix: the footer root is a FooterSplit — an HSplit whose overflow path
allocates the children's minimums bottom-up (composer, rules, status bar
first) so only the row that arrived late loses its row for that one frame,
and schedules an unthrottled redraw so the next frame measures and paints
against the same state.

Live repro (real `hermes chat` in a 200x50 tmux pane against a fake
provider, measure->paint window widened via a sitecustomize shim):
before 3/3 turns hit the placeholder in the renderer and it reached the
screen; after 3/3 still trip the base-class check and 0/3 reach the screen.
2026-09-20 13:35:09 -07:00

100 lines
3.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""The classic CLI footer never paints prompt_toolkit's "Window too small..." placeholder.
Regression for #57393: a footer row appearing between the renderer's measure pass and the paint
pass (the agent thread starting a turn) made the root ``HSplit`` give up on the whole footer, on
any terminal size.
"""
import asyncio
from prompt_toolkit.application import Application
from prompt_toolkit.data_structures import Size
from prompt_toolkit.input import create_pipe_input
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.layout import HSplit, Layout, Window
from prompt_toolkit.layout.dimension import Dimension
from prompt_toolkit.layout.screen import WritePosition
from prompt_toolkit.output.vt100 import Vt100_Output
class _Tty:
encoding = "utf-8"
def __init__(self):
self.chunks = []
def write(self, data):
self.chunks.append(data)
def flush(self):
pass
def isatty(self):
return True
def fileno(self):
return 1
@property
def text(self):
return "".join(self.chunks)
def _render_once(layout, kb, style, tty, rows=50, columns=200):
"""One non-full-screen frame through the same CPR-disabled vt100 output the CLI ships."""
output = Vt100_Output(tty, lambda: Size(rows=rows, columns=columns), term="xterm-256color", enable_cpr=False)
async def run():
with create_pipe_input() as pipe:
app = Application(layout=layout, key_bindings=kb, style=style, full_screen=False,
input=pipe, output=output)
painted = asyncio.Event()
app.after_render += lambda _: painted.set()
task = asyncio.create_task(app.run_async())
await asyncio.wait_for(painted.wait(), 5)
app.exit()
await asyncio.wait_for(task, 3)
asyncio.run(run())
def test_footer_survives_a_row_appearing_between_measure_and_paint(monkeypatch):
monkeypatch.setenv("HERMES_DEFER_AGENT_STARTUP", "1")
from cli import HermesCLI
cli = HermesCLI(model="fixture", provider="openai-compat", api_key="fixture", base_url="http://127.0.0.1:1/v1")
cli._tui_init_run_state()
kb = KeyBindings()
layout, style = cli._tui_build_layout(kb)
root = layout.container
measured = HSplit.preferred_height
def measure_then_agent_starts(self, width, max_available_height):
dimension = measured(self, width, max_available_height)
if self is root:
# The agent thread flips these right after the renderer measured the footer.
cli._agent_running = True
cli._spinner_text = "⠋ Thinking..."
return dimension
monkeypatch.setattr(HSplit, "preferred_height", measure_then_agent_starts)
tty = _Tty()
_render_once(layout, kb, style, tty)
assert "Window too small" not in tty.text
assert "❯" in tty.text, "the composer prompt must survive the frame"
def test_footer_split_clips_from_the_top_when_minimums_overflow():
from prompt_toolkit.application import DummyApplication, set_app
from hermes_cli.cli_footer_split import FooterSplit
top, middle, bottom = (Window(height=Dimension.exact(1)) for _ in range(3))
split = FooterSplit([top, middle, bottom])
with set_app(DummyApplication()):
sizes = split._divide_heights(WritePosition(0, 0, 80, 2))
# ``_all_children`` interleaves zero-height padding windows between the three children.
assert sizes == [0, 0, 1, 0, 1], "composer-side rows keep their height; the late top row is clipped"