e8399b7c94
* 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.
311 lines
12 KiB
Python
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
|