Files
EvoScientist-Multi/tests/test_tui_completion_window.py
Xi Zhang e8399b7c94 fix(tui): window slash-command completions to terminal height (#362)
* feat(tui): implement completion popup rendering and windowing logic

* feat(tui): enhance completion popup with dynamic row budgeting and CSS adjustments

* Refactor picker widgets to use shared base class for improved code reuse

- Introduced `picker_base.py` to encapsulate common functionality for picker widgets.
- Updated `ModelPickerWidget`, `SkillBrowserWidget`, and `ThreadPickerWidget` to inherit from `PickerWidgetBase`.
- Implemented selection helpers (`first_selectable_index`, `move_selection`) in `picker_base.py` for consistent item navigation.
- Refactored rendering and selection logic in each widget to utilize the new base class methods.
- Added tests for picker functionality to ensure behavior remains consistent post-refactor.
2026-07-16 16:17:58 +01:00

311 lines
12 KiB
Python

"""Tests for the TUI completion popup windowing (issue #354).
The ``#completions`` Static used to be clipped by a fixed CSS
``max-height: 15`` — the flattened command list for ``/`` renders ~30
rows (items + category headers + separators), so trailing categories
(``General``) were silently cut off. The fix renders a height-bounded
window that always keeps the selected item visible and marks hidden
items with overflow indicators.
"""
from __future__ import annotations
from EvoScientist.cli.tui_interactive import (
_COMPLETIONS_CSS,
_build_completion_rows,
_completion_row_budget,
_normalize_chat_scroll,
_render_completion_text,
_window_completion_rows,
)
from EvoScientist.commands._completion_engine import (
CompletionCandidate,
compute_completions,
)
def _candidates(spec: list[tuple[str, str]]) -> list[CompletionCandidate]:
"""Build candidates from (text, category) pairs."""
return [
CompletionCandidate(
text=text,
description=f"desc of {text}",
replace_start=0,
replace_end=1,
category=category,
)
for text, category in spec
]
def _long_candidates() -> list[CompletionCandidate]:
"""Three candidates whose lines far exceed a 40-column terminal."""
return [
CompletionCandidate(
text=f"/long-command-{i}",
description="x" * 120,
replace_start=0,
replace_end=1,
category="Session",
)
for i in range(3)
]
class TestBuildCompletionRows:
def test_headers_and_separators_between_categories(self):
items = _candidates([("/a", "Session"), ("/b", "Session"), ("/c", "General")])
rows = _build_completion_rows(items)
kinds = [kind for kind, _payload, _idx in rows]
assert kinds == ["header", "item", "item", "sep", "header", "item"]
def test_item_rows_keep_original_indices(self):
items = _candidates([("/a", "Session"), ("/b", "General")])
rows = _build_completion_rows(items)
item_indices = [idx for kind, _payload, idx in rows if kind == "item"]
assert item_indices == [0, 1]
def test_no_category_items_have_no_headers(self):
items = _candidates([("@f1", ""), ("@f2", "")])
rows = _build_completion_rows(items)
assert [kind for kind, _p, _i in rows] == ["item", "item"]
class TestWindowCompletionRows:
def test_fitting_rows_are_untouched(self):
rows = _build_completion_rows(
_candidates([("/a", "Session"), ("/b", "General")])
)
visible, above, below = _window_completion_rows(rows, -1, 10)
assert visible == rows
assert (above, below) == (0, 0)
def test_no_selection_windows_from_top(self):
items = _candidates([(f"/c{i}", "Session") for i in range(20)])
rows = _build_completion_rows(items)
visible, above, below = _window_completion_rows(rows, -1, 8)
assert above == 0
assert below > 0
# First item visible at the top of the window
first_items = [idx for kind, _p, idx in visible if kind == "item"]
assert first_items[0] == 0
def test_last_selection_windows_to_bottom(self):
items = _candidates([(f"/c{i}", "Session") for i in range(20)])
rows = _build_completion_rows(items)
visible, above, below = _window_completion_rows(rows, 19, 8)
assert below == 0
assert above > 0
assert any(kind == "item" and idx == 19 for kind, _p, idx in visible)
def test_selected_item_always_visible(self):
items = _candidates(
[(f"/c{i}", cat) for i, cat in enumerate(["A"] * 7 + ["B"] * 7 + ["C"] * 6)]
)
rows = _build_completion_rows(items)
for selected in range(len(items)):
visible, above, below = _window_completion_rows(rows, selected, 9)
assert any(
kind == "item" and idx == selected for kind, _p, idx in visible
), f"selected {selected} not in window"
# Window body + indicator rows never exceed the budget
total = len(visible) + (1 if above else 0) + (1 if below else 0)
assert total <= 9
def test_hidden_counts_only_count_items(self):
items = _candidates(
[("/a", "A"), ("/b", "A"), ("/c", "B"), ("/d", "B"), ("/e", "C")]
)
rows = _build_completion_rows(items) # 8 rows
visible, above, below = _window_completion_rows(rows, 4, 5)
assert above + below == 5 - sum(1 for k, _p, _i in visible if k == "item")
class TestCompletionRowBudget:
"""The popup line budget follows the terminal but is hard-capped so
the popup never dwarfs the chat area (mainstream CLI behavior)."""
def test_capped_on_tall_terminals(self):
assert _completion_row_budget(100) == 15
def test_shrinks_with_terminal(self):
assert _completion_row_budget(20) == 8
def test_floor_on_tiny_terminals(self):
assert _completion_row_budget(10) == 5
def test_unknown_height_falls_back_to_cap(self):
assert _completion_row_budget(0) == 15
class TestRenderCompletionText:
def test_generous_budget_renders_all_commands(self):
"""Regression for #354: with enough rows every category renders."""
result = compute_completions("/", 1)
text = _render_completion_text(result.candidates, -1, 100).plain
assert "General" in text
assert "/help" in text
for candidate in result.candidates:
assert candidate.text in text
def test_short_terminal_shows_overflow_indicator(self):
result = compute_completions("/", 1)
text = _render_completion_text(result.candidates, -1, 10).plain
assert "more" in text
assert "↓" in text # ↓ indicator for hidden items below
assert result.candidates[0].text in text
def test_navigation_reaches_every_command(self):
"""Every candidate becomes visible when selected, even clipped ones."""
result = compute_completions("/", 1)
for i, candidate in enumerate(result.candidates):
text = _render_completion_text(result.candidates, i, 10).plain
assert candidate.text in text, f"{candidate.text} hidden when selected"
def test_selected_candidate_marked(self):
items = _candidates([("/a", "Session"), ("/b", "Session")])
text = _render_completion_text(items, 1, 20).plain
marked = [ln for ln in text.splitlines() if "▸" in ln]
assert len(marked) == 1
assert "/b" in marked[0]
def test_no_indicators_when_everything_fits(self):
items = _candidates([("/a", "Session"), ("/b", "General")])
text = _render_completion_text(items, -1, 20).plain
assert "more" not in text
class TestCompletionPopupCss:
"""Textual converts rich Text to Content and drops rich no_wrap and
overflow attributes, so cropping is enforced by the widget CSS.
"""
async def test_static_crops_long_lines_with_real_css(self):
"""Mount a Static with the real popup CSS and verify visual rows
equal logical rows on a narrow terminal (no wrapping)."""
from textual.app import App, ComposeResult
from textual.widgets import Static
class PopupApp(App[None]):
CSS = _COMPLETIONS_CSS
def compose(self) -> ComposeResult:
yield Static("", id="completions")
text = _render_completion_text(_long_candidates(), 0, 20)
logical_rows = len(text.plain.splitlines())
app = PopupApp()
async with app.run_test(size=(40, 30)) as pilot:
widget = app.query_one("#completions", Static)
widget.display = True
widget.update(text)
await pilot.pause()
assert widget.size.height == logical_rows
rendered = [
"".join(seg.text for seg in widget.render_line(y))
for y in range(widget.size.height)
]
assert any("…" in line for line in rendered)
def _make_chat_app(n_lines: int):
"""Minimal app mirroring the real chat + completion popup layout."""
from textual.app import App, ComposeResult
from textual.containers import Container, VerticalScroll
from textual.widgets import Static
class ChatApp(App[None]):
CSS = (
"Screen { layout: vertical; }\n"
"#chat { height: 1fr; }\n"
"#input-shell { height: auto; }\n"
) + _COMPLETIONS_CSS
def compose(self) -> ComposeResult:
with VerticalScroll(id="chat"):
for i in range(n_lines):
yield Static(f"line {i}")
with Container(id="input-shell"):
yield Static("", id="completions")
return ChatApp()
async def _toggle_popup_cycle(app, pilot):
"""din0s's repro: popup open -> scroll to bottom -> hide -> reopen."""
from textual.containers import VerticalScroll
from textual.widgets import Static
chat = app.query_one("#chat", VerticalScroll)
comp = app.query_one("#completions", Static)
text = _render_completion_text(
_candidates([(f"/c{i}", "Session") for i in range(14)]), -1, 15
)
comp.update(text)
comp.display = True
await pilot.pause()
chat.scroll_end(animate=False)
await pilot.pause()
comp.display = False
await pilot.pause()
return chat, comp
class TestNormalizeChatScroll:
"""Popup show/hide resizes the chat viewport. For an anchored chat
whose content then fits, Textual's compositor pushes ``scroll_y``
negative (bypasses the validator, issue #301 family) — the scrollbar
then renders as if scrolled to the bottom while the content sits at
the top. ``_normalize_chat_scroll`` repairs the state.
"""
async def test_releases_anchor_and_pins_top_when_content_fits(self):
from textual.widget import Widget
app = _make_chat_app(18)
async with app.run_test(size=(80, 30)) as pilot:
chat, comp = await _toggle_popup_cycle(app, pilot)
chat.anchor()
await pilot.pause()
# Deterministically inject the compositor's anchored-scroll
# bypass (set_reactive skips validator AND watcher — see
# textual _compositor.py) instead of racing its layout pass.
chat.set_reactive(Widget.scroll_y, -12.0)
chat.set_reactive(Widget.scroll_target_y, -12.0)
_normalize_chat_scroll(chat)
await pilot.pause()
assert chat.scroll_y == 0
assert not chat.is_anchored
# The scrollbar thumb must not keep a stale position: when it
# becomes visible again (popup reopens) it would render as if
# scrolled to the bottom while the content sits at the top.
comp.display = True
await pilot.pause()
_normalize_chat_scroll(chat)
await pilot.pause()
assert chat.vertical_scrollbar.position == chat.scroll_y
async def test_keeps_anchored_overflowing_chat_pinned(self):
app = _make_chat_app(60)
async with app.run_test(size=(80, 30)) as pilot:
chat, _comp = await _toggle_popup_cycle(app, pilot)
chat.anchor()
await pilot.pause()
_normalize_chat_scroll(chat)
await pilot.pause()
assert chat.is_anchored
assert chat.scroll_y == chat.max_scroll_y
async def test_preserves_position_when_user_scrolled_up(self):
app = _make_chat_app(60)
async with app.run_test(size=(80, 30)) as pilot:
chat, comp = await _toggle_popup_cycle(app, pilot)
chat.scroll_to(y=10, animate=False)
await pilot.pause()
comp.display = True
await pilot.pause()
_normalize_chat_scroll(chat)
await pilot.pause()
assert chat.scroll_y == 10