Coverage for src/lilbee/cli/tui/screens/model_picker.py: 100%
86 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-17 10:02 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-17 10:02 +0000
1"""Modal picker for chat / embedding model selection.
3Replaces the flat ``Select`` widgets in ModelBar. Opens with focus on a
4search input that filters a virtualized ``ModelList`` body. Returns the
5selected ref via ``dismiss``; the caller routes it through
6``apply_active_model`` so the persistence path is unchanged.
7"""
9from __future__ import annotations
11from dataclasses import dataclass
12from typing import ClassVar, Literal
14from textual import on
15from textual.app import ComposeResult
16from textual.binding import Binding, BindingType
17from textual.containers import Vertical
18from textual.screen import ModalScreen
19from textual.timer import Timer
20from textual.widgets import Input, Label, Static
22from lilbee.catalog.types import ModelCompat
23from lilbee.cli.tui import messages as msg
24from lilbee.cli.tui.screens.catalog_utils import (
25 CatalogRow,
26 LocalCatalogRow,
27)
28from lilbee.cli.tui.widgets.model_bar import ModelOption
29from lilbee.cli.tui.widgets.model_list import ModelList, ModelListSection
31PickerScope = Literal["chat", "embed", "vision", "rerank"]
33# Sentinel ref returned by the picker when the user picks the "Browse catalog
34# to download" row. apply_model_pick intercepts it and navigates to the
35# Catalog focused on the role's task tab; it is never persisted as a real ref.
36BROWSE_CATALOG_REF = "__browse_catalog__"
39def _picker_title(scope: PickerScope) -> str:
40 """Return the modal heading for the requested scope."""
41 if scope == "embed":
42 return msg.MODEL_PICKER_TITLE_EMBED
43 if scope == "vision":
44 return msg.MODEL_PICKER_TITLE_VISION
45 if scope == "rerank":
46 return msg.MODEL_PICKER_TITLE_RERANK
47 return msg.MODEL_PICKER_TITLE_CHAT
50def _browse_catalog_row() -> CatalogRow:
51 """The 'Browse catalog' action row appended to every picker."""
52 return LocalCatalogRow(
53 name=msg.MODEL_PICKER_BROWSE_CATALOG,
54 task="",
55 params="--",
56 size="--",
57 quant="--",
58 downloads="--",
59 featured=False,
60 installed=False,
61 sort_downloads=0,
62 sort_size=0.0,
63 ref=BROWSE_CATALOG_REF,
64 backend="",
65 compat=ModelCompat.SUPPORTED,
66 )
69@dataclass
70class _PickerOptions:
71 """Bridge from the dropdown's ``ModelOption`` shape to ``CatalogRow``."""
73 options: list[ModelOption]
75 def to_sections(self, search: str) -> list[ModelListSection]:
76 rows = [_option_to_row(o) for o in self.options if _matches(o, search)]
77 # The browse-catalog action row is always present (filter-agnostic) so
78 # the on-ramp is reachable even when the search box is empty AND when
79 # it has narrowed the list to zero installed matches.
80 rows.append(_browse_catalog_row())
81 return [ModelListSection(heading=None, rows=rows)]
84def _option_to_row(option: ModelOption) -> CatalogRow:
85 # Picker options are already-available models; mark SUPPORTED so the list
86 # doesn't tag every row with the "?" unknown-compat indicator.
87 return LocalCatalogRow(
88 name=option.label,
89 task="",
90 params="--",
91 size="--",
92 quant="--",
93 downloads="--",
94 featured=False,
95 installed=False,
96 sort_downloads=0,
97 sort_size=0.0,
98 ref=option.ref,
99 backend="",
100 compat=ModelCompat.SUPPORTED,
101 )
104def _matches(option: ModelOption, search: str) -> bool:
105 if not search:
106 return True
107 needle = search.lower()
108 return needle in option.label.lower() or needle in option.ref.lower()
111class ModelPickerModal(ModalScreen[str | None]):
112 """Searchable model list. Returns the selected ref or None on cancel."""
114 CSS_PATH: ClassVar[str] = "model_picker.tcss"
116 BINDINGS: ClassVar[list[BindingType]] = [
117 Binding("escape", "dismiss(None)", "Cancel", show=True),
118 Binding("slash", "focus_search", "Search", show=True),
119 # Arrow keys reach the list even while the search input has focus, so
120 # open -> arrows -> Enter works without knowing about Tab. When the
121 # list itself is focused its own cursor bindings win.
122 Binding("down", "list_cursor_down", "Next", show=False),
123 Binding("up", "list_cursor_up", "Prev", show=False),
124 ]
126 _SEARCH_DEBOUNCE_SECONDS = 0.08
128 def __init__(
129 self,
130 *,
131 scope: PickerScope,
132 options: list[ModelOption],
133 ) -> None:
134 super().__init__()
135 self._scope: PickerScope = scope
136 self._options = _PickerOptions(options=options)
137 self._search_timer: Timer | None = None
139 def compose(self) -> ComposeResult:
140 with Vertical(id="picker-root"):
141 yield Label(_picker_title(self._scope), id="picker-title")
142 yield Input(placeholder=msg.MODEL_PICKER_SEARCH_PLACEHOLDER, id="picker-search")
143 yield ModelList(id="picker-list")
144 yield Static(msg.MODEL_PICKER_HINT, id="picker-hint")
146 def on_mount(self) -> None:
147 self._refresh_list("")
148 self.query_one("#picker-search", Input).focus()
150 def _refresh_list(self, search: str) -> None:
151 ml = self.query_one("#picker-list", ModelList)
152 sections = self._options.to_sections(search)
153 ml.set_rows(sections)
154 # A default highlight makes Enter select the first match immediately.
155 # The ever-present browse-catalog action row is not a match, so Enter
156 # with no real matches stays a no-op instead of jumping to the catalog.
157 has_match = any(
158 row.ref != BROWSE_CATALOG_REF for section in sections for row in section.rows
159 )
160 ml.highlighted = 0 if has_match else None
162 @on(Input.Changed, "#picker-search")
163 def _on_search_changed(self, event: Input.Changed) -> None:
164 # Debounce: rapid typing collapses to one rebuild after the user pauses.
165 # Without this, each keystroke remounts every option (~50 ms each at 500 rows).
166 if self._search_timer is not None:
167 self._search_timer.stop()
168 search = event.value.strip()
169 self._search_timer = self.set_timer(
170 self._SEARCH_DEBOUNCE_SECONDS, lambda: self._refresh_list(search)
171 )
173 @on(Input.Submitted, "#picker-search")
174 def _on_search_submitted(self) -> None:
175 ml = self.query_one("#picker-list", ModelList)
176 if ml.option_count:
177 ml.action_select()
179 @on(ModelList.Selected)
180 def _on_model_list_selected(self, event: ModelList.Selected) -> None:
181 event.stop()
182 self.dismiss(event.row.ref)
184 def action_focus_search(self) -> None:
185 self.query_one("#picker-search", Input).focus()
187 def action_list_cursor_down(self) -> None:
188 self.query_one("#picker-list", ModelList).action_cursor_down()
190 def action_list_cursor_up(self) -> None:
191 self.query_one("#picker-list", ModelList).action_cursor_up()