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.
100 lines
3.5 KiB
Python
100 lines
3.5 KiB
Python
"""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"
|