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.
This commit is contained in:
Xi Zhang
2026-07-16 16:17:58 +01:00
committed by GitHub
parent 0f709cff8b
commit e8399b7c94
10 changed files with 1269 additions and 716 deletions
+201 -31
View File
@@ -316,6 +316,159 @@ def _stopped_response_after_narration(
return display_current, display_stopped, full_stopped
# (kind, payload, item_index): kind is "header"/"sep"/"item"; payload is the
# category name for headers or the candidate for items; item_index is the
# candidate's position in the source list (-1 for non-item rows).
_CompletionRow = tuple[str, Any, int]
def _build_completion_rows(items: list[Any]) -> list[_CompletionRow]:
"""Flatten completion candidates into render rows with category headers."""
rows: list[_CompletionRow] = []
last_cat = ""
for i, candidate in enumerate(items):
cat = getattr(candidate, "category", "")
if cat and cat != last_cat:
if last_cat:
rows.append(("sep", "", -1))
rows.append(("header", cat, -1))
last_cat = cat
rows.append(("item", candidate, i))
return rows
def _window_completion_rows(
rows: list[_CompletionRow],
selected: int,
max_rows: int,
) -> tuple[list[_CompletionRow], int, int]:
"""Slice *rows* to a window of at most *max_rows* total display lines.
The window always contains the selected item (top of the list when
nothing is selected) and reserves one line per overflow indicator.
Returns ``(visible_rows, hidden_items_above, hidden_items_below)``.
"""
max_rows = max(max_rows, 5)
if len(rows) <= max_rows:
return list(rows), 0, 0
sel_row = 0
if selected >= 0:
for r, (kind, _payload, idx) in enumerate(rows):
if kind == "item" and idx == selected:
sel_row = r
break
# Center the selection; centering keeps it clear of the indicator
# lines that replace the window's edge rows when content is clipped.
start = min(max(sel_row - max_rows // 2, 0), len(rows) - max_rows)
end = start + max_rows
content_start = start + (1 if start > 0 else 0)
content_end = end - (1 if end < len(rows) else 0)
above = sum(1 for kind, _p, _i in rows[:content_start] if kind == "item")
below = sum(1 for kind, _p, _i in rows[content_end:] if kind == "item")
return rows[content_start:content_end], above, below
def _render_completion_text(items: list[Any], selected: int, max_rows: int) -> Text:
"""Render the completion popup content bounded to *max_rows* lines."""
rows = _build_completion_rows(items)
visible, above, below = _window_completion_rows(rows, selected, max_rows)
# Blank separator lines are cosmetic — drop them at the window edges.
while visible and visible[0][0] == "sep":
visible = visible[1:]
while visible and visible[-1][0] == "sep":
visible = visible[:-1]
lines: list[Text] = []
if above:
lines.append(Text(f" ↑ {above} more", style="dim italic"))
for kind, payload, idx in visible:
if kind == "sep":
lines.append(Text())
elif kind == "header":
lines.append(Text(f" {payload}", style="bold #6b7280"))
elif idx == selected:
lines.append(
Text.assemble(
(" ▸ ", "bold"),
(f"{payload.text:<28}", "bold"),
(payload.description, "bold"),
)
)
else:
lines.append(
Text.assemble(
(" ", "#888888"),
(f"{payload.text:<28}", "#888888"),
(payload.description, "#888888"),
)
)
if below:
lines.append(Text(f" ↓ {below} more", style="dim italic"))
return Text("\n").join(lines)
# Hard cap on popup lines so the popup never dwarfs the chat area
# (mainstream CLI behavior); matches the pre-#354 max-height.
_COMPLETION_MAX_VISIBLE_ROWS = 15
# Rows kept free for the input row, status bar and a slice of chat. On
# terminals shorter than ~17 rows the 5-row floor wins over this
# reservation — a smaller popup would be unusable.
_COMPLETION_RESERVED_ROWS = 12
def _completion_row_budget(height: int) -> int:
"""Popup line budget for a terminal of *height* rows."""
if height <= 0:
return _COMPLETION_MAX_VISIBLE_ROWS
return max(5, min(height - _COMPLETION_RESERVED_ROWS, _COMPLETION_MAX_VISIBLE_ROWS))
# Textual converts rich Text to Content and drops rich no_wrap/overflow
# attributes, so line cropping must be enforced here in CSS.
_COMPLETIONS_CSS = """
#completions {
display: none;
height: auto;
background: #1e1f26;
padding: 0 1;
border-bottom: solid #0284c7;
text-wrap: nowrap;
text-overflow: ellipsis;
}
"""
def _normalize_chat_scroll(container: Any) -> None:
"""Repair the chat scroll state after the popup resized the viewport.
Textual's compositor recomputes ``scroll_y`` for anchored containers
bypassing the validator, so when the popup hides and the content fits
again, ``scroll_y`` can go negative — the scrollbar then renders as if
scrolled to the bottom while the content sits at the top (issue #301
family). Runs after refresh so sizes are current.
"""
# force=True: with the content fitting, the scrollbar is hidden and
# allow_vertical_scroll is False — an unforced scroll_home would
# silently no-op and leave the negative scroll_y in place.
if container.is_anchored:
if container.max_scroll_y <= 0:
container.anchor(False)
container.scroll_home(animate=False, immediate=True, force=True)
elif container.scroll_y < 0:
container.scroll_home(animate=False, immediate=True, force=True)
# Resync the scrollbar thumb: watch_scroll_y skips the update while
# the scrollbar is hidden (or when the compositor wrote scroll_y via
# set_reactive), so a stale position survives until the scrollbar
# reappears — rendering as "scrolled to bottom" at the top.
scrollbar = getattr(container, "vertical_scrollbar", None)
if scrollbar is not None and scrollbar.position != container.scroll_y:
scrollbar.position = container.scroll_y
def run_textual_interactive(
*,
show_thinking: bool,
@@ -384,7 +537,8 @@ def run_textual_interactive(
def supports_interactive(self) -> bool:
return True
CSS = """
CSS = (
"""
Screen {
layout: vertical;
background: #16161a;
@@ -436,14 +590,9 @@ def run_textual_interactive(
padding: 0 2;
color: #9ca3af;
}
#completions {
display: none;
height: auto;
max-height: 15;
background: #1e1f26;
padding: 0 1;
border-bottom: solid #0284c7;
}
"""
+ _COMPLETIONS_CSS
+ """
#status {
height: 1;
min-height: 1;
@@ -452,6 +601,7 @@ def run_textual_interactive(
padding: 0 1;
}
"""
)
BINDINGS: ClassVar[list[Binding]] = [
Binding("ctrl+c", "request_quit", "Quit", show=False, priority=True),
Binding("ctrl+v", "paste_clipboard", "Paste", show=False),
@@ -501,6 +651,7 @@ def run_textual_interactive(
] = [] # queued messages to send after current turn
self._comp_items: list = []
self._comp_index: int = -1
self._comp_last_height: int = 0
self._comp_base: str = ""
self._hitl_auto_approve: bool = False
self._approval_future: asyncio.Future | None = None
@@ -912,6 +1063,17 @@ def run_textual_interactive(
self._background_tasks.add(ch_task)
ch_task.add_done_callback(self._background_tasks.discard)
def on_resize(self, event: Any) -> None:
"""Re-window the completion popup for the new terminal height."""
try:
comp_widget = self.query_one("#completions", Static)
except Exception:
return
if comp_widget.display and self._comp_items:
# Deferred: this handler can run before the base App
# handler updates self.size with the new dimensions.
self.call_after_refresh(self._render_completions)
# ── Update check ──────────────────────────────────────
async def _check_for_updates(self) -> None:
@@ -1149,7 +1311,9 @@ def run_textual_interactive(
go negative and pushes the welcome banner out of view (issue #301).
"""
container.anchor(False)
container.scroll_home(animate=False, immediate=True)
# force=True: with content fitting, the scrollbar is hidden and
# allow_vertical_scroll is False — unforced scroll_home no-ops.
container.scroll_home(animate=False, immediate=True, force=True)
def _append_system(self, text: str, style: str = "dim") -> None:
"""Mount a SystemMessage widget into #chat."""
@@ -2858,30 +3022,36 @@ def run_textual_interactive(
def _hide_completions(self) -> None:
self._comp_items = []
self._comp_index = -1
self.query_one("#completions", Static).display = False
self._comp_last_height = 0
comp_widget = self.query_one("#completions", Static)
was_visible = comp_widget.display
comp_widget.display = False
# Called on every ordinary input change — only a popup that was
# actually visible changed the chat viewport.
if was_visible:
self.call_after_refresh(self._normalize_chat_after_popup)
def _completion_max_rows(self) -> int:
return _completion_row_budget(int(getattr(self.size, "height", 0) or 0))
def _render_completions(self) -> None:
comp_text = Text()
last_cat = ""
for i, candidate in enumerate(self._comp_items):
cmd, desc = candidate.text, candidate.description
cat = getattr(candidate, "category", "")
if cat and cat != last_cat:
if last_cat:
comp_text.append("\n")
comp_text.append(f" {cat}\n", style="bold #6b7280")
last_cat = cat
if i == self._comp_index:
comp_text.append(" \u25b8 ", style="bold")
comp_text.append(f"{cmd:<28}", style="bold")
comp_text.append(desc, style="bold")
else:
comp_text.append(" ", style="#888888")
comp_text.append(f"{cmd:<28}", style="#888888")
comp_text.append(desc, style="#888888")
if i < len(self._comp_items) - 1:
comp_text.append("\n")
comp_text = _render_completion_text(
self._comp_items, self._comp_index, self._completion_max_rows()
)
self.query_one("#completions", Static).update(comp_text)
# Selection-only navigation keeps the height — skip the (cheap
# but per-keystroke) normalize unless the viewport can change.
n_lines = len(comp_text.plain.splitlines()) if comp_text.plain else 0
if n_lines != self._comp_last_height:
self._comp_last_height = n_lines
self.call_after_refresh(self._normalize_chat_after_popup)
def _normalize_chat_after_popup(self) -> None:
try:
container = self.query_one("#chat", VerticalScroll)
except Exception:
return
_normalize_chat_scroll(container)
# ── Slash commands ─────────────────────────────────────
+19 -296
View File
@@ -1,6 +1,7 @@
"""Inline MCP server browser widget for /install-mcp in TUI.
Two-phase keyboard-driven widget (mirrors SkillBrowserWidget):
Two-phase keyboard-driven widget built on the shared picker engine
(``picker_base.TagCheckboxBrowserBase``):
Phase 1 — tag picker (arrow keys + Enter to select, or Esc for all)
Phase 2 — server checkbox (arrow keys to navigate, Space to toggle, Enter to confirm)
@@ -12,73 +13,20 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any, ClassVar
from rich.text import Text
from textual.binding import Binding, BindingType
from textual.containers import Container
from textual.message import Message
from textual.widget import Widget
from textual.widgets import Static
from .picker_base import TagCheckboxBrowserBase
if TYPE_CHECKING:
from textual import events
from textual.app import ComposeResult
from ...mcp.registry import MCPServerEntry
class MCPBrowserWidget(Widget):
"""Inline MCP server browser — mounts in chat, keyboard-driven.
class MCPBrowserWidget(TagCheckboxBrowserBase):
"""Inline MCP server browser — mounts in chat, keyboard-driven."""
Phase 1: Tag picker (select a tag filter or "All").
Phase 2: Server checkbox (toggle servers, confirm to install).
"""
can_focus = True
can_focus_children = False
DEFAULT_CSS = """
MCPBrowserWidget {
height: auto;
max-height: 30;
margin: 1 0;
padding: 0 1;
background: $surface;
border: solid $primary;
}
MCPBrowserWidget .browser-title {
height: 1;
text-style: bold;
color: $primary;
}
MCPBrowserWidget .browser-rows {
height: auto;
max-height: 20;
overflow-y: auto;
}
MCPBrowserWidget .browser-row {
height: 1;
padding: 0 1;
}
MCPBrowserWidget .browser-row-selected {
background: $primary;
text-style: bold;
}
MCPBrowserWidget .browser-help {
height: 1;
color: $text-muted;
text-style: italic;
}
"""
BINDINGS: ClassVar[list[BindingType]] = [
Binding("up", "move_up", "Up", show=False),
Binding("k", "move_up", "Up", show=False),
Binding("down", "move_down", "Down", show=False),
Binding("j", "move_down", "Down", show=False),
Binding("enter", "confirm", "Confirm", show=False),
Binding("space", "toggle", "Toggle", show=False),
Binding("escape", "cancel", "Cancel", show=False),
]
_INSTALLED_SUFFIX: ClassVar[str] = " (configured)"
_PHASE2_TITLE: ClassVar[str] = "Select MCP servers to install"
_PHASE2_CONFIRM_LABEL: ClassVar[str] = "install"
class Confirmed(Message):
"""Posted when user confirms server selection."""
@@ -90,242 +38,17 @@ class MCPBrowserWidget(Widget):
class Cancelled(Message):
"""Posted when user cancels."""
def __init__(
self,
servers: list[MCPServerEntry],
installed_names: set[str],
*,
pre_filter_tag: str = "",
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self._servers = servers
self._installed_names = installed_names
self._pre_filter_tag = pre_filter_tag.lower()
self._selected = 0
self._row_widgets: list[Static] = []
self._title_widget: Static | None = None
self._help_widget: Static | None = None
def _item_name(self, item: Any) -> str:
return item.name
# Phase 1: tag picker
# Phase 2: server checkbox
self._phase: int = 1
self._tag_items: list[tuple[str, int]] = []
self._server_items: list[MCPServerEntry] = []
self._checked: set[int] = set()
def _item_tags(self, item: Any) -> list[str]:
return item.tags
# Build tag list
from collections import Counter
def _item_desc(self, item: Any) -> str:
return item.description or item.label
tag_counter: Counter[str] = Counter()
for s in self._servers:
for t in s.tags:
tag_counter[t.lower()] += 1
sorted_tags = sorted(tag_counter.items(), key=lambda x: (-x[1], x[0]))
self._tag_items = [("all", len(self._servers)), *sorted_tags]
def _post_confirmed(self, items: list[Any]) -> None:
self.post_message(self.Confirmed(items))
# If pre-filtered, skip to phase 2
if self._pre_filter_tag:
self._server_items = [
s
for s in self._servers
if self._pre_filter_tag in [t.lower() for t in s.tags]
]
if self._server_items:
self._phase = 2
else:
self._pre_filter_tag = ""
def compose(self) -> ComposeResult:
self._title_widget = Static("", classes="browser-title")
yield self._title_widget
with Container(classes="browser-rows"):
max_rows = max(len(self._tag_items), len(self._servers))
for _ in range(max_rows):
widget = Static("", classes="browser-row")
self._row_widgets.append(widget)
yield widget
self._help_widget = Static("", classes="browser-help")
yield self._help_widget
def on_mount(self) -> None:
self.call_after_refresh(self._update_display)
self.call_later(self.focus)
def _update_display(self) -> None:
if self._phase == 1:
self._render_tag_picker()
else:
self._render_server_checkbox()
def _render_tag_picker(self) -> None:
if self._title_widget:
self._title_widget.update("Filter by tag:")
if self._help_widget:
self._help_widget.update(
"\u2191/\u2193 navigate \u00b7 Enter select \u00b7 Esc cancel"
)
for i, widget in enumerate(self._row_widgets):
if i < len(self._tag_items):
tag, count = self._tag_items[i]
is_selected = i == self._selected
text = Text()
cursor = "\u25b8 " if is_selected else " "
text.append(cursor, style="bold cyan" if is_selected else "dim")
label = f"{tag} ({count})"
text.append(label, style="bold" if is_selected else "")
widget.update(text)
widget.display = True
widget.remove_class("browser-row-selected")
if is_selected:
widget.add_class("browser-row-selected")
widget.scroll_visible()
else:
widget.update("")
widget.display = False
def _row_content_width(self) -> int:
try:
w = self.size.width
if w > 0:
return w - 6
except Exception:
pass
try:
return self.app.size.width - 10
except Exception:
return 100
def _truncate(self, desc: str, name: str, *, suffix: str = "") -> str:
overhead = 2 + 2 + len(name) + 3 + len(suffix)
max_len = max(20, self._row_content_width() - overhead)
if len(desc) <= max_len:
return desc
return desc[: max_len - 1] + "\u2026"
def _render_server_checkbox(self) -> None:
n_checked = len(
[
i
for i in self._checked
if self._server_items[i].name not in self._installed_names
]
)
if self._title_widget:
self._title_widget.update(
f"Select MCP servers to install ({n_checked} selected):"
)
if self._help_widget:
self._help_widget.update(
"\u2191/\u2193 navigate \u00b7 Space toggle \u00b7 Enter install \u00b7 Esc cancel"
)
for i, widget in enumerate(self._row_widgets):
if i < len(self._server_items):
entry = self._server_items[i]
is_selected = i == self._selected
is_installed = entry.name in self._installed_names
is_checked = i in self._checked
text = Text()
cursor = "\u25b8 " if is_selected else " "
text.append(cursor, style="bold cyan" if is_selected else "dim")
desc = entry.description or entry.label
if is_installed:
suffix = " (configured)"
desc = self._truncate(desc, entry.name, suffix=suffix)
text.append("\u2713 ", style="green")
text.append(entry.name, style="green dim")
text.append(f" \u2014 {desc}", style="dim")
text.append(suffix, style="dim italic")
elif is_checked:
desc = self._truncate(desc, entry.name)
text.append("\u25cf ", style="green bold")
text.append(entry.name, style="bold")
text.append(f" \u2014 {desc}", style="")
else:
desc = self._truncate(desc, entry.name)
text.append("\u25cb ", style="dim")
text.append(entry.name, style="bold" if is_selected else "")
text.append(f" \u2014 {desc}", style="dim")
widget.update(text)
widget.display = True
widget.remove_class("browser-row-selected")
if is_selected:
widget.add_class("browser-row-selected")
widget.scroll_visible()
else:
widget.update("")
widget.display = False
def _current_items_count(self) -> int:
if self._phase == 1:
return len(self._tag_items)
return len(self._server_items)
def action_move_up(self) -> None:
n = self._current_items_count()
if not n:
return
self._selected = (self._selected - 1) % n
self._update_display()
def action_move_down(self) -> None:
n = self._current_items_count()
if not n:
return
self._selected = (self._selected + 1) % n
self._update_display()
def action_toggle(self) -> None:
if self._phase != 2:
return
if not self._server_items:
return
entry = self._server_items[self._selected]
if entry.name in self._installed_names:
return
if self._selected in self._checked:
self._checked.discard(self._selected)
else:
self._checked.add(self._selected)
self._update_display()
def action_confirm(self) -> None:
if self._phase == 1:
if not self._tag_items:
return
tag, _ = self._tag_items[self._selected]
if tag == "all":
self._server_items = list(self._servers)
else:
self._server_items = [
s for s in self._servers if tag in [t.lower() for t in s.tags]
]
self._phase = 2
self._selected = 0
self._checked = set()
self._update_display()
else:
entries = [
self._server_items[i]
for i in sorted(self._checked)
if self._server_items[i].name not in self._installed_names
]
self.post_message(self.Confirmed(entries))
def action_cancel(self) -> None:
if self._phase == 2 and not self._pre_filter_tag:
self._phase = 1
self._selected = 0
self._checked = set()
self._update_display()
else:
self.post_message(self.Cancelled())
def on_blur(self, event: events.Blur) -> None:
self.call_after_refresh(self.focus)
def _post_cancelled(self) -> None:
self.post_message(self.Cancelled())
+16 -24
View File
@@ -12,9 +12,10 @@ from rich.text import Text
from textual.binding import Binding, BindingType
from textual.containers import Container
from textual.message import Message
from textual.widget import Widget
from textual.widgets import Input, Static
from .picker_base import PickerWidgetBase, first_selectable_index, move_selection
if TYPE_CHECKING:
from textual import events
from textual.app import ComposeResult
@@ -81,14 +82,13 @@ def _build_items(
return items
class ModelPickerWidget(Widget):
class ModelPickerWidget(PickerWidgetBase):
"""Inline model picker -- mounts in chat, keyboard-driven.
Posts ``Picked(name, provider)`` on Enter, ``Cancelled()`` on Esc.
Type to filter models.
"""
can_focus = True
# Required so the Custom Ollama ``Input`` child can hold focus when the
# user is typing a model name.
can_focus_children = True
@@ -188,22 +188,19 @@ class ModelPickerWidget(Widget):
self._mode: Literal["list", "input"] = "list"
self._custom_input: Input | None = None
@staticmethod
def _is_model(item: dict) -> bool:
return item["type"] == "model"
def _first_model_index(self) -> int:
for i, item in enumerate(self._items):
if item["type"] == "model":
return i
return 0
return first_selectable_index(self._items, self._is_model)
def _move(self, direction: int) -> None:
if not self._items:
return
i = (self._selected + direction) % len(self._items)
steps = 0
while self._items[i]["type"] != "model" and steps < len(self._items):
i = (i + direction) % len(self._items)
steps += 1
if self._items[i]["type"] == "model":
self._selected = i
new = move_selection(self._items, self._selected, direction, self._is_model)
if self._is_model(self._items[new]):
self._selected = new
self._update_rows()
def _rebuild(self) -> None:
@@ -251,10 +248,9 @@ class ModelPickerWidget(Widget):
classes="picker-help",
)
def on_mount(self) -> None:
def _refresh_view(self) -> None:
self._update_rows()
self._update_filter()
self.call_later(self.focus)
def _update_filter(self) -> None:
if self._filter_widget is not None:
@@ -273,8 +269,8 @@ class ModelPickerWidget(Widget):
for i, (item, widget) in enumerate(
zip(self._items, self._row_widgets, strict=False)
):
widget.remove_class("picker-row-selected")
if item["type"] == "header":
widget.remove_class("picker-row-selected")
t = Text()
t.append("\u2500\u2500 ", style="bold cyan")
t.append(item["label"], style="bold cyan")
@@ -289,9 +285,7 @@ class ModelPickerWidget(Widget):
t.append(" *", style="bold green")
t.append(f" ({item['provider']})", style="dim italic")
widget.update(t)
if is_selected:
widget.add_class("picker-row-selected")
widget.scroll_visible()
self.apply_row_highlight(widget, is_selected)
def on_key(self, event: events.Key) -> None:
# In input mode, the Input child owns printable keys + backspace.
@@ -350,11 +344,9 @@ class ModelPickerWidget(Widget):
return
self.post_message(self.Cancelled())
def on_blur(self, event: events.Blur) -> None:
def _should_refocus_on_blur(self) -> bool:
# When the Input child has focus we must NOT steal it back.
if self._mode == "input":
return
self.call_after_refresh(self.focus)
return self._mode != "input"
def on_input_submitted(self, event: Input.Submitted) -> None:
"""Safety net: Enter fired inside the Input widget rather than
+427
View File
@@ -0,0 +1,427 @@
"""Shared engine for the TUI's keyboard-driven picker/browser widgets.
Every inline picker (model picker, thread picker, skill/MCP browsers)
follows the same pattern: a flat item list where some rows are
selectable, a wrapping highlight cursor, Enter/Esc terminal messages,
and focus trapped inside the widget until a decision is made. This
module owns that machinery so the widgets only provide their data
model and row rendering.
Subclassing contract: Textual dispatches same-named handlers at EVERY
level of the MRO, so subclasses must NOT define ``on_mount``/``on_blur``
— they implement the ``_refresh_view()`` hook (and override
``_should_refocus_on_blur()`` if focus may legitimately leave, e.g. a
child ``Input``). Message classes (``Picked``/``Confirmed``/
``Cancelled``) stay defined in each widget: their handler names
(``on_<widget>_<message>``) derive from the defining class.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, ClassVar
from rich.text import Text
from textual.binding import Binding, BindingType
from textual.containers import Container
from textual.widget import Widget
from textual.widgets import Static
if TYPE_CHECKING:
from textual import events
from textual.app import ComposeResult
# ---------------------------------------------------------------------------
# Pure selection helpers
# ---------------------------------------------------------------------------
def first_selectable_index(
items: list[Any], is_selectable: Callable[[Any], bool]
) -> int:
"""Index of the first selectable item, or 0 when none qualifies."""
for i, item in enumerate(items):
if is_selectable(item):
return i
return 0
def move_selection(
items: list[Any],
current: int,
direction: int,
is_selectable: Callable[[Any], bool],
) -> int:
"""Next selectable index from *current*, wrapping around the list.
Non-selectable rows (headers, separators) are skipped; when no
selectable row exists the *current* index is returned unchanged.
"""
if not items:
return current
i = (current + direction) % len(items)
steps = 0
while not is_selectable(items[i]) and steps < len(items):
i = (i + direction) % len(items)
steps += 1
return i if is_selectable(items[i]) else current
# ---------------------------------------------------------------------------
# Widget base
# ---------------------------------------------------------------------------
class PickerWidgetBase(Widget):
"""Focus-trapped inline picker: mount-focus, blur-refocus, row
highlight bookkeeping and description truncation."""
can_focus = True
can_focus_children = False
def _refresh_view(self) -> None:
"""Render the current state into the row widgets."""
raise NotImplementedError
def _should_refocus_on_blur(self) -> bool:
"""Whether blur should snap focus back (focus trap)."""
return True
def on_mount(self) -> None:
# Deferred so self.size is populated for width-aware rendering.
self.call_after_refresh(self._refresh_view)
self.call_later(self.focus)
def on_blur(self, event: events.Blur) -> None:
if self._should_refocus_on_blur():
self.call_after_refresh(self.focus)
@staticmethod
def apply_row_highlight(
widget: Static, selected: bool, css_class: str = "picker-row-selected"
) -> None:
"""Toggle the selected-row CSS class and keep the row in view."""
widget.remove_class(css_class)
if selected:
widget.add_class(css_class)
widget.scroll_visible()
def _row_content_width(self) -> int:
"""Usable character width for a row's text content (accounts for
widget border/padding; falls back to terminal width pre-layout)."""
try:
w = self.size.width
if w > 0:
# border (2) + widget padding (2) + row padding (2)
return w - 6
except Exception:
pass
try:
return self.app.size.width - 10
except Exception:
return 100
def _truncate(self, desc: str, name: str, *, suffix: str = "") -> str:
"""Truncate a description to fit the row, adding ellipsis."""
# cursor(2) + indicator(2) + name + " — "(3) + suffix
overhead = 2 + 2 + len(name) + 3 + len(suffix)
max_len = max(20, self._row_content_width() - overhead)
if len(desc) <= max_len:
return desc
return desc[: max_len - 1] + "…"
# ---------------------------------------------------------------------------
# Two-phase tag-filter → checkbox browser
# ---------------------------------------------------------------------------
class TagCheckboxBrowserBase(PickerWidgetBase):
"""Two-phase multi-select browser shared by the skill and MCP browsers.
Phase 1 — tag picker (Enter selects a tag filter, "all" included).
Phase 2 — checkbox list (Space toggles, Enter confirms, Esc goes back
to phase 1 unless the widget was constructed pre-filtered).
Subclasses provide the data adapters (``_item_name`` / ``_item_tags``
/ ``_item_desc``), the phase-2 texts, and ``_post_confirmed()``.
"""
DEFAULT_CSS = """
TagCheckboxBrowserBase {
height: auto;
max-height: 30;
margin: 1 0;
padding: 0 1;
background: $surface;
border: solid $primary;
}
TagCheckboxBrowserBase .browser-title {
height: 1;
text-style: bold;
color: $primary;
}
TagCheckboxBrowserBase .browser-rows {
height: auto;
max-height: 20;
overflow-y: auto;
}
TagCheckboxBrowserBase .browser-row {
height: 1;
padding: 0 1;
}
TagCheckboxBrowserBase .browser-row-selected {
background: $primary;
text-style: bold;
}
TagCheckboxBrowserBase .browser-help {
height: 1;
color: $text-muted;
text-style: italic;
}
"""
BINDINGS: ClassVar[list[BindingType]] = [
Binding("up", "move_up", "Up", show=False),
Binding("k", "move_up", "Up", show=False),
Binding("down", "move_down", "Down", show=False),
Binding("j", "move_down", "Down", show=False),
Binding("enter", "confirm", "Confirm", show=False),
Binding("space", "toggle", "Toggle", show=False),
Binding("escape", "cancel", "Cancel", show=False),
]
# -- subclass adapters --------------------------------------------
_INSTALLED_SUFFIX: ClassVar[str] = " (installed)"
_PHASE2_TITLE: ClassVar[str] = "Select items to install"
_PHASE2_CONFIRM_LABEL: ClassVar[str] = "install"
def _item_name(self, item: Any) -> str:
raise NotImplementedError
def _item_tags(self, item: Any) -> list[str]:
raise NotImplementedError
def _item_desc(self, item: Any) -> str:
raise NotImplementedError
def _post_confirmed(self, items: list[Any]) -> None:
"""Post the widget-specific ``Confirmed`` message."""
raise NotImplementedError
def _post_cancelled(self) -> None:
"""Post the widget-specific ``Cancelled`` message."""
raise NotImplementedError
# -- state ---------------------------------------------------------
def __init__(
self,
items: list[Any],
installed_names: set[str],
*,
pre_filter_tag: str = "",
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self._all_items = items
self._installed_names = installed_names
self._pre_filter_tag = pre_filter_tag.lower()
self._selected = 0
self._row_widgets: list[Static] = []
self._title_widget: Static | None = None
self._help_widget: Static | None = None
self._phase: int = 1
self._filtered_items: list[Any] = []
self._checked: set[int] = set()
# Build tag list (sorted by count desc, then alphabetically)
from collections import Counter
tag_counter: Counter[str] = Counter()
for item in self._all_items:
for t in self._item_tags(item):
tag_counter[t.lower()] += 1
sorted_tags = sorted(tag_counter.items(), key=lambda x: (-x[1], x[0]))
self._tag_items: list[tuple[str, int]] = [
("all", len(self._all_items)),
*sorted_tags,
]
# If pre-filtered, skip to phase 2
if self._pre_filter_tag:
self._filtered_items = self._items_with_tag(self._pre_filter_tag)
if self._filtered_items:
self._phase = 2
else:
self._pre_filter_tag = ""
def _items_with_tag(self, tag: str) -> list[Any]:
if tag == "all":
return list(self._all_items)
return [
item
for item in self._all_items
if tag in [t.lower() for t in self._item_tags(item)]
]
# -- layout ---------------------------------------------------------
def compose(self) -> ComposeResult:
self._title_widget = Static("", classes="browser-title")
yield self._title_widget
with Container(classes="browser-rows"):
max_rows = max(len(self._tag_items), len(self._all_items))
for _ in range(max_rows):
widget = Static("", classes="browser-row")
self._row_widgets.append(widget)
yield widget
self._help_widget = Static("", classes="browser-help")
yield self._help_widget
# -- rendering ------------------------------------------------------
def _refresh_view(self) -> None:
if self._phase == 1:
self._render_tag_picker()
else:
self._render_checkbox_list()
def _render_tag_picker(self) -> None:
if self._title_widget:
self._title_widget.update("Filter by tag:")
if self._help_widget:
self._help_widget.update("↑/↓ navigate · Enter select · Esc cancel")
for i, widget in enumerate(self._row_widgets):
if i < len(self._tag_items):
tag, count = self._tag_items[i]
is_selected = i == self._selected
text = Text()
cursor = "▸ " if is_selected else " "
text.append(cursor, style="bold cyan" if is_selected else "dim")
text.append(f"{tag} ({count})", style="bold" if is_selected else "")
widget.update(text)
widget.display = True
self.apply_row_highlight(widget, is_selected, "browser-row-selected")
else:
widget.update("")
widget.display = False
def _render_checkbox_list(self) -> None:
n_checked = len(
[
i
for i in self._checked
if self._item_name(self._filtered_items[i]) not in self._installed_names
]
)
if self._title_widget:
self._title_widget.update(f"{self._PHASE2_TITLE} ({n_checked} selected):")
if self._help_widget:
self._help_widget.update(
"↑/↓ navigate · Space toggle · "
f"Enter {self._PHASE2_CONFIRM_LABEL} · Esc cancel"
)
for i, widget in enumerate(self._row_widgets):
if i < len(self._filtered_items):
item = self._filtered_items[i]
name = self._item_name(item)
is_selected = i == self._selected
is_installed = name in self._installed_names
is_checked = i in self._checked
text = Text()
cursor = "▸ " if is_selected else " "
text.append(cursor, style="bold cyan" if is_selected else "dim")
if is_installed:
suffix = self._INSTALLED_SUFFIX
desc = self._truncate(self._item_desc(item), name, suffix=suffix)
text.append("✓ ", style="green")
text.append(name, style="green dim")
text.append(f" — {desc}", style="dim")
text.append(suffix, style="dim italic")
elif is_checked:
desc = self._truncate(self._item_desc(item), name)
text.append("● ", style="green bold")
text.append(name, style="bold")
text.append(f" — {desc}", style="")
else:
desc = self._truncate(self._item_desc(item), name)
text.append("○ ", style="dim")
text.append(name, style="bold" if is_selected else "")
text.append(f" — {desc}", style="dim")
widget.update(text)
widget.display = True
self.apply_row_highlight(widget, is_selected, "browser-row-selected")
else:
widget.update("")
widget.display = False
# -- actions ----------------------------------------------------------
def _current_items_count(self) -> int:
if self._phase == 1:
return len(self._tag_items)
return len(self._filtered_items)
def action_move_up(self) -> None:
n = self._current_items_count()
if not n:
return
self._selected = (self._selected - 1) % n
self._refresh_view()
def action_move_down(self) -> None:
n = self._current_items_count()
if not n:
return
self._selected = (self._selected + 1) % n
self._refresh_view()
def action_toggle(self) -> None:
"""Toggle checkbox selection (phase 2 only)."""
if self._phase != 2 or not self._filtered_items:
return
if self._item_name(self._filtered_items[self._selected]) in (
self._installed_names
):
return # Can't toggle already-installed items
if self._selected in self._checked:
self._checked.discard(self._selected)
else:
self._checked.add(self._selected)
self._refresh_view()
def action_confirm(self) -> None:
if self._phase == 1:
if not self._tag_items:
return
tag, _ = self._tag_items[self._selected]
self._filtered_items = self._items_with_tag(tag)
self._phase = 2
self._selected = 0
self._checked = set()
self._refresh_view()
else:
items = [
self._filtered_items[i]
for i in sorted(self._checked)
if self._item_name(self._filtered_items[i]) not in self._installed_names
]
self._post_confirmed(items)
def action_cancel(self) -> None:
if self._phase == 2 and not self._pre_filter_tag:
# Go back to tag picker
self._phase = 1
self._selected = 0
self._checked = set()
self._refresh_view()
else:
self._post_cancelled()
+19 -316
View File
@@ -1,6 +1,7 @@
"""Inline skill browser widget for /evoskills in TUI.
Two-phase keyboard-driven widget:
Two-phase keyboard-driven widget built on the shared picker engine
(``picker_base.TagCheckboxBrowserBase``):
Phase 1 — tag picker (arrow keys + Enter to select, or Esc for all)
Phase 2 — skill checkbox (arrow keys to navigate, Space to toggle, Enter to confirm)
@@ -10,73 +11,19 @@ or ``SkillBrowserWidget.Cancelled`` on Esc.
from __future__ import annotations
from typing import TYPE_CHECKING, Any, ClassVar
from typing import Any, ClassVar
from rich.text import Text
from textual.binding import Binding, BindingType
from textual.containers import Container
from textual.message import Message
from textual.widget import Widget
from textual.widgets import Static
if TYPE_CHECKING:
from textual import events
from textual.app import ComposeResult
from .picker_base import TagCheckboxBrowserBase
class SkillBrowserWidget(Widget):
"""Inline skill browser — mounts in chat, keyboard-driven.
class SkillBrowserWidget(TagCheckboxBrowserBase):
"""Inline skill browser — mounts in chat, keyboard-driven."""
Phase 1: Tag picker (select a tag filter or "All").
Phase 2: Skill checkbox (toggle skills, confirm to install).
"""
can_focus = True
can_focus_children = False
DEFAULT_CSS = """
SkillBrowserWidget {
height: auto;
max-height: 30;
margin: 1 0;
padding: 0 1;
background: $surface;
border: solid $primary;
}
SkillBrowserWidget .browser-title {
height: 1;
text-style: bold;
color: $primary;
}
SkillBrowserWidget .browser-rows {
height: auto;
max-height: 20;
overflow-y: auto;
}
SkillBrowserWidget .browser-row {
height: 1;
padding: 0 1;
}
SkillBrowserWidget .browser-row-selected {
background: $primary;
text-style: bold;
}
SkillBrowserWidget .browser-help {
height: 1;
color: $text-muted;
text-style: italic;
}
"""
BINDINGS: ClassVar[list[BindingType]] = [
Binding("up", "move_up", "Up", show=False),
Binding("k", "move_up", "Up", show=False),
Binding("down", "move_down", "Down", show=False),
Binding("j", "move_down", "Down", show=False),
Binding("enter", "confirm", "Confirm", show=False),
Binding("space", "toggle", "Toggle", show=False),
Binding("escape", "cancel", "Cancel", show=False),
]
_INSTALLED_SUFFIX: ClassVar[str] = " (installed)"
_PHASE2_TITLE: ClassVar[str] = "Select skills to install"
_PHASE2_CONFIRM_LABEL: ClassVar[str] = "install"
class Confirmed(Message):
"""Posted when user confirms skill selection."""
@@ -88,261 +35,17 @@ class SkillBrowserWidget(Widget):
class Cancelled(Message):
"""Posted when user cancels."""
def __init__(
self,
index: list[dict],
installed_names: set[str],
*,
pre_filter_tag: str = "",
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self._index = index
self._installed_names = installed_names
self._pre_filter_tag = pre_filter_tag.lower()
self._selected = 0
self._row_widgets: list[Static] = []
self._title_widget: Static | None = None
self._help_widget: Static | None = None
def _item_name(self, item: Any) -> str:
return item["name"]
# Phase 1: tag picker
# Phase 2: skill checkbox
self._phase: int = 1
self._tag_items: list[tuple[str, int]] = [] # (tag, count)
self._skill_items: list[dict] = [] # filtered skills
self._checked: set[int] = set() # indices of checked skills
def _item_tags(self, item: Any) -> list[str]:
return item.get("tags", [])
# Build tag list (sorted by count desc, then alphabetically)
from collections import Counter
def _item_desc(self, item: Any) -> str:
return item["description"]
tag_counter: Counter[str] = Counter()
for s in self._index:
for t in s.get("tags", []):
tag_counter[t.lower()] += 1
sorted_tags = sorted(tag_counter.items(), key=lambda x: (-x[1], x[0]))
self._tag_items = [("all", len(self._index)), *sorted_tags]
def _post_confirmed(self, items: list[Any]) -> None:
self.post_message(self.Confirmed([s["install_source"] for s in items]))
# If pre-filtered, skip to phase 2
if self._pre_filter_tag:
self._skill_items = [
s
for s in self._index
if self._pre_filter_tag in [t.lower() for t in s.get("tags", [])]
]
if self._skill_items:
self._phase = 2
else:
# No matches — show tag picker anyway
self._pre_filter_tag = ""
def compose(self) -> ComposeResult:
self._title_widget = Static("", classes="browser-title")
yield self._title_widget
with Container(classes="browser-rows"):
# Pre-allocate enough rows for the larger of tag list or skill list
max_rows = max(len(self._tag_items), len(self._index))
for _ in range(max_rows):
widget = Static("", classes="browser-row")
self._row_widgets.append(widget)
yield widget
self._help_widget = Static("", classes="browser-help")
yield self._help_widget
def on_mount(self) -> None:
# Defer rendering until after layout so self.size is populated
self.call_after_refresh(self._update_display)
self.call_later(self.focus)
def _update_display(self) -> None:
if self._phase == 1:
self._render_tag_picker()
else:
self._render_skill_checkbox()
def _render_tag_picker(self) -> None:
if self._title_widget:
self._title_widget.update("Filter by tag:")
if self._help_widget:
self._help_widget.update("↑/↓ navigate · Enter select · Esc cancel")
for i, widget in enumerate(self._row_widgets):
if i < len(self._tag_items):
tag, count = self._tag_items[i]
is_selected = i == self._selected
text = Text()
cursor = "▸ " if is_selected else " "
text.append(cursor, style="bold cyan" if is_selected else "dim")
label = f"{tag} ({count})"
text.append(label, style="bold" if is_selected else "")
widget.update(text)
widget.display = True
widget.remove_class("browser-row-selected")
if is_selected:
widget.add_class("browser-row-selected")
widget.scroll_visible()
else:
widget.update("")
widget.display = False
def _row_content_width(self) -> int:
"""Get the usable character width for a row's text content.
Accounts for widget border, widget padding, and row padding.
Falls back to terminal width if the widget hasn't been laid out yet.
"""
try:
w = self.size.width
if w > 0:
# border (2) + widget padding-left/right (2) + row padding-left/right (2)
return w - 6
except Exception:
pass
# Fallback: use terminal width minus reasonable chrome
try:
return self.app.size.width - 10
except Exception:
return 100
def _truncate(self, desc: str, name: str, *, suffix: str = "") -> str:
"""Truncate a description to fit the row, adding ellipsis if needed."""
# cursor(2) + indicator(2) + name + " — "(3) + suffix
overhead = 2 + 2 + len(name) + 3 + len(suffix)
max_len = max(20, self._row_content_width() - overhead)
if len(desc) <= max_len:
return desc
return desc[: max_len - 1] + "…"
def _render_skill_checkbox(self) -> None:
n_checked = len(
[
i
for i in self._checked
if self._skill_items[i]["name"] not in self._installed_names
]
)
if self._title_widget:
self._title_widget.update(
f"Select skills to install ({n_checked} selected):"
)
if self._help_widget:
self._help_widget.update(
"↑/↓ navigate · Space toggle · Enter install · Esc cancel"
)
for i, widget in enumerate(self._row_widgets):
if i < len(self._skill_items):
skill = self._skill_items[i]
is_selected = i == self._selected
is_installed = skill["name"] in self._installed_names
is_checked = i in self._checked
text = Text()
cursor = "▸ " if is_selected else " "
text.append(cursor, style="bold cyan" if is_selected else "dim")
if is_installed:
suffix = " (installed)"
desc = self._truncate(
desc=skill["description"],
name=skill["name"],
suffix=suffix,
)
text.append("✓ ", style="green")
text.append(skill["name"], style="green dim")
text.append(f" — {desc}", style="dim")
text.append(suffix, style="dim italic")
elif is_checked:
desc = self._truncate(skill["description"], skill["name"])
text.append("● ", style="green bold")
text.append(skill["name"], style="bold")
text.append(f" — {desc}", style="")
else:
desc = self._truncate(skill["description"], skill["name"])
text.append("○ ", style="dim")
text.append(skill["name"], style="bold" if is_selected else "")
text.append(f" — {desc}", style="dim")
widget.update(text)
widget.display = True
widget.remove_class("browser-row-selected")
if is_selected:
widget.add_class("browser-row-selected")
widget.scroll_visible()
else:
widget.update("")
widget.display = False
def _current_items_count(self) -> int:
if self._phase == 1:
return len(self._tag_items)
return len(self._skill_items)
def action_move_up(self) -> None:
n = self._current_items_count()
if not n:
return
self._selected = (self._selected - 1) % n
self._update_display()
def action_move_down(self) -> None:
n = self._current_items_count()
if not n:
return
self._selected = (self._selected + 1) % n
self._update_display()
def action_toggle(self) -> None:
"""Toggle skill selection (phase 2 only)."""
if self._phase != 2:
return
if not self._skill_items:
return
skill = self._skill_items[self._selected]
if skill["name"] in self._installed_names:
return # Can't toggle installed skills
if self._selected in self._checked:
self._checked.discard(self._selected)
else:
self._checked.add(self._selected)
self._update_display()
def action_confirm(self) -> None:
if self._phase == 1:
# Transition to phase 2
if not self._tag_items:
return
tag, _ = self._tag_items[self._selected]
if tag == "all":
self._skill_items = list(self._index)
else:
self._skill_items = [
s
for s in self._index
if tag in [t.lower() for t in s.get("tags", [])]
]
self._phase = 2
self._selected = 0
self._checked = set()
self._update_display()
else:
# Confirm selection
sources = [
self._skill_items[i]["install_source"]
for i in sorted(self._checked)
if self._skill_items[i]["name"] not in self._installed_names
]
self.post_message(self.Confirmed(sources))
def action_cancel(self) -> None:
if self._phase == 2 and not self._pre_filter_tag:
# Go back to tag picker
self._phase = 1
self._selected = 0
self._checked = set()
self._update_display()
else:
self.post_message(self.Cancelled())
def on_blur(self, event: events.Blur) -> None:
"""Re-focus to keep focus trapped until decision is made."""
self.call_after_refresh(self.focus)
def _post_cancelled(self) -> None:
self.post_message(self.Cancelled())
+15 -26
View File
@@ -23,11 +23,11 @@ from rich.text import Text
from textual.binding import Binding, BindingType
from textual.containers import Container
from textual.message import Message
from textual.widget import Widget
from textual.widgets import Static
from .picker_base import PickerWidgetBase, first_selectable_index, move_selection
if TYPE_CHECKING:
from textual import events
from textual.app import ComposeResult
@@ -237,16 +237,13 @@ def build_row_text(
# ---------------------------------------------------------------------------
class ThreadPickerWidget(Widget):
class ThreadPickerWidget(PickerWidgetBase):
"""Inline thread picker — mounts in chat, keyboard-driven.
Posts ``Picked(thread_id)`` on Enter, ``Cancelled()`` on Esc.
Threads are displayed in a two-level workspace hierarchy.
"""
can_focus = True
can_focus_children = False
DEFAULT_CSS = """
ThreadPickerWidget {
height: auto;
@@ -323,22 +320,19 @@ class ThreadPickerWidget(Widget):
self._selected = self._first_thread_index()
self._row_widgets: list[Static] = []
@staticmethod
def _is_thread(item: dict) -> bool:
return item["type"] == "thread"
def _first_thread_index(self) -> int:
for i, item in enumerate(self._items):
if item["type"] == "thread":
return i
return 0
return first_selectable_index(self._items, self._is_thread)
def _move(self, direction: int) -> None:
if not self._items:
return
i = (self._selected + direction) % len(self._items)
steps = 0
while self._items[i]["type"] != "thread" and steps < len(self._items):
i = (i + direction) % len(self._items)
steps += 1
if self._items[i]["type"] == "thread":
self._selected = i
new = move_selection(self._items, self._selected, direction, self._is_thread)
if self._is_thread(self._items[new]):
self._selected = new
self._update_rows()
def compose(self) -> ComposeResult:
@@ -358,18 +352,18 @@ class ThreadPickerWidget(Widget):
classes="picker-help",
)
def on_mount(self) -> None:
def _refresh_view(self) -> None:
self._update_rows()
self.call_later(self.focus)
def _update_rows(self) -> None:
for i, (item, widget) in enumerate(
zip(self._items, self._row_widgets, strict=False)
):
widget.remove_class("picker-row-selected")
if item["type"] == "header":
widget.remove_class("picker-row-selected")
widget.update(build_header_text(item["label"]))
elif item["type"] == "subheader":
widget.remove_class("picker-row-selected")
widget.update(build_subheader_text(item["label"]))
else:
thread = item["thread"]
@@ -381,9 +375,7 @@ class ThreadPickerWidget(Widget):
indented=item.get("indented", False),
)
widget.update(text)
if is_selected:
widget.add_class("picker-row-selected")
widget.scroll_visible()
self.apply_row_highlight(widget, is_selected)
def action_move_up(self) -> None:
self._move(-1)
@@ -403,6 +395,3 @@ class ThreadPickerWidget(Widget):
def action_cancel(self) -> None:
self.post_message(self.Cancelled())
def on_blur(self, event: events.Blur) -> None:
self.call_after_refresh(self.focus)
+255
View File
@@ -0,0 +1,255 @@
"""Behavior tests for the picker/browser widgets and their shared engine.
Written against the pre-refactor SkillBrowserWidget / MCPBrowserWidget
behavior to pin the two-phase tag->checkbox flow, then kept green while
the widgets migrated onto the shared picker engine (PR #362 follow-up
discussion). Widgets are driven without a running app: ``__init__`` and
the ``action_*`` methods only touch widget state, and DOM-dependent
rendering no-ops before ``compose()``.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import ClassVar
from unittest.mock import MagicMock
from EvoScientist.cli.widgets.mcp_browser import MCPBrowserWidget
from EvoScientist.cli.widgets.picker_base import (
first_selectable_index,
move_selection,
)
from EvoScientist.cli.widgets.skill_browser import SkillBrowserWidget
_SKILL_INDEX = [
{
"name": "paper-writing",
"description": "Write papers",
"tags": ["writing"],
"install_source": "src/paper-writing",
},
{
"name": "paper-review",
"description": "Review papers",
"tags": ["writing", "review"],
"install_source": "src/paper-review",
},
{
"name": "experiment-pipeline",
"description": "Run experiments",
"tags": ["experiments"],
"install_source": "src/experiment-pipeline",
},
]
@dataclass
class _Server:
name: str
tags: list[str] = field(default_factory=list)
description: str = ""
label: str = ""
_SERVERS = [
_Server("tavily", tags=["search"], description="Web search"),
_Server("github", tags=["dev", "search"], description="GitHub API"),
_Server("filesystem", tags=["dev"], description="Local files"),
]
def _skill_browser(**kwargs) -> SkillBrowserWidget:
w = SkillBrowserWidget(_SKILL_INDEX, {"paper-review"}, **kwargs)
w.post_message = MagicMock()
w.focus = MagicMock()
return w
def _mcp_browser(**kwargs) -> MCPBrowserWidget:
w = MCPBrowserWidget(_SERVERS, {"github"}, **kwargs)
w.post_message = MagicMock()
w.focus = MagicMock()
return w
class TestSelectionHelpers:
"""Pure navigation helpers shared by all pickers (headers skipped,
wrapping cursor)."""
_ITEMS: ClassVar[list[str]] = ["header", "item", "item", "header", "item"]
@staticmethod
def _selectable(item: str) -> bool:
return item == "item"
def test_first_selectable_skips_headers(self):
assert first_selectable_index(self._ITEMS, self._selectable) == 1
def test_first_selectable_empty_or_none_returns_zero(self):
assert first_selectable_index([], self._selectable) == 0
assert first_selectable_index(["header"], self._selectable) == 0
def test_move_skips_headers_and_wraps(self):
assert move_selection(self._ITEMS, 1, 1, self._selectable) == 2
assert move_selection(self._ITEMS, 2, 1, self._selectable) == 4
# wraps past the trailing header back to the first item
assert move_selection(self._ITEMS, 4, 1, self._selectable) == 1
assert move_selection(self._ITEMS, 1, -1, self._selectable) == 4
def test_move_with_no_selectable_keeps_current(self):
items = ["header", "header"]
assert move_selection(items, 0, 1, self._selectable) == 0
def test_move_single_selectable_stays_put(self):
items = ["header", "item"]
assert move_selection(items, 1, 1, self._selectable) == 1
class TestSkillBrowserFlow:
def test_tag_items_sorted_by_count_with_all_first(self):
w = _skill_browser()
assert w._tag_items[0] == ("all", 3)
# "writing" appears twice -> first among real tags
assert w._tag_items[1] == ("writing", 2)
def test_phase1_enter_filters_and_enters_phase2(self):
w = _skill_browser()
w._selected = 1 # "writing"
w.action_confirm()
assert w._phase == 2
assert [s["name"] for s in w._filtered_items] == [
"paper-writing",
"paper-review",
]
assert w._selected == 0
def test_phase1_all_selects_everything(self):
w = _skill_browser()
w._selected = 0 # "all"
w.action_confirm()
assert len(w._filtered_items) == 3
def test_space_toggles_uninstalled_only(self):
w = _skill_browser()
w.action_confirm() # all -> phase 2
w._selected = 0 # paper-writing (not installed)
w.action_toggle()
assert 0 in w._checked
w.action_toggle()
assert 0 not in w._checked
w._selected = 1 # paper-review (installed)
w.action_toggle()
assert 1 not in w._checked
def test_confirm_posts_confirmed_with_sources(self):
w = _skill_browser()
w.action_confirm() # all -> phase 2
w._selected = 0
w.action_toggle()
w._selected = 2
w.action_toggle()
w.action_confirm()
msg = w.post_message.call_args[0][0]
assert isinstance(msg, SkillBrowserWidget.Confirmed)
assert msg.install_sources == ["src/paper-writing", "src/experiment-pipeline"]
def test_escape_returns_to_phase1_then_cancels(self):
w = _skill_browser()
w.action_confirm() # phase 2
w.action_cancel()
assert w._phase == 1
w.action_cancel()
msg = w.post_message.call_args[0][0]
assert isinstance(msg, SkillBrowserWidget.Cancelled)
def test_pre_filter_tag_skips_to_phase2(self):
w = _skill_browser(pre_filter_tag="experiments")
assert w._phase == 2
assert [s["name"] for s in w._filtered_items] == ["experiment-pipeline"]
# Esc cancels directly (no tag-picker phase to return to)
w.action_cancel()
msg = w.post_message.call_args[0][0]
assert isinstance(msg, SkillBrowserWidget.Cancelled)
def test_pre_filter_without_match_falls_back_to_phase1(self):
w = _skill_browser(pre_filter_tag="nonexistent")
assert w._phase == 1
def test_move_wraps_around(self):
w = _skill_browser()
n = len(w._tag_items)
w._selected = 0
w.action_move_up()
assert w._selected == n - 1
w.action_move_down()
assert w._selected == 0
class TestMountedBrowser:
"""End-to-end smoke test: the engine's mount hook, bindings and
base-class CSS must all work on a real mounted widget."""
async def test_skill_browser_keyboard_flow(self):
from textual.app import App, ComposeResult
posted: list = []
class BrowserApp(App[None]):
def compose(self) -> ComposeResult:
yield SkillBrowserWidget(_SKILL_INDEX, {"paper-review"})
def on_skill_browser_widget_confirmed(self, event) -> None:
posted.append(event)
app = BrowserApp()
async with app.run_test(size=(80, 30)) as pilot:
widget = app.query_one(SkillBrowserWidget)
await pilot.pause()
# Base CSS (via TagCheckboxBrowserBase type selector) applied
assert widget.styles.border.top is not None
# Phase 1 renders the tag rows
assert widget._phase == 1
# Enter on "all" -> phase 2; space toggles; enter confirms
await pilot.press("enter")
assert widget._phase == 2
await pilot.press("space")
await pilot.press("enter")
await pilot.pause()
assert len(posted) == 1
assert posted[0].install_sources == ["src/paper-writing"]
class TestMCPBrowserFlow:
def test_tag_items_sorted_by_count_with_all_first(self):
w = _mcp_browser()
assert w._tag_items[0] == ("all", 3)
assert {t for t, _ in w._tag_items[1:]} == {"dev", "search"}
def test_confirm_posts_confirmed_with_entries(self):
w = _mcp_browser()
w.action_confirm() # all -> phase 2
w._selected = 0 # tavily (not installed)
w.action_toggle()
w._selected = 1 # github (installed -> ignored)
w.action_toggle()
w.action_confirm()
msg = w.post_message.call_args[0][0]
assert isinstance(msg, MCPBrowserWidget.Confirmed)
assert [e.name for e in msg.entries] == ["tavily"]
def test_tag_filter_selects_matching_servers(self):
w = _mcp_browser()
tag_index = [t for t, _ in w._tag_items].index("dev")
w._selected = tag_index
w.action_confirm()
assert w._phase == 2
assert [e.name for e in w._filtered_items] == ["github", "filesystem"]
def test_escape_returns_to_phase1_then_cancels(self):
w = _mcp_browser()
w.action_confirm()
w.action_cancel()
assert w._phase == 1
w.action_cancel()
msg = w.post_message.call_args[0][0]
assert isinstance(msg, MCPBrowserWidget.Cancelled)
+310
View File
@@ -0,0 +1,310 @@
"""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
+5 -23
View File
@@ -695,7 +695,6 @@ class TestCompletionLogic(unittest.TestCase):
def _make_app(self, comp_items=None, comp_index=-1):
"""Return a stub app-like object with completion state."""
from rich.text import Text
# Fake Input widget -------------------------------------------------
class _FakeInput:
@@ -795,29 +794,12 @@ class TestCompletionLogic(unittest.TestCase):
self.query_one("#completions").display = False
def _render_completions(self):
from EvoScientist.cli.tui_interactive import _render_completion_text
comp_widget = self.query_one("#completions")
comp_text = Text()
for i, candidate in enumerate(self._comp_items):
cmd, desc = candidate.text, candidate.description
cat = getattr(candidate, "category", "")
if cat and (
i == 0
or getattr(self._comp_items[i - 1], "category", "") != cat
):
if i > 0:
comp_text.append("\n")
comp_text.append(f" {cat}\n", style="bold #6b7280")
if i == self._comp_index:
comp_text.append(" \u25b8 ", style="bold")
comp_text.append(f"{cmd:<28}", style="bold")
comp_text.append(desc, style="bold")
else:
comp_text.append(" ", style="#888888")
comp_text.append(f"{cmd:<28}", style="#888888")
comp_text.append(desc, style="#888888")
if i < len(self._comp_items) - 1:
comp_text.append("\n")
comp_widget.update(comp_text)
comp_widget.update(
_render_completion_text(self._comp_items, self._comp_index, 15)
)
def on_key(self, key: str):
comp_widget = self.query_one("#completions")
Generated
+2
View File
@@ -961,6 +961,7 @@ dependencies = [
{ name = "langchain-openrouter" },
{ name = "langgraph-checkpoint-sqlite" },
{ name = "langgraph-cli", extra = ["inmem"] },
{ name = "langgraph-sdk" },
{ name = "lazy-loader" },
{ name = "markdownify" },
{ name = "nest-asyncio" },
@@ -1068,6 +1069,7 @@ requires-dist = [
{ name = "langchain-openrouter", specifier = ">=0.2.5" },
{ name = "langgraph-checkpoint-sqlite", specifier = ">=3.0" },
{ name = "langgraph-cli", extras = ["inmem"], specifier = ">=0.4" },
{ name = "langgraph-sdk", specifier = ">=0.4" },
{ name = "lark-oapi", marker = "extra == 'all-channels'", specifier = ">=1.4.0" },
{ name = "lark-oapi", marker = "extra == 'feishu'", specifier = ">=1.4.0" },
{ name = "lazy-loader", specifier = ">=0.5" },