Coverage for src/lilbee/cli/tui/screens/catalog.py: 100%
1371 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
1"""Catalog screen -- browse and install models via grid or list view."""
3from __future__ import annotations
5import contextlib
6import logging
7import time
8from dataclasses import dataclass
9from typing import ClassVar, NamedTuple
11from textual import getters, on, work
12from textual.app import ComposeResult
13from textual.binding import Binding, BindingType
14from textual.containers import Container, Horizontal, VerticalScroll
15from textual.css.query import NoMatches
16from textual.events import Click, Key, MouseScrollDown
17from textual.message import Message
18from textual.screen import Screen
19from textual.timer import Timer
20from textual.widget import AwaitMount
21from textual.widgets import Footer, Input, Static, TabbedContent, TabPane
22from textual.worker import Worker, WorkerState
24from lilbee.app.services import get_services
25from lilbee.catalog import (
26 CatalogModel,
27 ModelFamily,
28 ModelVariant,
29 disk_shortfall,
30 get_catalog,
31 get_families,
32 resolve_filename,
33)
34from lilbee.catalog.download import _BYTES_PER_GB, download_bytes
35from lilbee.catalog.models import estimate_min_ram_gb
36from lilbee.catalog.types import ModelCompat, ModelSource, ModelTask
37from lilbee.cli.tui import messages as msg
38from lilbee.cli.tui.app import LilbeeApp, apply_active_model
39from lilbee.cli.tui.screens.catalog_grouping import (
40 GridSection,
41 flatten_sections,
42 for_you_by_role,
43 group_frontier_rows,
44 group_rows_for_grid,
45 group_task_rows_with_picks,
46 row_cache_signature,
47)
48from lilbee.cli.tui.screens.catalog_utils import (
49 SORT_KEYS,
50 TAB_CHAT,
51 TAB_DISCOVER,
52 TAB_EMBED,
53 TAB_ID_TO_TASK,
54 TAB_LIBRARY,
55 TAB_RERANK,
56 TAB_VISION,
57 TASK_TAB_IDS,
58 CatalogRow,
59 CatalogRowKind,
60 FrontierCatalogRow,
61 KeyStatus,
62 LocalCatalogRow,
63 SourceMode,
64 catalog_to_row,
65 family_to_size_variants,
66 frontier_row_from_remote,
67 matches_search,
68 next_source_mode,
69 remote_to_row,
70 row_delete_id,
71 variant_to_row,
72)
73from lilbee.cli.tui.spinner import SPINNER_FRAMES
74from lilbee.cli.tui.thread_safe import call_from_thread
75from lilbee.cli.tui.widgets.bottom_bars import BottomBars
76from lilbee.cli.tui.widgets.catalog_detail import CatalogDetailDrawer
77from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
78from lilbee.cli.tui.widgets.discover_rails import DiscoverRails
79from lilbee.cli.tui.widgets.grid_select import GridSelect
80from lilbee.cli.tui.widgets.model_card import ModelCard
81from lilbee.cli.tui.widgets.model_grid import ModelGrid
82from lilbee.cli.tui.widgets.model_list import ModelList, ModelListSection
83from lilbee.cli.tui.widgets.status_bar import ViewTabs
84from lilbee.cli.tui.widgets.task_bar import TaskBar
85from lilbee.cli.tui.widgets.top_bars import TopBars
86from lilbee.core.config import cfg
87from lilbee.modelhub.model_manager import RemoteModel, classify_all_remote_models
88from lilbee.providers.sdk_backend import PROVIDER_API_KEY_FIELD
89from lilbee.runtime.hardware import available_memory_for_fit, chip_for_size
91log = logging.getLogger(__name__)
93# Rows per browse page for the active task. Must exceed one viewport (about
94# five cards a row, four rows) or the grid paints part-empty under the
95# "keep scrolling" hint.
96_HF_PAGE_SIZE = 24
97# Rows per search page. Wider than a browse page: matches are spread across
98# the hub rather than sitting at one offset.
99_HF_SEARCH_LIMIT = 50
100_HF_LOAD_MORE_TRIGGER = 4
101_ALL_TASKS = tuple(ModelTask)
103# Which config model-role field a selected model is assigned to, keyed by its task.
104# Remote/frontier rows surface into their matching task tab, so selecting one must
105# persist to that role, not always to chat_model.
106_TASK_TO_MODEL_FIELD: dict[ModelTask, str] = {
107 ModelTask.CHAT: "chat_model",
108 ModelTask.EMBEDDING: "embedding_model",
109 ModelTask.VISION: "vision_model",
110 ModelTask.RERANK: "reranker_model",
111}
113# Warning shown when a frontier row whose key is not usable is selected.
114_KEY_PROBLEM_MESSAGE: dict[KeyStatus, str] = {
115 KeyStatus.MISSING_KEY: msg.CATALOG_NEEDS_KEY,
116 KeyStatus.INVALID_KEY: msg.CATALOG_KEY_REJECTED,
117}
120def _model_field_for_task(task: ModelTask | str) -> str:
121 return _TASK_TO_MODEL_FIELD.get(ModelTask(task), "chat_model")
124_WORKER_FETCH_HF = "fetch_hf_models"
125_WORKER_FETCH_MORE_HF = "fetch_more_hf"
126_WORKER_FETCH_REMOTE = "fetch_remote_models"
127_WORKER_FETCH_SEARCH = "fetch_hf_search"
128_WORKER_FETCH_FRONTIER = "fetch_frontier_models"
129_WORKER_FETCH_FAMILIES = "fetch_families"
131_GRID_PAGE_ROWS = 3
132_LIST_PAGE_ROWS = 10
134# Per-tab DOM ids: f"grid-{tab_id}" / f"list-{tab_id}". Memoized on the
135# screen so each access is one dict lookup, not a DOM walk.
136_GRID_ID_PREFIX = "grid-"
137_LIST_ID_PREFIX = "list-"
139# Toggles the filter Input between revealed and `display: none` (catalog.tcss).
140_HIDDEN_CLASS = "-hidden"
142# Refresh cycles the initial tab activation waits for the tab strip's children
143# to mount before giving up (they mount a frame after construction; a slow host
144# can take several).
145_TAB_ACTIVATION_RETRY_BUDGET = 20
147_SORT_CYCLE: tuple[str, ...] = ("Name", "Downloads", "Size", "Params")
149# Spinner cadence for the catalog pagination/search loading indicator: cycled on
150# a 100 ms timer while the catalog fetches more HF rows or a remote search is in
151# flight, so the wait always shows a moving signal instead of an empty pane. The
152# frames come from the shared, Rich-sourced ``SPINNER_FRAMES``.
153_SPINNER_INTERVAL_S = 0.1
155_RowCacheKey = tuple[int, int, int, int, int, int]
158@dataclass(frozen=True)
159class _RowCacheEntry:
160 """Memoized output of one ``_all_*_rows`` builder."""
162 key: _RowCacheKey
163 rows: list[LocalCatalogRow]
166class _GridCacheKey(NamedTuple):
167 """Per-tab identity of a painted grid; equal keys mean nothing to repaint."""
169 data_version: int
170 rows: tuple[tuple[str, bool], ...]
171 search: str
174class CatalogScreen(Screen[None]):
175 """Model catalog with grid (default) and list views."""
177 app: LilbeeApp # type: ignore[assignment]
179 CSS_PATH = "catalog.tcss"
180 AUTO_FOCUS = "" # GridSelect is mounted dynamically; focused in on_mount
182 HELP = (
183 "# Catalog\n"
184 "Six tabs: Discover (curated landing), Chat / Embed / Vision / Rerank,\n"
185 "and Library (your installed local + activated cloud APIs).\n\n"
186 "## Navigation\n"
187 "- Arrows / j k h l: move the card cursor.\n"
188 "- 1-6: jump to tab N (the numerals are shown on the tab strip).\n"
189 "- < / >: step to the previous / next tab.\n"
190 "- Tab / Shift+Tab: cycle focus.\n\n"
191 "## Actions\n"
192 "- Enter: install the highlighted model (or activate, if cloud).\n"
193 "- Space: toggle select.\n"
194 "- d / Backspace / x: delete an installed model (two presses to confirm).\n"
195 "- i: open the info modal for the highlighted card.\n"
196 "- Right Arrow: expand a family card to show its size variants.\n\n"
197 "## Filters and views\n"
198 "- /: filter the active tab (Esc clears).\n"
199 "- s: cycle sort (Name / Downloads / Size / Params).\n"
200 "- v: toggle Grid vs List view on a task tab.\n"
201 "- o: cycle source chip [local | cloud | both] on a task tab.\n"
202 "- n: load more HF rows (or just keep scrolling).\n\n"
203 "## Detail drawer\n"
204 "- Ctrl+B: toggle the right-pane detail drawer.\n"
205 " Shows fit chip, size variants with per-variant fit, license, description.\n\n"
206 "## Fit chip\n"
207 "- Green 'fits +N GB': model fits with at least 1 GB headroom.\n"
208 "- Amber 'tight +N GB': model fits but within the 0..1 GB band.\n"
209 '- Red "won\'t N GB": model overflows available memory by N GB.\n\n'
210 "## Other\n"
211 "- q / Esc: back."
212 )
213 _SCROLL_GROUP = Binding.Group("Scroll", compact=True)
214 _TAB_GROUP = Binding.Group("Tabs", compact=True)
216 BINDINGS: ClassVar[list[BindingType]] = [
217 Binding("q", "go_back", "Back", show=True),
218 Binding("escape", "go_back", "", show=False),
219 # Help-panel only: the footer keeps the keys that move between views plus the
220 # one this screen is for, which is search. Switching grid for list is a
221 # preference, not a way around the catalog.
222 Binding("v", "toggle_view", "Grid/List", show=False),
223 Binding("slash", "focus_search", "Search", show=True),
224 # `d` / `i` are bound but help-panel only, like everything here that is not
225 # search: the footer carries the keys that move between views plus the
226 # one verb this screen is for. Both stay discoverable via F2 (command
227 # palette) and `?` (help overlay).
228 Binding("d", "delete_model", "Delete", show=False),
229 Binding("backspace", "delete_model", "Delete", show=False),
230 Binding("x", "delete_model", "Delete", show=False),
231 Binding("i", "show_info", "Info", show=False),
232 Binding("j", "cursor_down", "Nav", show=False, group=_SCROLL_GROUP),
233 Binding("k", "cursor_up", "Nav", show=False, group=_SCROLL_GROUP),
234 # Arrows move the card cursor too (auto-scrolls into view) so
235 # the highlight follows the visible region. Decoupling them
236 # into pure viewport scroll left a stale highlight on the
237 # previously-focused card.
238 Binding("down", "cursor_down", "Down", show=False, group=_SCROLL_GROUP),
239 Binding("up", "cursor_up", "Up", show=False, group=_SCROLL_GROUP),
240 # priority=True so vim jump-to-top/bottom always wins over the
241 # focused ModelGrid's enter/select binding when keys collide.
242 Binding("g", "jump_top", "Top", show=False, group=_SCROLL_GROUP, priority=True),
243 Binding("G", "jump_bottom", "End", show=False, group=_SCROLL_GROUP, priority=True),
244 Binding("space", "page_down", "PgDn", show=False, group=_SCROLL_GROUP),
245 Binding("ctrl+d", "page_down", "PgDn", show=False, group=_SCROLL_GROUP),
246 Binding("ctrl+u", "page_up", "PgUp", show=False, group=_SCROLL_GROUP),
247 # Help-panel only; the sort-label surfaces "press n for more" and "press s to
248 # sort" on screen instead, which is better than a footer cell.
249 Binding("n", "load_more", "More", show=False),
250 Binding("s", "cycle_sort", "Sort", show=False),
251 Binding("ctrl+b", "toggle_drawer", "Detail", show=False),
252 # `o` for origin, not `c`: `c` is the app-wide jump to Chat. An app
253 # binding may only be shadowed by a screen key the footer explains,
254 # and this one is hidden, so it would have flipped the source filter
255 # with nothing on screen to say why.
256 Binding("o", "cycle_source", "Source", show=False),
257 # Numeric tab shortcuts; 1-6 jump to the corresponding tab in
258 # ALL_TAB_IDS order (Discover, Chat, Embed, Vision, Rerank, Library).
259 # priority=True so they win against any focused-widget binding that
260 # might already grab digits (Textual's Tabs/ContentTabs has its own
261 # numeric handling), and over-the-air shortcut feel matches the plan.
262 Binding("1", "select_tab(0)", "Discover", show=False, priority=True),
263 Binding("2", "select_tab(1)", "Chat", show=False, priority=True),
264 Binding("3", "select_tab(2)", "Embed", show=False, priority=True),
265 Binding("4", "select_tab(3)", "Vision", show=False, priority=True),
266 Binding("5", "select_tab(4)", "Rerank", show=False, priority=True),
267 Binding("6", "select_tab(5)", "Library", show=False, priority=True),
268 # Tab cycling, shown as a compact pair: stepping between Chat / Embed
269 # / Vision / Rerank is the catalog's most-used move and the numbered
270 # strip alone did not advertise it. ctrl+arrow conflicts with macOS
271 # desktop-space shortcuts, hence vim-style angle brackets.
272 # priority=True so the active ModelGrid's own focus cycling doesn't
273 # swallow them.
274 Binding(
275 "less_than_sign",
276 "cycle_tab(-1)",
277 "Prev tab",
278 show=True,
279 priority=True,
280 group=_TAB_GROUP,
281 ),
282 Binding(
283 "greater_than_sign",
284 "cycle_tab(1)",
285 "Next tab",
286 show=True,
287 priority=True,
288 group=_TAB_GROUP,
289 ),
290 ]
292 _search_input = getters.query_one("#catalog-search", Input)
294 def __init__(self, *, focus_task: str | None = None) -> None:
295 super().__init__()
296 self._focus_task: str | None = focus_task
297 # Empty until the worker lands: get_families() resolves the picks,
298 # which hits HuggingFace on the first call of a session. Building it
299 # here would block screen construction on the network.
300 self._families: list[ModelFamily] = []
301 self._families_in_flight = True
302 self._hf_models: list[CatalogModel] = []
303 self._remote_models: list[RemoteModel] = []
304 # Per-task pagination state. Each task tab tracks its own HF offset
305 # and has-more flag so paginating in one tab (e.g. Chat) only fetches
306 # that task's next page; sibling tabs stay untouched.
307 self._hf_offset_by_task: dict[ModelTask, int] = dict.fromkeys(_ALL_TASKS, 0)
308 self._hf_has_more_by_task: dict[ModelTask, bool] = dict.fromkeys(_ALL_TASKS, True)
309 self._hf_truncated_by_task: dict[ModelTask, bool] = dict.fromkeys(_ALL_TASKS, False)
310 self._hf_fetched_tasks: set[ModelTask] = set()
311 self._rows: list[LocalCatalogRow] = []
312 self._sort_column: str = "Name"
313 self._sort_ascending: bool = True
314 self._pending_delete: str | None = None
315 self._installed_names: set[str] = set()
316 self._grid_view: bool = True
317 self._loading_more: bool = False
318 # Per-tab grid/list cache keys. Each tab tracks its own last-rendered
319 # shape; switching between already-populated tabs is a no-op refresh.
320 self._grid_cache_keys: dict[str, _GridCacheKey] = {}
321 self._list_cache_keys: dict[str, tuple] = {}
322 self._search_in_flight: bool = False
323 # Remote-search pagination, keyed on the query the offset belongs to.
324 # Distinct from the per-task browse offsets, which page unfiltered rows.
325 self._searched_query: str = ""
326 self._search_offset: int = 0
327 self._search_has_more: bool = False
328 self._search_truncated: bool = False
329 self._frontier_rows: list[FrontierCatalogRow] = []
330 # Bumped on every worker callback so the _all_*_rows caches
331 # invalidate even when collection lengths happen to coincide.
332 self._data_version: int = 0
333 self._family_rows_cache: _RowCacheEntry | None = None
334 self._hf_rows_cache: _RowCacheEntry | None = None
335 self._remote_rows_cache: _RowCacheEntry | None = None
336 self._view_switching: bool = False
337 self._frontier_refresh_timer: Timer | None = None
338 self._search_filter_timer: Timer | None = None
339 self._remote_search_timer: Timer | None = None
340 self._scroll_prefetch_armed_at: float = 0.0
341 self._spinner_timer: Timer | None = None
342 self._spinner_frame: int = 0
343 # Active-tab cache + per-tab widget memoization. Avoids a second
344 # query_one on every _grid_container / _list_widget access. Default
345 # matches the tab _activate_initial_tab selects on mount.
346 self._active_tab_id_cache: str = TAB_CHAT
347 self._tab_grid_cache: dict[str, VerticalScroll] = {}
348 self._tab_list_cache: dict[str, ModelList] = {}
349 # During initial mount Textual fires TabActivated for whichever pane
350 # ends up first in compose order (Discover) before our explicit
351 # call_after_refresh setter activates Chat. Suppressing cache writes
352 # while this flag is False keeps the cache pinned to its TAB_CHAT
353 # __init__ default through the race; user-driven tab switches after
354 # mount flip the flag and re-arm normal cache updates.
355 self._activation_settled: bool = False
356 # Refresh cycles left to wait for the tab strip to mount before the
357 # initial activation gives up (see _activate_initial_tab).
358 self._activation_retries: int = _TAB_ACTIVATION_RETRY_BUDGET
359 # Per-tab source mode (local / cloud / both). Defaults to LOCAL on
360 # every task tab so the catalog opens on the same row set the
361 # mega-grid era surfaced; users opt into cloud-mixed views via `c`.
362 self._source_modes: dict[str, SourceMode] = {
363 tab_id: SourceMode.LOCAL for tab_id in TASK_TAB_IDS
364 }
365 # Hardware-fit baseline. Captured once at construction so the
366 # cached row-build path can stamp each row's fit chip without
367 # re-probing on every refresh.
368 self._available_memory_bytes: int | None = available_memory_for_fit()
370 def _grid_for_tab(self, tab_id: str) -> VerticalScroll:
371 """Return (and memoize) the scroll container hosting *tab_id*'s grids.
373 Discover's container is the DiscoverRails scroll itself, so cursor
374 actions and leave handlers always operate on the visible tab's grids
375 (never a hidden sibling pane's). Cached references are validated via
376 ``is_running`` so a stale post-remount handle gets refreshed
377 transparently.
378 """
379 cached = self._tab_grid_cache.get(tab_id)
380 if cached is not None and cached.is_running:
381 return cached
382 selector = "#discover-rails" if tab_id == TAB_DISCOVER else f"#{_GRID_ID_PREFIX}{tab_id}"
383 container = self.query_one(selector, VerticalScroll)
384 self._tab_grid_cache[tab_id] = container
385 return container
387 def _list_for_tab(self, tab_id: str) -> ModelList:
388 """Return (and memoize) the ModelList for *tab_id*.
390 Discover has no list view; falls through to TAB_CHAT so callers that
391 touch ``_list_widget`` while Discover is active never crash.
392 """
393 target = TAB_CHAT if tab_id == TAB_DISCOVER else tab_id
394 cached = self._tab_list_cache.get(target)
395 if cached is not None and cached.is_running:
396 return cached
397 widget = self.query_one(f"#{_LIST_ID_PREFIX}{target}", ModelList)
398 self._tab_list_cache[target] = widget
399 return widget
401 def _grid_mounted(self) -> bool:
402 """Whether the active tab's grid container exists to paint into."""
403 try:
404 self._grid_for_tab(self._active_tab_id_cache)
405 except NoMatches:
406 return False
407 return True
409 def _list_mounted(self) -> bool:
410 """Whether the active tab's list widget exists to paint into."""
411 try:
412 self._list_for_tab(self._active_tab_id_cache)
413 except NoMatches:
414 return False
415 return True
417 @property
418 def _grid_container(self) -> VerticalScroll:
419 return self._grid_for_tab(self._active_tab_id_cache)
421 @property
422 def _list_widget(self) -> ModelList:
423 return self._list_for_tab(self._active_tab_id_cache)
425 @property
426 def _search_focused(self) -> bool:
427 """True when the search Input widget owns focus.
429 Used to short-circuit digit / single-character action handlers so the
430 keystroke lands in the search field instead of activating a tab.
431 """
432 return isinstance(self.focused, Input)
434 @property
435 def _filter_open(self) -> bool:
436 """True while the filter Input is revealed, independent of focus."""
437 return not self._search_input.has_class(_HIDDEN_CLASS)
439 def compose(self) -> ComposeResult:
440 from lilbee.cli.tui.widgets.grid_list_toggle import GridListToggle
442 with TopBars():
443 yield ViewTabs()
444 yield Input(
445 placeholder=msg.CATALOG_FILTER_PLACEHOLDER,
446 id="catalog-search",
447 classes=_HIDDEN_CLASS,
448 )
449 # First-run guidance: shown while no chat model resolves, hidden the
450 # moment one does. The fit chips on the cards carry the hardware signal.
451 yield Static(msg.CATALOG_WELCOME, id="catalog-welcome")
452 with Horizontal(id="catalog-toolbar"):
453 yield GridListToggle()
454 yield Static("", id="sort-label", shrink=True)
455 yield Static("", id="catalog-loading-spinner")
456 # Horizontal split: TabbedContent fills, CatalogDetailDrawer docks
457 # right at fixed width and toggles via the -collapsed class. Each
458 # per-task tab has its own VerticalScroll + ModelList so prefetch
459 # only extends the active tab's grid; the single mega-grid was the
460 # source of cross-section viewport jumps on pagination.
461 with Horizontal(id="catalog-body"):
462 with (
463 Container(id="catalog-tabs-wrap"),
464 TabbedContent(id="catalog-tabs"),
465 ):
466 with TabPane(f"1 {msg.CATALOG_TAB_DISCOVER}", id=TAB_DISCOVER):
467 yield DiscoverRails(id="discover-rails")
468 with TabPane(f"2 {msg.CATALOG_TAB_CHAT}", id=TAB_CHAT):
469 yield VerticalScroll(
470 id=f"{_GRID_ID_PREFIX}{TAB_CHAT}", classes="catalog-grid-pane"
471 )
472 yield ModelList(id=f"{_LIST_ID_PREFIX}{TAB_CHAT}")
473 with TabPane(f"3 {msg.CATALOG_TAB_EMBED}", id=TAB_EMBED):
474 yield VerticalScroll(
475 id=f"{_GRID_ID_PREFIX}{TAB_EMBED}", classes="catalog-grid-pane"
476 )
477 yield ModelList(id=f"{_LIST_ID_PREFIX}{TAB_EMBED}")
478 with TabPane(f"4 {msg.CATALOG_TAB_VISION}", id=TAB_VISION):
479 yield VerticalScroll(
480 id=f"{_GRID_ID_PREFIX}{TAB_VISION}", classes="catalog-grid-pane"
481 )
482 yield ModelList(id=f"{_LIST_ID_PREFIX}{TAB_VISION}")
483 with TabPane(f"5 {msg.CATALOG_TAB_RERANK}", id=TAB_RERANK):
484 yield VerticalScroll(
485 id=f"{_GRID_ID_PREFIX}{TAB_RERANK}", classes="catalog-grid-pane"
486 )
487 yield ModelList(id=f"{_LIST_ID_PREFIX}{TAB_RERANK}")
488 with TabPane(f"6 {msg.CATALOG_TAB_LIBRARY}", id=TAB_LIBRARY):
489 yield VerticalScroll(
490 id=f"{_GRID_ID_PREFIX}{TAB_LIBRARY}", classes="catalog-grid-pane"
491 )
492 yield ModelList(id=f"{_LIST_ID_PREFIX}{TAB_LIBRARY}")
493 yield CatalogDetailDrawer(id="catalog-detail-drawer", classes="-collapsed")
494 with BottomBars():
495 yield TaskBar()
496 yield Footer()
498 def on_mount(self) -> None:
499 self.watch(self.app, "chat_is_ready", self._on_chat_ready_changed, init=True)
500 self._fetch_installed_names()
501 self._fetch_families()
502 # Force Chat as the initial active tab. Deliberately not
503 # `TabbedContent(initial=...)`: that arms `Tabs._on_mount` to assign the
504 # active tab unconditionally, and when the app tears down mid-mount
505 # Textual's `mount_all` no-ops, so the strip has no Tab children yet and
506 # the assignment raises `ValueError: No Tab with id`. Setting it here via
507 # call_after_refresh also lets the TabActivated cascade settle first.
508 # Chat is the most common landing destination; users opt into
509 # Discover via keyboard shortcut.
510 self.call_after_refresh(self._activate_initial_tab)
511 self.add_class("-grid-view")
513 def _activate_initial_tab(self) -> None:
514 try:
515 tabs = self.query_one("#catalog-tabs", TabbedContent)
516 except Exception:
517 self._activation_settled = True
518 return
519 target: str | None
520 if self._focus_task is not None:
521 # On-ramp: land directly on the requested task tab.
522 self._active_tab_id_cache = self._focus_task
523 target = self._focus_task
524 else:
525 target = TAB_CHAT if self._active_tab_id_cache == TAB_CHAT else None
526 if target is not None and tabs.active != target:
527 try:
528 tabs.active = target
529 except ValueError:
530 # The strip's Tab children mount a frame after construction; on
531 # a slow host this refresh callback can still beat them and the
532 # setter raises "No Tab with id". Wait out the next refresh.
533 if self._activation_retries > 0:
534 self._activation_retries -= 1
535 self.call_after_refresh(self._activate_initial_tab)
536 return
537 if not self._activation_settled:
538 self._activation_settled = True
539 self.call_after_refresh(self._refresh_grid)
540 self.call_after_refresh(self._initial_focus_first_grid)
541 self._fetch_remote_models()
542 self._fetch_frontier_models()
543 # Eagerly load the HF catalog for the initial chat tab. Sibling
544 # task tabs fetch lazily on first activation (see
545 # `_on_catalog_tab_activated`) so opening the catalog only costs
546 # one HF round-trip instead of four.
547 self._ensure_task_initial_fetch(ModelTask.CHAT)
548 self.app.provider_availability_changed_signal.subscribe(
549 self, self._on_provider_availability_changed
550 )
551 # Auto-load more HF rows when scrolled near the bottom in either view.
552 # Watch every per-task tab's container plus the Library container.
553 # Inactive tabs never scroll, so the handler runs only for the active
554 # tab; this is cheaper than tearing down and re-installing the watch
555 # on every tab activation.
556 for tab_id in (*TASK_TAB_IDS, TAB_LIBRARY):
557 with contextlib.suppress(Exception):
558 self.watch(
559 self._list_for_tab(tab_id), "scroll_y", self._on_list_scrolled, init=False
560 )
561 self.watch(
562 self._grid_for_tab(tab_id), "scroll_y", self._on_grid_scrolled, init=False
563 )
565 def on_unmount(self) -> None:
566 with contextlib.suppress(Exception):
567 self.app.provider_availability_changed_signal.unsubscribe(self)
568 self._stop_spinner_timer()
570 def on_screen_suspend(self) -> None:
571 """Pause the spinner timer while the screen is offscreen.
573 Without this the 100 ms braille tick keeps firing for the full
574 TUI session even when the catalog is not visible, costing ~4%
575 of main-thread CPU forever.
576 """
577 self._stop_spinner_timer()
579 def on_screen_resume(self) -> None:
580 """Re-arm the spinner only if a fetch is still in flight."""
581 if self._loading_more or self._search_in_flight:
582 self._sync_loading_spinner()
584 def _stop_spinner_timer(self) -> None:
585 if self._spinner_timer is not None:
586 self._spinner_timer.stop()
587 self._spinner_timer = None
589 _FRONTIER_REFRESH_DEBOUNCE = 1.0
591 def _on_provider_availability_changed(self, _payload: tuple[str, object]) -> None:
592 """Debounced refetch of frontier rows when an API key changes."""
593 if self._frontier_refresh_timer is not None:
594 self._frontier_refresh_timer.stop()
595 self._frontier_refresh_timer = self.set_timer(
596 self._FRONTIER_REFRESH_DEBOUNCE, self._fetch_frontier_models
597 )
599 def _focus_first_grid(self) -> None:
600 """Focus the first populated grid widget in the active tab's container."""
601 with contextlib.suppress(Exception):
602 grids = self._pane_grids_with_rows()
603 if grids:
604 self.set_focus(grids[0])
605 return
606 with contextlib.suppress(Exception):
607 self.set_focus(self._grid_container.query(GridSelect).first())
609 def _initial_focus_first_grid(self) -> None:
610 """on_mount initial focus: skip if a later refresh-tick has already
611 landed focus elsewhere (e.g. a test focused #catalog-search before
612 the streaming-section mount drained its scheduled callbacks)."""
613 if self.focused is not None:
614 return
615 self._focus_first_grid()
617 def _fetch_installed_names(self) -> None:
618 """Populate installed identities from the shared ModelManager cache.
620 The set contains both the canonical ref (``hf_repo/filename``) and
621 the bare ``hf_repo`` so catalog rows whose ref is the repo alone
622 still light up as installed when at least one quant of that repo
623 has a manifest.
625 An unreadable registry leaves the previous set in place and logs.
626 The screen must still open, so this is the one place the fault
627 stops rather than reaching the user as a crash.
628 """
629 try:
630 self._installed_names = set(get_services().model_manager.list_native_identities())
631 except Exception:
632 log.warning("Could not read the model registry; catalog install marks may be stale")
633 return
634 self._data_version += 1
636 def _active_tab_id(self) -> str:
637 """Return the cached active tab id; falls back to TAB_CHAT pre-mount.
639 The cache is updated by ``_on_catalog_tab_activated`` so this is a
640 bare attribute read, not a DOM walk. Prefer this over a fresh
641 ``TabbedContent.active`` lookup on every check.
642 """
643 return self._active_tab_id_cache
645 def _active_task(self) -> ModelTask | None:
646 """Return the active tab's task, or None on Discover / Library."""
647 return TAB_ID_TO_TASK.get(self._active_tab_id())
649 def _active_task_has_more(self) -> bool:
650 """True iff the active task tab has another HF page available.
652 Discover and Library tabs return False; neither paginates. Under an
653 active search this is the search's own flag, so the hint describes the
654 result set on screen.
655 """
656 return self._active_listing_flag(self._search_has_more, self._hf_has_more_by_task)
658 def _active_task_truncated(self) -> bool:
659 """True iff the active task tab's listing stopped at the scan bound with rows unread."""
660 return self._active_listing_flag(self._search_truncated, self._hf_truncated_by_task)
662 def _active_listing_flag(self, search_flag: bool, by_task: dict[ModelTask, bool]) -> bool:
663 """The search's flag under a search, else the active task's; False off a task tab."""
664 task = self._active_task()
665 if task is None:
666 return False
667 if self._get_search_text():
668 return search_flag
669 return by_task.get(task, False)
671 def _hf_fetched_any(self) -> bool:
672 """True iff any task has had its first HF page fetched.
674 Renders gate HF sections on this so the catalog doesn't paint
675 empty HF rows before the first per-task fetch lands.
676 """
677 return bool(self._hf_fetched_tasks)
679 def _ensure_task_initial_fetch(self, task: ModelTask) -> None:
680 """Fire the per-task initial HF fetch once; idempotent on repeats."""
681 if task in self._hf_fetched_tasks:
682 return
683 self._hf_fetched_tasks.add(task)
684 self._fetch_initial_hf_models_for_task(task)
686 def action_toggle_view(self) -> None:
687 """Toggle between grid and list view on the active task tab.
689 Mid-toggle re-entry would tear the DOM (one toggle's mount_all
690 running while the previous toggle's remove_children is still in
691 flight). The _view_switching gate makes the toggle atomic.
692 Discover and Library tabs don't expose the toggle.
693 """
694 if self._active_tab_id() not in TASK_TAB_IDS:
695 return
696 if self._view_switching:
697 return
698 self._view_switching = True
699 try:
700 if self._grid_view:
701 self._grid_view = False
702 self.remove_class("-grid-view")
703 self.add_class("-list-view")
704 active_task = TAB_ID_TO_TASK.get(self._active_tab_id())
705 if active_task is not None:
706 self._ensure_task_initial_fetch(active_task)
707 with self.app.batch_update():
708 self._refresh_list()
709 self._focus_list_item(0)
710 else:
711 self._grid_view = True
712 self.remove_class("-list-view")
713 self.add_class("-grid-view")
714 with self.app.batch_update():
715 self._refresh_grid()
716 with contextlib.suppress(Exception):
717 self._grid_container.query_one(ModelGrid).focus()
718 finally:
719 self._view_switching = False
720 self._sync_grid_list_toggle()
722 def _sync_grid_list_toggle(self) -> None:
723 from lilbee.cli.tui.widgets.grid_list_toggle import GridListToggle
725 with contextlib.suppress(Exception):
726 self.query_one(GridListToggle).set_grid(self._grid_view)
728 def action_focus_search(self) -> None:
729 """Reveal and focus the filter input. Bound to / key.
731 ``set_focus`` rather than ``Input.focus()``: Widget.focus() only queues
732 the move via ``call_later``, so two pending focus callbacks resolve in
733 queue order and whichever lands last wins. Setting it on the screen
734 lands now, which keeps `/` from losing to a focus queued beside it.
735 """
736 self._search_input.remove_class(_HIDDEN_CLASS)
737 self.set_focus(self._search_input)
739 _SEARCH_FILTER_DEBOUNCE_SECONDS = 0.08
740 # The remote leg is a round trip, not a repaint, so it waits for a real
741 # pause in typing.
742 _REMOTE_SEARCH_DEBOUNCE_SECONDS = 0.45
744 @on(Input.Changed, "#catalog-search")
745 def _on_search_changed(self, event: Input.Changed) -> None:
746 """Schedule a filter pass, and a hub search behind a longer debounce.
748 Each keystroke triggers a grid re-render or a list redraw, both of
749 which Textual treats as layout invalidations. Without the debounce
750 a 5-char term produces 5 full passes; with it, typing collapses
751 to a single pass once the user pauses.
753 The filter only narrows models already fetched, so a term the catalog
754 has not paged to would otherwise read as "no such model".
755 """
756 if self._search_filter_timer is not None:
757 self._search_filter_timer.stop()
758 self._search_filter_timer = self.set_timer(
759 self._SEARCH_FILTER_DEBOUNCE_SECONDS,
760 self._apply_search_filter,
761 )
762 if self._remote_search_timer is not None:
763 self._remote_search_timer.stop()
764 # The Input already holds the new value when this fires.
765 if not self._get_search_text():
766 # Cleared: the next search starts its own result set.
767 self._searched_query = ""
768 self._search_offset = 0
769 self._search_has_more = False
770 self._search_truncated = False
771 return
772 self._remote_search_timer = self.set_timer(
773 self._REMOTE_SEARCH_DEBOUNCE_SECONDS,
774 lambda: self._trigger_remote_search(self._get_search_text()),
775 )
777 def _apply_search_filter(self) -> None:
778 if self._active_tab_id() == TAB_LIBRARY:
779 self._populate_library_list()
780 return
781 if self._active_tab_id() == TAB_DISCOVER:
782 return
783 if self._grid_view:
784 self._filter_grid()
785 else:
786 self._filter_list()
788 @on(Input.Submitted, "#catalog-search")
789 def _on_search_submitted(self, event: Input.Submitted) -> None:
790 """Enter dismisses the search box and puts the cursor on the results.
792 Typing already runs the filter and the hub search, so there is no query
793 left to submit. It must not install: every row is a multi-gigabyte
794 download and the top row is whichever one sorted first. Installing is a
795 deliberate Enter on the focused card.
796 """
797 self._focus_first_result()
799 def _focus_first_result(self) -> None:
800 """Move the cursor to the first match. Focus only, never selection."""
801 with contextlib.suppress(Exception):
802 if not self._grid_view:
803 self._focus_first_list_row()
804 return
805 self._focus_first_grid_card()
807 def _focus_first_list_row(self) -> None:
808 """Put the list cursor on the first row, if there is one."""
809 if not self._list_widget.option_count:
810 return
811 self._list_widget.highlighted = 0
812 self._list_widget.focus()
814 def _focus_first_grid_card(self) -> None:
815 """Put the grid cursor on the first card of the first populated grid."""
816 for grid in self._grid_container.query(ModelGrid):
817 if grid.rows:
818 grid.focus()
819 grid.highlighted = 0
820 return
822 def _trigger_remote_search(self, query: str) -> None:
823 """Fire the HF search worker for the active task, unless one is in flight.
825 Search is task-scoped so typing on the Chat tab only surfaces chat
826 models; embedding/vision/rerank rows can never leak into the active
827 list. Non-task tabs (Discover/Library) can't reach this path because
828 the search Input is hidden on them.
829 """
830 if self._search_in_flight or not query:
831 return
832 active_task = TAB_ID_TO_TASK.get(self._active_tab_id())
833 if active_task is None:
834 return
835 # A new term starts its own result set; only _load_more advances the
836 # offset, and only for the query it was fetched under.
837 if query != self._searched_query:
838 self._searched_query = query
839 self._search_offset = 0
840 self._search_has_more = False
841 self._search_truncated = False
842 self._search_in_flight = True
843 self._update_sort_label()
844 # The toolbar spinner carries this in both views. No toast: typing
845 # fires this, so one per pause would stack up over a single term.
846 self._sync_loading_spinner()
847 self._fetch_hf_search(query, active_task, self._search_offset)
849 def _resume_search_if_term_moved_on(self) -> None:
850 """Re-run the hub search when the box moved on during the last one.
852 ``_trigger_remote_search`` drops a request that arrives mid-flight, so
853 the term typed during a round trip would otherwise never reach the hub.
854 """
855 query = self._get_search_text()
856 if query and query != self._searched_query:
857 self._trigger_remote_search(query)
859 @on(Click, ".search-hf-cta")
860 def _on_search_hf_cta_clicked(self) -> None:
861 self._trigger_remote_search(self._get_search_text())
863 def _fetch_hf_page_for_task(self, task: ModelTask) -> list[CatalogModel]:
864 """Fetch one HF page for *task* at the task's own offset.
866 Dedupes against repos already in ``self._hf_models`` so re-fetches
867 from a stale offset don't double-count rows. Writes the per-task
868 ``has_more`` directly on the screen from the worker thread; the
869 dict assignment is GIL-atomic and the main thread only reads.
870 """
871 offset = self._hf_offset_by_task[task]
872 result = get_catalog(
873 task=task,
874 featured=False,
875 limit=_HF_PAGE_SIZE,
876 offset=offset,
877 )
878 self._hf_has_more_by_task[task] = result.has_more
879 self._hf_truncated_by_task[task] = result.truncated
880 existing_repos = {m.hf_repo for m in self._hf_models}
881 return [m for m in result.models if not m.featured and m.hf_repo not in existing_repos]
883 @work(thread=True, name=_WORKER_FETCH_HF)
884 def _fetch_initial_hf_models_for_task(self, task: ModelTask) -> list[CatalogModel]:
885 """Fetch the first HF page for *task* (extends the merged store)."""
886 return self._fetch_hf_page_for_task(task)
888 @work(thread=True, name=_WORKER_FETCH_REMOTE)
889 def _fetch_remote_models(self) -> list[RemoteModel]:
890 return classify_all_remote_models()
892 @work(thread=True, name=_WORKER_FETCH_FAMILIES)
893 def _fetch_families(self) -> list[ModelFamily]:
894 """Group the picks into families off the UI thread.
896 ``get_families`` resolves the picks, which is an HTTP round trip on the
897 first call of a session. Doing it during screen construction froze the
898 catalog on open for as long as HuggingFace took to answer.
899 """
900 try:
901 return get_families()
902 except Exception:
903 log.debug("get_families failed in worker", exc_info=True)
904 return []
906 @work(thread=True, name=_WORKER_FETCH_FRONTIER, exit_on_error=False)
907 def _fetch_frontier_models(self) -> list[FrontierCatalogRow]:
908 """Discover cloud chat models off the UI thread.
910 ``discover_api_model_groups`` imports litellm (heavy, >50ms) and
911 checks every configured provider key over the network. Running it on
912 the main thread froze the catalog on mount and on every
913 signal-driven refresh; the worker keeps the screen responsive."""
914 from lilbee.modelhub.model_manager import discover_api_model_groups
916 try:
917 groups = discover_api_model_groups()
918 except Exception:
919 log.debug("discover_api_model_groups failed in worker", exc_info=True)
920 return []
922 rows = [
923 frontier_row_from_remote(rm, provider_id=group.provider, key_status=group.key_status)
924 for group in groups
925 for rm in group.models
926 ]
927 rows.sort(key=lambda r: (r.provider, r.name.lower()))
928 return rows
930 @work(thread=True, name=_WORKER_FETCH_MORE_HF)
931 def _fetch_more_hf_for_task(self, task: ModelTask) -> list[CatalogModel]:
932 """Fetch the next HF page for *task* (extends the merged store)."""
933 return self._fetch_hf_page_for_task(task)
935 @work(thread=True, name=_WORKER_FETCH_SEARCH, exit_on_error=False)
936 def _fetch_hf_search(self, query: str, task: ModelTask, offset: int) -> list[CatalogModel]:
937 """Fetch one page of HF models matching *query* for *task* (worker thread).
939 Writes ``_search_has_more`` from the worker thread the same way
940 ``_fetch_hf_page_for_task`` writes the per-task flag: the assignment is
941 GIL-atomic and the main thread only reads it.
942 """
943 existing_repos = {m.hf_repo for m in self._hf_models}
944 result = get_catalog(
945 task=task,
946 featured=False,
947 search=query,
948 limit=_HF_SEARCH_LIMIT,
949 offset=offset,
950 )
951 self._search_has_more = result.has_more
952 self._search_truncated = result.truncated
953 return [m for m in result.models if not m.featured and m.hf_repo not in existing_repos]
955 def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
956 # PENDING/RUNNING fire here too; only ERROR/CANCELLED should release latches.
957 if event.state in (WorkerState.ERROR, WorkerState.CANCELLED):
958 self._handle_worker_error_or_cancel(event.worker.name)
959 return
960 if event.state != WorkerState.SUCCESS:
961 return
962 result = event.worker.result
963 if not isinstance(result, list):
964 return
965 worker_name = event.worker.name
966 if not self._apply_worker_result(worker_name, result):
967 return
968 # A fast worker can complete before TabbedContent finishes mounting
969 # its panes; tolerate that and let the deferred _refresh_grid that
970 # _activate_initial_tab schedules rebuild against the applied state.
971 with contextlib.suppress(NoMatches):
972 # FETCH_MORE_HF appends to the active view's tail; skip the full
973 # _refresh_view rebuild so scroll position and focus are preserved.
974 if worker_name == _WORKER_FETCH_MORE_HF:
975 if self._grid_view:
976 self._refresh_grid()
977 else:
978 self._append_more_hf_to_list(result)
979 return
980 self._refresh_view()
982 def _append_more_hf_to_list(self, new_models: list[CatalogModel]) -> None:
983 """Append newly-arrived HF rows to the active task tab's list.
985 Falls back to a full ``_refresh_view`` on the rare tab-switch
986 race where the worker's payload no longer matches the active
987 task; otherwise a blind extend would leak foreign rows into a
988 sibling tab's list.
989 """
990 active_task = self._active_task()
991 if active_task is None or any(m.task != active_task for m in new_models):
992 self._refresh_view()
993 return
994 new_rows = self._sort_rows(
995 [
996 catalog_to_row(m, installed=self._is_installed(m.ref, m.hf_repo, m.gguf_filename))
997 for m in new_models
998 ]
999 )
1000 if not new_rows:
1001 self._update_sort_label()
1002 return
1003 self._rows.extend(new_rows)
1004 self._list_widget.append_rows(list(new_rows))
1005 # Update the per-tab cache key (not a stray singular attribute) so a
1006 # subsequent _refresh_list for this tab sees the appended rows as cached.
1007 self._list_cache_keys[self._active_tab_id_cache] = (
1008 self._data_version,
1009 tuple((r.name, r.installed) for r in self._rows),
1010 self._get_search_text(),
1011 )
1012 self._update_sort_label()
1014 def _handle_worker_error_or_cancel(self, name: str) -> None:
1015 if name == _WORKER_FETCH_MORE_HF:
1016 self._loading_more = False
1017 if name == _WORKER_FETCH_SEARCH:
1018 self._search_in_flight = False
1019 self._update_sort_label()
1020 self._resume_search_if_term_moved_on()
1021 if name == _WORKER_FETCH_FAMILIES:
1022 self._families_in_flight = False
1023 self._sync_loading_spinner()
1025 def _apply_worker_result(self, name: str, result: list) -> bool:
1026 """Land worker results into the screen's caches.
1028 Returns True when the screen should refresh its view, False when
1029 the worker name is unrecognized (defensive: a future @work
1030 decorator name won't silently rebuild the grid)."""
1031 if name == _WORKER_FETCH_HF:
1032 # Per-task initial fetches all share this worker name; each
1033 # one carries dedup-filtered new rows (see
1034 # ``_fetch_hf_page_for_task``) so extend is correct here.
1035 self._hf_models.extend(result)
1036 self._loading_more = False
1037 elif name == _WORKER_FETCH_MORE_HF:
1038 self._hf_models.extend(result)
1039 self._loading_more = False
1040 elif name == _WORKER_FETCH_SEARCH:
1041 self._hf_models.extend(result)
1042 self._search_in_flight = False
1043 self._update_sort_label()
1044 self._resume_search_if_term_moved_on()
1045 elif name == _WORKER_FETCH_REMOTE:
1046 self._remote_models = result
1047 elif name == _WORKER_FETCH_FRONTIER:
1048 self._frontier_rows = result
1049 self._populate_library_list()
1050 elif name == _WORKER_FETCH_FAMILIES:
1051 self._families = result
1052 self._families_in_flight = False
1053 else:
1054 return False
1055 self._data_version += 1
1056 self._sync_loading_spinner()
1057 # If the user is parked on Discover, re-populate the rails so the
1058 # Fresh-on-the-Hub strip fills as HF rows arrive. Without this the
1059 # rail stays empty for the lifetime of the Discover view because
1060 # _populate_discover_rails fires only on tab activation.
1061 if self._active_tab_id_cache == TAB_DISCOVER:
1062 self._populate_discover_rails()
1063 return True
1065 def _populate_library_list(self) -> None:
1066 """Render the Library tab: installed local + activated cloud APIs in both views."""
1067 search = self._get_search_text()
1068 installed_rows: list[LocalCatalogRow] = []
1069 for source in (self._all_family_rows, self._all_hf_rows, self._all_remote_rows):
1070 installed_rows.extend(r for r in source() if r.installed)
1071 if search:
1072 installed_rows = [r for r in installed_rows if matches_search(r, search)]
1073 frontier = self._build_frontier_rows(search)
1074 self._render_library_list(installed_rows, frontier)
1075 self._render_library_grid(installed_rows, frontier)
1077 def _render_library_list(
1078 self,
1079 installed_rows: list[LocalCatalogRow],
1080 frontier: list[FrontierCatalogRow],
1081 ) -> None:
1082 try:
1083 ml = self._list_for_tab(TAB_LIBRARY)
1084 except Exception:
1085 return
1086 sections: list[ModelListSection] = []
1087 if installed_rows:
1088 sections.append(
1089 ModelListSection(heading=msg.HEADING_INSTALLED, rows=list(installed_rows))
1090 )
1091 sections.extend(group_frontier_rows(frontier))
1092 ml.set_rows(sections)
1094 def _render_library_grid(
1095 self,
1096 installed_rows: list[LocalCatalogRow],
1097 frontier: list[FrontierCatalogRow],
1098 ) -> None:
1099 try:
1100 container = self._grid_for_tab(TAB_LIBRARY)
1101 except Exception:
1102 return
1103 sections: list[GridSection] = []
1104 if installed_rows:
1105 sections.append(GridSection(heading=msg.HEADING_INSTALLED, rows=list(installed_rows)))
1106 if frontier:
1107 sections.append(GridSection(heading="Cloud", rows=list(frontier)))
1108 existing_grids = list(container.query(ModelGrid))
1109 existing_headings = [
1110 w for w in container.query(".section-heading") if isinstance(w, Static)
1111 ]
1112 if existing_grids and len(existing_grids) == len(sections):
1113 for grid, heading, section in zip(
1114 existing_grids, existing_headings, sections, strict=False
1115 ):
1116 heading.update(section.heading)
1117 grid.set_rows(section.rows)
1118 return
1119 container.remove_children()
1120 for section in sections:
1121 container.mount_all(
1122 [
1123 Static(section.heading, classes="section-heading"),
1124 ModelGrid(section.rows, name=section.heading, classes="catalog-section"),
1125 ]
1126 )
1128 def _get_search_text(self) -> str:
1129 # Deferred refresh callbacks can land while the screen is between
1130 # mount cycles (e.g. switch_view chaining); the descriptor query
1131 # would otherwise raise NoMatches and crash the callback.
1132 try:
1133 return self._search_input.value.strip()
1134 except Exception:
1135 return ""
1137 def _local_rows_data_key(self) -> _RowCacheKey:
1138 """Cache key over the inputs that drive row construction.
1140 ``_data_version`` covers replacements and extensions both;
1141 search text deliberately omitted (we filter cached rows).
1142 """
1143 return (
1144 len(self._families),
1145 len(self._hf_models),
1146 len(self._remote_models),
1147 len(self._hf_fetched_tasks),
1148 len(self._installed_names),
1149 self._data_version,
1150 )
1152 def _all_family_rows(self) -> list[LocalCatalogRow]:
1153 """One row per featured family, aggregating its quants into size_variants.
1155 The mega-grid era emitted one row per ``ModelVariant``; the same
1156 family showed up three or four times stacked next to each other,
1157 once per quant. The redesign collapses each family into a single
1158 card whose ``size_variants`` strip carries every quant. Primary
1159 variant (recommended; otherwise the smallest) drives the card's
1160 primary metadata + fit chip; the strip lets users pick a
1161 non-primary size without leaving the grid.
1162 """
1163 key = self._local_rows_data_key()
1164 cached = self._family_rows_cache
1165 if cached is not None and cached.key == key:
1166 return cached.rows
1167 rows: list[LocalCatalogRow] = []
1168 for fam in self._families:
1169 if not fam.variants:
1170 continue
1171 primary = min(fam.variants, key=lambda v: v.size_mb)
1172 family_installed = any(
1173 self._is_installed(v.hf_repo, repo=v.hf_repo, filename=v.filename)
1174 for v in fam.variants
1175 )
1176 row = variant_to_row(primary, fam, family_installed)
1177 row.size_variants = family_to_size_variants(fam)
1178 rows.append(row)
1179 self._stamp_fit(rows)
1180 self._family_rows_cache = _RowCacheEntry(key=key, rows=rows)
1181 return rows
1183 def _all_hf_rows(self) -> list[LocalCatalogRow]:
1184 key = self._local_rows_data_key()
1185 cached = self._hf_rows_cache
1186 if cached is not None and cached.key == key:
1187 return cached.rows
1188 rows: list[LocalCatalogRow] = []
1189 for m in self._hf_models:
1190 installed = self._is_installed(m.ref, repo=m.hf_repo, filename=m.gguf_filename)
1191 rows.append(catalog_to_row(m, installed))
1192 self._stamp_fit(rows)
1193 self._hf_rows_cache = _RowCacheEntry(key=key, rows=rows)
1194 return rows
1196 def _all_remote_rows(self) -> list[LocalCatalogRow]:
1197 key = self._local_rows_data_key()
1198 cached = self._remote_rows_cache
1199 if cached is not None and cached.key == key:
1200 return cached.rows
1201 rows = [remote_to_row(rm) for rm in self._remote_models]
1202 # Remote rows don't carry a known size; _stamp_fit no-ops on those.
1203 self._stamp_fit(rows)
1204 self._remote_rows_cache = _RowCacheEntry(key=key, rows=rows)
1205 return rows
1207 def _stamp_fit(self, rows: list[LocalCatalogRow]) -> None:
1208 """Stamp each row's hardware-fit chip in place.
1210 Runs only inside the cached row builders, so this is one pass per
1211 data refresh, not per render. Rows whose ``sort_size`` is zero
1212 (remote / unknown size) leave ``fit`` as ``None`` and the card
1213 renderer omits the chip. Available-memory probe is captured once
1214 at __init__; if the probe failed, every row falls through chip-less.
1215 """
1216 for row in rows:
1217 row.fit = chip_for_size(row.sort_size, self._available_memory_bytes)
1219 def _build_rows(self) -> list[LocalCatalogRow]:
1220 """Build filtered table rows from current data sources."""
1221 search = self._get_search_text()
1222 rows: list[LocalCatalogRow] = []
1223 rows.extend(self._build_family_rows(search))
1224 rows.extend(self._build_hf_rows(search))
1225 rows.extend(self._build_remote_rows(search))
1226 return rows
1228 def _build_family_rows(self, search: str) -> list[LocalCatalogRow]:
1229 """Filter the cached family rows against the active search."""
1230 if not search:
1231 return self._all_family_rows()
1232 return [r for r in self._all_family_rows() if matches_search(r, search)]
1234 def _build_hf_rows(self, search: str) -> list[LocalCatalogRow]:
1235 """Filter the cached HF rows against the active search."""
1236 if not search:
1237 return self._all_hf_rows()
1238 return [r for r in self._all_hf_rows() if matches_search(r, search)]
1240 def _build_remote_rows(self, search: str) -> list[LocalCatalogRow]:
1241 """Filter the cached remote rows against the active search."""
1242 if not search:
1243 return self._all_remote_rows()
1244 return [r for r in self._all_remote_rows() if matches_search(r, search)]
1246 def _build_frontier_rows(self, search: str) -> list[FrontierCatalogRow]:
1247 """Filter the cached frontier rows against the active search.
1249 The discovery itself runs in :meth:`_fetch_frontier_models` (a
1250 worker) because litellm import + key probing blocks the UI
1251 thread. Renderers call this synchronously to read the
1252 already-discovered rows, so no I/O happens here.
1253 """
1254 if not self._frontier_rows:
1255 return []
1256 return [row for row in self._frontier_rows if matches_search(row, search)]
1258 def _is_installed(self, name: str, repo: str = "", filename: str = "") -> bool:
1259 """Check if a model is installed by name or source repo/filename."""
1260 if name in self._installed_names:
1261 return True
1262 if repo and filename:
1263 return f"{repo}/{filename}" in self._installed_names
1264 return False
1266 def _sort_rows(self, rows: list[LocalCatalogRow]) -> list[LocalCatalogRow]:
1267 """Sort rows: featured first, then by current sort column."""
1268 key_fn = SORT_KEYS.get(self._sort_column, SORT_KEYS["Name"])
1269 # Stable sort: featured always first, then by column
1270 return sorted(
1271 rows,
1272 key=lambda r: (not r.featured, key_fn(r)),
1273 reverse=not self._sort_ascending,
1274 )
1276 def _refresh_view(self) -> None:
1277 """Refresh the active view (grid or list).
1279 Discover renders rails, not sections; worker landings while it is
1280 active repopulate the rails instead of painting a hidden task pane.
1282 Mount/remove of dozens of widgets is wrapped in batch_update so
1283 Textual coalesces layout passes; without it, the worker callback
1284 path can land inside an in-flight grid-list toggle and tear the
1285 DOM."""
1286 if self._active_tab_id_cache == TAB_DISCOVER:
1287 self._populate_discover_rails()
1288 return
1289 with self.app.batch_update():
1290 if self._grid_view:
1291 self._refresh_grid()
1292 else:
1293 self._refresh_list()
1295 def _refresh_grid(self) -> None:
1296 """Rebuild grid view; extend in-place when sections already mounted.
1298 Initial paint mounts everything (first time a tab is opened).
1299 Subsequent dataset updates (HF pagination, sort change, filter)
1300 update each existing ModelGrid via set_rows rather than tearing
1301 the container down and re-mounting from scratch. Avoids a 100%
1302 CPU spike on every "Browse more" return.
1304 A scheduled refresh can fire while the screen is composing or being
1305 dismissed, when the tab containers aren't mounted; there is nothing
1306 to paint then, and the guard runs before the row-cache update so the
1307 next mounted refresh still repaints.
1308 """
1309 if self._active_tab_id_cache == TAB_DISCOVER:
1310 # Discover paints rails via _populate_discover_rails, never sections.
1311 return
1312 if not self._grid_mounted():
1313 return
1314 prep = self._prepare_grid_refresh()
1315 if prep is None:
1316 self._update_sort_label()
1317 return
1318 sections, hf_count, filter_changed = prep
1319 if filter_changed:
1320 # The offset belongs to the previous result set. Keeping it parks
1321 # the viewport past the end of a shorter one (Textual clamps to the
1322 # new max), so the matches render above the visible area.
1323 self._grid_container.scroll_to(y=0, animate=False)
1324 if not sections:
1325 self._grid_container.remove_children()
1326 self._mount_grid_ctas(hf_count=hf_count)
1327 self._update_sort_label()
1328 return
1329 if self._extend_grid_sections_in_place(sections, hf_count):
1330 return
1331 self._remount_grid_sections(sections, hf_count)
1332 self._update_sort_label()
1334 def _prepare_grid_refresh(self) -> tuple[list[GridSection], int, bool] | None:
1335 """Build sections + cache them. Returns None when the cache is hot.
1337 Third element is True when the search text changed since the last
1338 paint, which the caller uses to reset the scroll offset.
1340 On the None branch the caller refreshes the sort label so the
1341 cached path still picks up sort-toggle clicks.
1342 """
1343 search = self._get_search_text()
1344 family_rows = self._build_family_rows(search)
1345 remote_rows = self._build_remote_rows(search)
1346 hf_rows = self._build_hf_rows(search) if self._hf_fetched_any() else []
1347 all_rows = family_rows + remote_rows + hf_rows
1348 active_tab = self._active_tab_id_cache
1349 tab_rows = self._rows_for_active_tab(all_rows, active_tab)
1350 # Keep self._rows in sync (locals-only) so the toolbar sort-label
1351 # can render "{n} loaded" whichever view (grid or list) is active.
1352 # Frontier rows render in their own Cloud section but don't count
1353 # toward the local-row tally.
1354 local_tab_rows: list[LocalCatalogRow] = [
1355 r for r in tab_rows if r.kind == CatalogRowKind.LOCAL
1356 ]
1357 self._rows = local_tab_rows
1358 # _data_version is part of the key: row signatures cover only
1359 # (name, installed), so a worker landing that changes rendered state
1360 # the signature misses (frontier key_status, fit, compat) must still
1361 # repaint rather than read as cache-hot.
1362 row_key = _GridCacheKey(
1363 self._data_version,
1364 tuple(row_cache_signature(r) for r in tab_rows),
1365 search,
1366 )
1367 # Per-tab cache key: switching back to an already-rendered tab
1368 # is a no-op refresh; only sort-label refreshes. Keyed by
1369 # active_tab so other tabs' caches survive in-place.
1370 previous_key = self._grid_cache_keys.get(active_tab)
1371 if previous_key == row_key:
1372 return None
1373 self._grid_cache_keys[active_tab] = row_key
1374 filter_changed = previous_key is not None and previous_key.search != search
1375 if active_tab in TASK_TAB_IDS:
1376 active_task = TAB_ID_TO_TASK[active_tab]
1377 task_label = active_task.value.capitalize()
1378 # Split locals and frontier so the picks/installed grouping
1379 # only sees LocalCatalogRow (it reads .featured / .installed
1380 # which FrontierCatalogRow doesn't carry). Frontier rows land
1381 # under their own "Cloud" section appended below.
1382 frontier_only = [r for r in tab_rows if r.kind == CatalogRowKind.FRONTIER]
1383 sections = [s for s in group_task_rows_with_picks(local_tab_rows, task_label) if s.rows]
1384 if frontier_only:
1385 sections.append(GridSection(heading="Cloud", rows=list(frontier_only)))
1386 hf_count = sum(1 for r in hf_rows if r.task == active_task.value)
1387 else:
1388 sections = [s for s in group_rows_for_grid(local_tab_rows) if s.rows]
1389 hf_count = len(hf_rows)
1390 if search:
1391 # A filtered catalog is one result set, not a taxonomy: matches
1392 # render flat so the viewport holds cards instead of headings.
1393 sections = flatten_sections(sections, msg.HEADING_MATCHES)
1394 return sections, hf_count, filter_changed
1396 def _extend_grid_sections_in_place(self, sections: list[GridSection], hf_count: int) -> bool:
1397 """Update existing ModelGrids in place when section count matches.
1399 Returns True iff the in-place path applied; the caller falls
1400 through to a teardown + remount on False.
1401 """
1402 existing_grids = list(self._grid_container.query(ModelGrid))
1403 existing_headings = [
1404 w for w in self._grid_container.query(".section-heading") if isinstance(w, Static)
1405 ]
1406 if not existing_grids or len(existing_grids) != len(sections):
1407 return False
1408 # Heading + grid mounts each compose on their own frame, so a
1409 # partially-mounted state can land here with the heading list
1410 # one short of the grid list. Drop strict=True so we cleanly
1411 # update whatever pairs we have without forcing a full remount.
1412 for grid, heading, section in zip(
1413 existing_grids, existing_headings, sections, strict=False
1414 ):
1415 heading.update(section.heading)
1416 grid.set_rows(section.rows)
1417 self._refresh_grid_ctas(hf_count=hf_count)
1418 self._update_sort_label()
1419 return True
1421 def _remount_grid_sections(self, sections: list[GridSection], hf_count: int) -> None:
1422 """Teardown + remount the grid for a section-count change.
1424 Captures the user's current cursor + scroll position before the
1425 teardown so both can be restored after remount; otherwise the
1426 ``_focus_first_grid`` fallback snaps the cursor back to the top
1427 of the catalog mid-keypress, and the layout shift from extra
1428 sections drifts the visible window away from where the user was
1429 looking.
1430 """
1431 focus_anchor = self._capture_focused_section()
1432 container = self._grid_container
1433 prior_scroll_y = container.scroll_y
1434 container.remove_children()
1435 self._mount_grid_section(sections[0], container)
1436 # Pass the container itself; the deferred callback must not re-query
1437 # by id because the pane can be unmounted before it runs.
1438 self.call_after_refresh(
1439 self._mount_remaining_grid_sections,
1440 container,
1441 sections[1:],
1442 hf_count=hf_count,
1443 focus_anchor=focus_anchor,
1444 prior_scroll_y=prior_scroll_y,
1445 )
1447 def _capture_focused_section(self) -> tuple[str, int | None] | None:
1448 """Return ``(heading, highlighted_index)`` for the focused grid.
1450 Heading is read from ``ModelGrid.name`` (set by
1451 ``_mount_grid_section``). Used to restore the cursor across a
1452 teardown+remount in ``_refresh_grid`` so paginated loads don't
1453 yank the user back to the top of the catalog.
1454 """
1455 focused = self._focused_grid()
1456 if not isinstance(focused, ModelGrid) or focused.name is None:
1457 return None
1458 return (focused.name, focused.highlighted)
1460 def _restore_focused_section(self, anchor: tuple[str, int | None] | None) -> bool:
1461 """Refocus the grid whose ``name`` matches the captured anchor.
1463 Returns True when the previous focus position was successfully
1464 restored; False when no anchor was given or the matching section
1465 no longer exists (caller falls back to ``_focus_first_grid``).
1466 """
1467 if anchor is None:
1468 return False
1469 target_heading, target_highlighted = anchor
1470 for grid in self._grid_container.query(ModelGrid):
1471 if grid.name != target_heading:
1472 continue
1473 grid.focus()
1474 if target_highlighted is not None and grid.rows:
1475 grid.highlighted = min(target_highlighted, len(grid.rows) - 1)
1476 return True
1477 return False
1479 def _mount_grid_section(self, section: GridSection, container: VerticalScroll) -> AwaitMount:
1480 # ``name=section.heading`` doubles as the section identity used by
1481 # ``_capture_focused_section`` / ``_restore_focused_section`` to
1482 # preserve the cursor across teardown + remount.
1483 #
1484 # Returns mount_all's awaitable so a caller that needs the widgets to
1485 # exist can wait for them. Pilot.pause only waits on the widgets that
1486 # were present when it was called, so it never covers these.
1487 grid = ModelGrid(section.rows, name=section.heading, classes="catalog-section")
1488 return container.mount_all(
1489 [
1490 Static(section.heading, classes="section-heading"),
1491 grid,
1492 ]
1493 )
1495 def _mount_remaining_grid_sections(
1496 self,
1497 container: VerticalScroll,
1498 remaining: list[GridSection],
1499 hf_count: int,
1500 focus_anchor: tuple[str, int | None] | None = None,
1501 prior_scroll_y: float = 0.0,
1502 ) -> list[AwaitMount]:
1503 # Runs one refresh after _remount_grid_sections; the pane can be
1504 # unmounted by then (screen teardown, tab remount). Returns one
1505 # awaitable per mounted section (empty when the pane is gone) so a
1506 # caller can wait for the sections to exist; the production caller
1507 # runs from call_after_refresh and ignores it.
1508 if not container.is_running:
1509 return []
1510 mounts = [self._mount_grid_section(section, container) for section in remaining]
1511 self._mount_grid_ctas(hf_count=hf_count)
1512 # Restore the prior viewport position; mounting fresh sections shifts
1513 # the layout and ``focus()`` below would otherwise overshoot.
1514 if prior_scroll_y:
1515 container.scroll_to(y=prior_scroll_y, animate=False)
1516 # Lock focus onto a grid once mount completes so j / k / PgDn /
1517 # PgUp dispatch correctly. Without this, on first paint the focus
1518 # race can leave nothing focused and the catalog feels frozen
1519 # until the user toggles to list view and back. When the previous
1520 # paint had a focused grid, restore the cursor to the same
1521 # section + highlighted index instead of jumping to the top.
1522 if not self._grid_view or self._focused_grid() is not None:
1523 return mounts
1524 # ...but "no grid has focus" is not "nothing has focus". This runs from
1525 # call_after_refresh, so the user may have pressed `/` since the mount
1526 # was scheduled; _focused_grid() is None while the filter Input owns the
1527 # cursor, so the check above would let a repaint yank it out of the
1528 # field they just opened. Same invariant _initial_focus_first_grid keeps.
1529 if self._search_focused:
1530 return mounts
1531 if self._restore_focused_section(focus_anchor):
1532 return mounts
1533 self._focus_first_grid()
1534 return mounts
1536 def _grid_scroll_hint_text(self, hf_count: int) -> str:
1537 """Pick the bottom scroll-hint text based on fetch state."""
1538 if self._loading_more:
1539 return msg.CATALOG_GRID_LOADING_MORE.format(frame=SPINNER_FRAMES[self._spinner_frame])
1540 if self._active_task_has_more():
1541 return msg.CATALOG_GRID_LOAD_MORE.format(count=hf_count)
1542 if self._active_task_truncated():
1543 return msg.CATALOG_GRID_CUT_SHORT.format(count=hf_count)
1544 return msg.CATALOG_GRID_ALL_LOADED.format(count=hf_count)
1546 def _mount_grid_ctas(self, *, hf_count: int) -> None:
1547 try:
1548 container = self._grid_container
1549 except Exception:
1550 return
1551 ctas: list[Static] = [
1552 Static(
1553 self._grid_scroll_hint_text(hf_count),
1554 classes="grid-cta scroll-hint",
1555 )
1556 ]
1557 search = self._get_search_text()
1558 if search:
1559 ctas.append(
1560 Static(
1561 msg.CATALOG_SEARCH_HF_CTA.format(query=search),
1562 classes="grid-cta search-hf-cta",
1563 markup=False,
1564 )
1565 )
1566 container.mount_all(ctas)
1568 def _refresh_grid_ctas(self, *, hf_count: int) -> None:
1569 """Update the bottom CTA strip in place; remount when class changes."""
1570 try:
1571 container = self._grid_container
1572 except Exception:
1573 return
1574 existing = list(container.query(".grid-cta"))
1575 for w in existing:
1576 with contextlib.suppress(Exception):
1577 w.remove()
1578 self._mount_grid_ctas(hf_count=hf_count)
1580 def _rows_for_active_tab(
1581 self, all_rows: list[LocalCatalogRow], active_tab: str
1582 ) -> list[CatalogRow]:
1583 """Slice the source row list for what the active task tab should render.
1585 Library/Discover bypass this (their refresh paths build their own
1586 slices). For task tabs, returns rows for the matching ModelTask
1587 further filtered by the per-tab SourceMode chip; CLOUD and BOTH
1588 also union the matching frontier rows.
1589 """
1590 if active_tab not in TASK_TAB_IDS:
1591 return list(all_rows)
1592 active_task = TAB_ID_TO_TASK[active_tab]
1593 mode = self._source_modes.get(active_tab, SourceMode.LOCAL)
1594 local_for_task: list[CatalogRow] = []
1595 if mode is not SourceMode.CLOUD:
1596 local_for_task = [r for r in all_rows if r.task == active_task.value]
1597 frontier_for_task: list[CatalogRow] = []
1598 if mode is not SourceMode.LOCAL:
1599 frontier_for_task = [r for r in self._frontier_rows if r.task == active_task.value]
1600 return local_for_task + frontier_for_task
1602 def _filter_grid(self) -> None:
1603 """Re-render the grid with the current filter applied via _refresh_grid."""
1604 self._refresh_grid()
1606 @on(ModelGrid.Highlighted)
1607 def _on_grid_highlighted(self, event: ModelGrid.Highlighted) -> None:
1608 """Run keyboard-driven prefetch on every grid cursor move and, when
1609 the cursor lands on the last row of the last grid, scroll the parent
1610 VerticalScroll to its end so the inline scroll-hint Static comes into
1611 view (matches the natural overshoot mouse-scroll past the cards
1612 already produces). Also re-renders the detail drawer for the newly
1613 highlighted row.
1614 """
1615 self._maybe_prefetch_on_grid_nav()
1616 self._reveal_scroll_hint_at_catalog_end()
1617 self._update_drawer_for_grid(event.grid, event.index)
1619 def _update_drawer_for_grid(self, grid: ModelGrid, index: int) -> None:
1620 """Push the focused row into the drawer; no-op if drawer is detached."""
1621 try:
1622 drawer = self.query_one("#catalog-detail-drawer", CatalogDetailDrawer)
1623 except Exception:
1624 return
1625 rows = grid.rows
1626 row = rows[index] if 0 <= index < len(rows) else None
1627 drawer.update_for_row(row)
1629 def on_key(self, event: Key) -> None:
1630 """Intercept 1-6 to jump tabs even when a focused widget owns digits.
1632 Bindings with priority=True should win against focused-widget
1633 bindings, but Textual's TabbedContent's inner ContentTabs swallows
1634 numeric keypresses before they reach screen-level bindings. An
1635 explicit on_key handler intercepts the digit at the bubbling stage,
1636 triggers ``action_select_tab``, and stops further dispatch so the
1637 digit doesn't bleed into the search Input or another widget.
1638 """
1639 if self._search_focused:
1640 return
1641 # 1-based digit -> 0-based tab index; bounded by the tab count so the
1642 # 1..6 contract derives from ALL_TAB_IDS rather than a parallel map.
1643 from lilbee.cli.tui.screens.catalog_utils import ALL_TAB_IDS
1645 if not event.key.isdigit():
1646 return
1647 index = int(event.key) - 1
1648 if not 0 <= index < len(ALL_TAB_IDS):
1649 return
1650 event.stop()
1651 event.prevent_default()
1652 self.action_select_tab(index)
1654 def action_select_tab(self, index: int) -> None:
1655 """Activate the tab at *index* in ALL_TAB_IDS (0..5)."""
1656 from lilbee.cli.tui.screens.catalog_utils import ALL_TAB_IDS
1658 if self._search_focused:
1659 return
1660 if not 0 <= index < len(ALL_TAB_IDS):
1661 return
1662 target = ALL_TAB_IDS[index]
1663 try:
1664 tabs = self.query_one("#catalog-tabs", TabbedContent)
1665 except Exception:
1666 return
1667 self.set_focus(None)
1668 if tabs.active != target:
1669 tabs.active = target
1670 self._active_tab_id_cache = target
1672 def action_cycle_tab(self, delta: int) -> None:
1673 """Step the active tab by *delta*, wrapping around the strip.
1675 ctrl+right -> next, ctrl+left -> prev. Wraps so the user can spin
1676 either direction without hitting an end stop.
1677 """
1678 from lilbee.cli.tui.screens.catalog_utils import ALL_TAB_IDS
1680 if self._search_focused:
1681 return
1682 try:
1683 current = ALL_TAB_IDS.index(self._active_tab_id_cache)
1684 except ValueError:
1685 current = 0
1686 next_index = (current + delta) % len(ALL_TAB_IDS)
1687 self.action_select_tab(next_index)
1689 def action_cycle_source(self) -> None:
1690 """Cycle the active task tab's source mode: LOCAL -> CLOUD -> BOTH.
1692 No-op outside the four task tabs (Discover/Library aren't filtered
1693 by source). Per-tab mode means flipping Chat to BOTH doesn't drag
1694 Embed along; users can keep different views per task.
1695 """
1696 if self._search_focused:
1697 return
1698 active = self._active_tab_id_cache
1699 if active not in TASK_TAB_IDS:
1700 return
1701 self._source_modes[active] = next_source_mode(self._source_modes[active])
1702 # Force a rebuild on this tab; cache key for this tab is now stale
1703 # because the source filter changed but the upstream row data didn't.
1704 self._grid_cache_keys.pop(active, None)
1705 self._list_cache_keys.pop(active, None)
1706 self._refresh_view()
1708 def action_toggle_drawer(self) -> None:
1709 """Toggle the detail drawer's visibility via the -collapsed class.
1711 Default state is collapsed; users opt in. Class toggle is a single
1712 layout pass; we don't dynamically mount/unmount the drawer because
1713 rendering it offscreen costs zero (display: none).
1714 """
1715 try:
1716 drawer = self.query_one("#catalog-detail-drawer", CatalogDetailDrawer)
1717 except Exception:
1718 return
1719 drawer.toggle_class("-collapsed")
1721 def _reveal_scroll_hint_at_catalog_end(self) -> None:
1722 """Scroll the catalog container to the end when the keyboard cursor
1723 is on the last row of the bottom-most grid; otherwise no-op so the
1724 ``watch_highlighted`` cell-into-view scroll keeps tracking the cursor.
1726 ``immediate=True`` so the overshoot lands in the same compositor
1727 frame as the cell-into-view scroll above it; deferred would let a
1728 subsequent ``parent.scroll_to_region`` re-pin scroll_y to the cell.
1730 Task tabs only: Discover/Library have no inline scroll hint, and the
1731 overshoot would yank the rails viewport away from the cursor.
1732 """
1733 if self._active_task() is None:
1734 return
1735 focused = self._focused_grid()
1736 if not isinstance(focused, ModelGrid) or focused.highlighted is None:
1737 return
1738 grids = list(self._grid_container.query(ModelGrid))
1739 if not grids or focused is not grids[-1]:
1740 return
1741 cols = max(1, focused.columns_per_row)
1742 last_row = (len(focused.rows) - 1) // cols
1743 if focused.highlighted // cols < last_row:
1744 return
1745 self._grid_container.scroll_end(animate=False, immediate=True)
1747 def _pane_grids_with_rows(self) -> list[ModelGrid]:
1748 """The active pane's ModelGrids that have cards (empty rails skipped)."""
1749 try:
1750 return [g for g in self._grid_container.query(ModelGrid) if g.rows]
1751 except NoMatches:
1752 return []
1754 @staticmethod
1755 def _enter_grid(grid: ModelGrid, *, from_above: bool) -> None:
1756 """Deliberate cursor entry into *grid* (toad's move_focus pattern).
1758 Highlight before focus: on_focus only assigns a cursor when none is
1759 set, so the entry cell wins and the arrival is always visible.
1760 """
1761 if from_above:
1762 grid.highlight_first()
1763 else:
1764 grid.highlight_last()
1765 grid.focus()
1767 @on(GridSelect.LeaveDown)
1768 @on(ModelGrid.LeaveDown)
1769 def _on_grid_leave_down(self, event: Message) -> None:
1770 """Enter the next grid in the active pane, or fetch more at the end.
1772 Only grids are arrow targets (never the generic focus chain, which
1773 can land on arrow-dead widgets and lose the cursor). On the
1774 bottom-most grid we expose the inline scroll-hint Static (mounted
1775 below the last grid via ``_mount_grid_ctas``) by scrolling the
1776 container to its end; cursor stays parked on the last cell.
1777 """
1778 if isinstance(event, ModelGrid.LeaveDown):
1779 grids = self._pane_grids_with_rows()
1780 try:
1781 index = grids.index(event.grid)
1782 except ValueError:
1783 return
1784 if index + 1 < len(grids):
1785 self._enter_grid(grids[index + 1], from_above=True)
1786 return
1787 self._grid_container.scroll_end(animate=False, immediate=True)
1788 if self._active_task_has_more() and not self._loading_more:
1789 self._load_more()
1790 return
1791 self.focus_next()
1793 @on(GridSelect.LeaveUp)
1794 @on(ModelGrid.LeaveUp)
1795 def _on_grid_leave_up(self, event: Message) -> None:
1796 """Enter the previous grid in the active pane.
1798 On the topmost grid, return without moving focus so the cursor
1799 stays parked at the top row instead of leaking focus upward.
1800 """
1801 if isinstance(event, ModelGrid.LeaveUp):
1802 grids = self._pane_grids_with_rows()
1803 try:
1804 index = grids.index(event.grid)
1805 except ValueError:
1806 return
1807 if index > 0:
1808 self._enter_grid(grids[index - 1], from_above=False)
1809 return
1810 self.focus_previous()
1812 @on(GridSelect.Selected)
1813 def _on_grid_select_selected(self, event: GridSelect.Selected) -> None:
1814 """Handle model selection from a GridSelect (setup wizard path)."""
1815 widget = event.widget
1816 if isinstance(widget, ModelCard):
1817 self._select_row(widget.row)
1819 @on(ModelGrid.Selected)
1820 def _on_grid_selected(self, event: ModelGrid.Selected) -> None:
1821 """Handle model selection from the catalog grid view."""
1822 self._select_row(event.row)
1824 @on(ModelList.Selected)
1825 def _on_model_list_selected(self, event: ModelList.Selected) -> None:
1826 """Handle model selection from any ModelList (Local list view or Frontier tab)."""
1827 self._select_row(event.row)
1829 def _refresh_list(self) -> None:
1830 """Rebuild the list view for the active tab; per-tab cache key skips no-op rebuilds."""
1831 if not self._list_mounted():
1832 return
1833 active_tab = self._active_tab_id_cache
1834 all_rows = self._sort_rows(self._build_rows())
1835 if active_tab in TASK_TAB_IDS:
1836 active_task = TAB_ID_TO_TASK[active_tab]
1837 self._rows = [r for r in all_rows if r.task == active_task.value]
1838 else:
1839 self._rows = list(all_rows)
1840 search = self._get_search_text()
1841 # _data_version mirrors the grid key: worker landings must repaint
1842 # even when (name, installed) shapes coincide.
1843 list_key = (
1844 self._data_version,
1845 tuple((r.name, r.installed) for r in self._rows),
1846 search,
1847 )
1848 if self._list_cache_keys.get(active_tab) == list_key:
1849 self._update_sort_label()
1850 return
1851 self._list_cache_keys[active_tab] = list_key
1852 visible = [r for r in self._rows if not search or matches_search(r, search)]
1853 self._list_widget.set_rows([ModelListSection(heading=None, rows=list(visible))])
1854 self._update_sort_label()
1856 def _filter_list(self) -> None:
1857 """Filter the list view to rows matching the active search."""
1858 search = self._get_search_text()
1859 visible = [r for r in self._rows if not search or matches_search(r, search)]
1860 self._list_widget.set_rows([ModelListSection(heading=None, rows=list(visible))])
1861 # Cache key reflects the filtered shape so a no-op _refresh_list
1862 # immediately after a filter pass does not double-render.
1863 self._list_cache_keys[self._active_tab_id_cache] = (
1864 self._data_version,
1865 tuple((r.name, r.installed) for r in self._rows),
1866 search,
1867 )
1868 self._update_sort_label()
1870 def _sync_loading_spinner(self) -> None:
1871 """Show/hide the toolbar spinner based on active fetch state.
1873 Visible when a paginated HF fetch, a remote search, or the initial
1874 families resolution is in flight (both grid and list views share the
1875 same toolbar widget). Cycles braille frames on a 100 ms timer so the
1876 wait reads as "moving" rather than "frozen".
1877 """
1878 try:
1879 spinner = self.query_one("#catalog-loading-spinner", Static)
1880 except Exception:
1881 return
1882 active = self._loading_more or self._search_in_flight or self._families_in_flight
1883 if active:
1884 spinner.styles.display = "block"
1885 spinner.update(f"{SPINNER_FRAMES[self._spinner_frame]} loading…")
1886 if self._spinner_timer is None:
1887 self._spinner_timer = self.set_interval(
1888 _SPINNER_INTERVAL_S, self._tick_loading_spinner
1889 )
1890 # Mirror the spinner into the inline scroll-hint so users
1891 # waiting at the bottom of the grid see the activity in the
1892 # same place mouse scroll surfaces it.
1893 if self._loading_more:
1894 with contextlib.suppress(Exception):
1895 hint = self._grid_container.query_one(".scroll-hint", Static)
1896 hint.update(
1897 msg.CATALOG_GRID_LOADING_MORE.format(
1898 frame=SPINNER_FRAMES[self._spinner_frame]
1899 )
1900 )
1901 else:
1902 spinner.update("")
1903 spinner.styles.display = "none"
1904 if self._spinner_timer is not None:
1905 self._spinner_timer.stop()
1906 self._spinner_timer = None
1907 self._spinner_frame = 0
1908 # Restore the post-load CTA text now that the fetch settled.
1909 # Count is per active task tab so the hint matches what's rendered.
1910 hf_rows = self._build_hf_rows(self._get_search_text()) if self._hf_fetched_any() else []
1911 active_task = self._active_task()
1912 hf_count = (
1913 sum(1 for r in hf_rows if r.task == active_task.value)
1914 if active_task is not None
1915 else len(hf_rows)
1916 )
1917 self._refresh_grid_ctas(hf_count=hf_count)
1919 def _tick_loading_spinner(self) -> None:
1920 """Advance the spinner one braille frame; called by the interval timer."""
1921 self._spinner_frame = (self._spinner_frame + 1) % len(SPINNER_FRAMES)
1922 with contextlib.suppress(Exception):
1923 spinner = self.query_one("#catalog-loading-spinner", Static)
1924 spinner.update(f"{SPINNER_FRAMES[self._spinner_frame]} loading…")
1925 if self._loading_more:
1926 with contextlib.suppress(Exception):
1927 hint = self._grid_container.query_one(".scroll-hint", Static)
1928 hint.update(
1929 msg.CATALOG_GRID_LOADING_MORE.format(frame=SPINNER_FRAMES[self._spinner_frame])
1930 )
1932 def _update_sort_label(self) -> None:
1933 """Update the sort indicator label, switching copy by active tab.
1935 Wrapped in NoMatches suppression because the worker callbacks that
1936 trigger an update (``_fetch_remote_models``, ``_fetch_frontier_models``)
1937 can fire on the next loop tick after a screen switch, before the
1938 new screen's ``compose`` has finished mounting ``#sort-label``.
1939 On Windows that race lands often enough to fail CI.
1940 """
1941 try:
1942 label = self.query_one("#sort-label", Static)
1943 except NoMatches:
1944 return
1945 if self._active_tab_id() == TAB_LIBRARY:
1946 label.update(self._frontier_label_text())
1947 return
1948 direction = "asc" if self._sort_ascending else "desc"
1949 n_total = len(self._rows)
1950 if self._loading_more:
1951 count = f"{n_total} models · loading more…"
1952 elif self._active_task_has_more():
1953 count = f"{n_total} models · press [b]n[/b] for more"
1954 elif self._active_task_truncated():
1955 count = msg.CATALOG_LIST_CUT_SHORT.format(count=n_total)
1956 else:
1957 count = msg.CATALOG_LIST_COUNT.format(count=n_total)
1958 hint = msg.CATALOG_SEARCHING_HF if self._search_in_flight else msg.CATALOG_VIEW_TOGGLE_LIST
1959 label.update(f"Sort: {self._sort_column} ({direction}) | {count} | {hint}")
1961 def _frontier_label_text(self) -> str:
1962 provider_count = len({r.provider for r in self._frontier_rows})
1963 return msg.CATALOG_FRONTIER_SUMMARY.format(
1964 count=len(self._frontier_rows), providers=provider_count
1965 )
1967 def action_cycle_sort(self) -> None:
1968 """Cycle the list-view sort column ascending: Name, Downloads, Size, Params."""
1969 if self._search_focused:
1970 return
1971 if self._active_tab_id() not in TASK_TAB_IDS:
1972 return
1973 if self._grid_view:
1974 self.notify(msg.CATALOG_SORT_LIST_ONLY)
1975 return
1976 try:
1977 idx = _SORT_CYCLE.index(self._sort_column)
1978 except ValueError:
1979 idx = -1
1980 self._sort_column = _SORT_CYCLE[(idx + 1) % len(_SORT_CYCLE)]
1981 self._sort_ascending = True
1982 self._refresh_list()
1983 # mount_all is async; focus the first row after Textual's next
1984 # refresh so the filter Input doesn't swallow the next `s` press.
1985 self.call_after_refresh(self._focus_list_item, 0)
1987 def _select_row(self, row: CatalogRow) -> None:
1988 """Handle row selection: install, switch model, or open settings."""
1989 if row.kind == CatalogRowKind.FRONTIER: # sealed-union dispatch
1990 self._select_frontier_row(row)
1991 return
1992 if row.variant and row.family:
1993 self._install_variant(row.variant, row.family)
1994 elif row.catalog_model:
1995 self._install_model(row.catalog_model)
1996 elif row.remote_model:
1997 apply_active_model(self.app, _model_field_for_task(row.remote_model.task), row.ref)
1998 self.notify(msg.CATALOG_USING_REMOTE.format(name=row.remote_model.name))
2000 def _select_frontier_row(self, row: FrontierCatalogRow) -> None:
2001 """Activate a cloud model, or jump to settings when its key is missing or rejected."""
2002 if row.key_status == KeyStatus.READY:
2003 apply_active_model(self.app, _model_field_for_task(row.task), row.ref)
2004 self.notify(
2005 msg.CATALOG_USING_FRONTIER.format(name=row.name, provider=row.provider),
2006 )
2007 return
2008 key_field = PROVIDER_API_KEY_FIELD.get(row.provider_id, f"{row.provider_id}_api_key")
2009 self.notify(
2010 _KEY_PROBLEM_MESSAGE[row.key_status].format(provider=row.provider, key_field=key_field),
2011 severity="warning",
2012 timeout=10,
2013 )
2014 self.app.switch_view("Settings")
2016 def _load_more(self) -> None:
2017 """Load the next HF page for the active task tab.
2019 Pagination is per-task: only the active tab's offset advances, only
2020 the active tab's task is fetched. Discover and Library short-circuit
2021 because they have no associated task and can't paginate. While a
2022 search is active the search's own offset advances instead; paging the
2023 browse offset there would fetch models the filter then discards.
2024 """
2025 if self._loading_more:
2026 return
2027 if self._get_search_text():
2028 self._load_more_search_results()
2029 return
2030 task = self._active_task()
2031 if task is None or not self._hf_has_more_by_task.get(task, False):
2032 return
2033 self._loading_more = True
2034 self._sync_loading_spinner()
2035 self._hf_offset_by_task[task] += _HF_PAGE_SIZE
2036 self._fetch_more_hf_for_task(task)
2038 def _load_more_search_results(self) -> None:
2039 """Advance the active search by one page.
2041 Guarded on the query the offset was fetched under, so a term edited
2042 mid-flight cannot append matches for a term nobody typed.
2043 """
2044 query = self._get_search_text()
2045 if self._search_in_flight or not self._search_has_more:
2046 return
2047 if query != self._searched_query:
2048 return
2049 active_task = TAB_ID_TO_TASK.get(self._active_tab_id())
2050 if active_task is None:
2051 return
2052 self._search_offset += _HF_SEARCH_LIMIT
2053 self._search_in_flight = True
2054 self._sync_loading_spinner()
2055 self._fetch_hf_search(query, active_task, self._search_offset)
2057 def action_load_more(self) -> None:
2058 """Keyboard trigger (``n``) so users can page without scrolling."""
2059 if self._active_tab_id() not in TASK_TAB_IDS:
2060 return
2061 self._load_more()
2063 @on(TabbedContent.TabActivated, "#catalog-tabs")
2064 def _on_catalog_tab_activated(self, event: TabbedContent.TabActivated) -> None:
2065 """Update active-tab cache, refresh sort label, populate the active pane.
2067 Cache update is the load-bearing line: every later check that asks
2068 ``_active_tab_id()`` reads this cache, not a fresh DOM query, so
2069 per-render overhead stays constant regardless of tab count.
2070 """
2071 new_tab = event.pane.id or TAB_CHAT
2072 if not self._activation_settled:
2073 return
2074 self._active_tab_id_cache = new_tab
2075 # Stale per-tab widget caches survive across tab activations,
2076 # but if the user switched after a remount, the cached handle
2077 # may be detached. _grid_for_tab/_list_for_tab validate via
2078 # is_running and refetch as needed.
2079 self._update_sort_label()
2080 if new_tab == TAB_LIBRARY:
2081 self._populate_library_list()
2082 elif new_tab == TAB_DISCOVER:
2083 self._populate_discover_rails()
2084 elif new_tab in TASK_TAB_IDS:
2085 # Lazy first-fetch: tabs other than Chat skip their HF round-trip
2086 # at mount and hit the API only when first activated. Cached
2087 # after, so re-activations stay free.
2088 self._ensure_task_initial_fetch(TAB_ID_TO_TASK[new_tab])
2089 # Refresh the newly active task tab. Per-tab cache key skips
2090 # the rebuild when the row shape hasn't changed since last paint.
2091 self._refresh_view()
2093 def _populate_discover_rails(self) -> None:
2094 """Push three curated row slices into the Discover landing.
2096 - For You: one runnable pick per role, in role order. Only rows the
2097 engine can load and the host cannot prove will not run, measured
2098 rows first, so every card is a one-click install rather than a
2099 coin flip.
2100 - Your Collection: every installed local row + every activated
2101 cloud API. Mirrors the Library tab's spirit but capped to a
2102 single rail-friendly slice.
2103 - Fresh on the Hub: most-downloaded non-featured HF rows as a
2104 recency-ish proxy (the API doesn't expose 'newly uploaded' as
2105 a sort key today; downloads-desc surfaces buzzy recent uploads),
2106 skipping rows For You already took.
2107 """
2108 try:
2109 rails = self.query_one("#discover-rails", DiscoverRails)
2110 except Exception:
2111 return
2112 family_rows = self._all_family_rows()
2113 hf_rows = self._all_hf_rows() if self._hf_fetched_any() else []
2114 remote_rows = self._all_remote_rows()
2115 for_you = for_you_by_role(family_rows + hf_rows)
2116 collection = [r for r in family_rows + remote_rows if r.installed][:6]
2117 taken_refs = {r.ref for r in for_you}
2118 fresh = sorted(
2119 (r for r in hf_rows if not r.featured and r.ref not in taken_refs),
2120 key=lambda r: -r.sort_downloads,
2121 )[:6]
2122 rails.set_rails(for_you=for_you, collection=collection, fresh=fresh)
2124 def _install_variant(self, variant: ModelVariant, family: ModelFamily) -> None:
2125 """Convert a variant back to a CatalogModel and trigger install."""
2126 entry = CatalogModel(
2127 hf_repo=variant.hf_repo,
2128 gguf_filename=variant.filename,
2129 size_gb=variant.size_mb / 1024,
2130 min_ram_gb=estimate_min_ram_gb(variant.size_mb / 1024),
2131 description=family.description,
2132 featured=True,
2133 downloads=0,
2134 task=family.task,
2135 )
2136 self._install_model(entry)
2138 def _install_model(self, model: CatalogModel) -> None:
2139 if self.app.task_bar.pending_download(model) is not None:
2140 self.notify(msg.CATALOG_ALREADY_DOWNLOADING.format(name=model.display_name))
2141 return
2142 # The row's own size is an approximation off the parameter count; one
2143 # file is named here, so the check asks HuggingFace what it really costs.
2144 needed = int(model.size_gb * _BYTES_PER_GB)
2145 try:
2146 filename = resolve_filename(model)
2147 dest = cfg.models_dir / filename
2148 if dest.exists():
2149 self.notify(msg.CATALOG_ALREADY_INSTALLED.format(name=model.display_name))
2150 return
2151 needed = download_bytes(model.hf_repo, filename)
2152 except Exception:
2153 log.debug("Could not resolve filename", exc_info=True)
2155 # After the already-installed check, which needs no space, and before
2156 # the enqueue: a task that fails instantly is terminal, so dedupe would
2157 # not stop a second row.
2158 shortfall = disk_shortfall(cfg.models_dir, model.hf_repo, needed)
2159 if shortfall is not None:
2160 self.notify(shortfall, severity="warning")
2161 return
2163 self._enqueue_download(model)
2165 def _on_chat_ready_changed(self, ready: bool) -> None:
2166 """Show the first-run welcome only while no chat model resolves."""
2167 with contextlib.suppress(NoMatches):
2168 self.query_one("#catalog-welcome", Static).display = not ready
2170 def _enqueue_download(self, model: CatalogModel) -> None:
2171 """Submit the download to the app-level TaskBarController.
2173 The controller owns the worker thread; this screen just fires the
2174 request and returns. Progress is visible from every screen and
2175 survives navigation. When the row's architecture is known-unsupported,
2176 confirm with a modal before enqueuing; the modal returns True to
2177 proceed with ``allow_unsupported=True`` or False to cancel.
2178 """
2180 def _adopt() -> None:
2181 # Runs on the download worker thread; the safe wrapper drops the
2182 # hop on a detached screen instead of crashing the worker.
2183 call_from_thread(self, self._adopt_first_download, model)
2185 if model.compat is ModelCompat.UNSUPPORTED:
2187 def _after_confirm(verdict: bool | None) -> None:
2188 if not verdict:
2189 return
2190 self.app.task_bar.start_download(model, allow_unsupported=True, on_success=_adopt)
2191 self.notify(msg.CATALOG_QUEUED_DOWNLOAD.format(name=model.display_name))
2193 self.app.push_screen(
2194 ConfirmDialog(
2195 msg.COMPAT_MODAL_TITLE,
2196 msg.COMPAT_MODAL_BODY.format(arch=model.architecture or "unknown"),
2197 ),
2198 _after_confirm,
2199 )
2200 return
2202 self.app.task_bar.start_download(model, on_success=_adopt)
2203 self.notify(msg.CATALOG_QUEUED_DOWNLOAD.format(name=model.display_name))
2205 def _adopt_first_download(self, model: CatalogModel) -> None:
2206 """Make the first model of an unconfigured role the active one.
2208 A role that already has a model keeps it: a later download never
2209 steals the assignment. Chat announces itself, since the user's next
2210 step (start chatting) is on another screen.
2211 """
2212 field = _model_field_for_task(model.task)
2213 if getattr(cfg, field):
2214 return
2215 apply_active_model(self.app, field, model.ref)
2216 if field == "chat_model":
2217 self.app.notify(msg.CHAT_READY_TOAST)
2219 def action_go_back(self) -> None:
2220 # An open filter collapses to hidden (restoring grid/list focus);
2221 # otherwise q / Esc returns to the view the user came from.
2222 if self._filter_open:
2223 self._search_input.value = ""
2224 self._search_input.add_class(_HIDDEN_CLASS)
2225 self._focus_list_or_grid()
2226 return
2227 self.app.go_back()
2229 def _focus_list_or_grid(self) -> None:
2230 """Move focus from the filter input to the active view's list/grid."""
2231 if self._grid_view:
2232 self._focus_first_grid()
2233 else:
2234 self._focus_list_item(0)
2236 def action_show_info(self) -> None:
2237 """Pop up an info modal for the highlighted catalog row."""
2238 if self._search_focused:
2239 return
2240 row = self._highlighted_row()
2241 if row is None:
2242 self.notify(msg.CATALOG_SELECT_FOR_INFO, severity="warning")
2243 return
2244 if row.kind != CatalogRowKind.LOCAL:
2245 self.notify(msg.CATALOG_FRONTIER_NO_INFO, severity="warning")
2246 return
2247 from lilbee.cli.tui.screens.model_info import ModelInfoModal
2249 self.app.push_screen(ModelInfoModal(row))
2251 def _highlighted_row(self) -> CatalogRow | None:
2252 """Return the focused row in either grid or list view, or None."""
2253 if not self._grid_view and self._list_widget.has_focus:
2254 return self._list_widget.highlighted_row()
2255 focused_grid = self._focused_grid()
2256 if focused_grid is None or focused_grid.highlighted is None:
2257 return None
2258 if isinstance(focused_grid, ModelGrid):
2259 rows = focused_grid.rows
2260 index = focused_grid.highlighted
2261 return rows[index] if 0 <= index < len(rows) else None
2262 child = focused_grid.children[focused_grid.highlighted]
2263 if isinstance(child, ModelCard):
2264 return child.row
2265 return None
2267 def action_delete_model(self) -> None:
2268 """Delete an installed model. First press asks confirmation, second confirms."""
2269 if self._search_focused:
2270 return
2271 model_name = self._get_highlighted_model_name()
2272 if model_name is None:
2273 self.notify(msg.CATALOG_SELECT_TO_DELETE, severity="warning")
2274 return
2276 if not self._row_is_installed(model_name):
2277 self.notify(
2278 msg.CATALOG_NOT_INSTALLED.format(name=model_name),
2279 severity="warning",
2280 )
2281 return
2283 if self._pending_delete == model_name:
2284 self._pending_delete = None
2285 self._run_delete(model_name)
2286 else:
2287 self._pending_delete = model_name
2288 self.notify(msg.CATALOG_CONFIRM_DELETE.format(name=model_name))
2290 def _row_is_installed(self, model_name: str) -> bool:
2291 """True if *model_name* names an installed native or remote model.
2293 ``_installed_names`` carries both the full ``<repo>/<file>.gguf``
2294 ref and the bare ``hf_repo`` for every installed native model,
2295 so it answers either ref shape; remote presence is asked of the
2296 manager directly.
2297 """
2298 if model_name in self._installed_names:
2299 return True
2300 return get_services().model_manager.is_installed(model_name, ModelSource.REMOTE)
2302 def _resolve_delete_ref(self, identity: str) -> str:
2303 """Pick the single registry ref that deleting *identity* maps to.
2305 Featured / HF browse rows surface a bare hf_repo while the
2306 registry deletes by ``<hf_repo>/<file>.gguf``. Bare repos
2307 resolve to the lexicographically-first matching installed
2308 manifest; full refs and remote names pass through.
2309 """
2310 if "/" in identity and identity.endswith(".gguf"):
2311 return identity
2312 prefix = identity + "/"
2313 matches = sorted(n for n in self._installed_names if n.startswith(prefix))
2314 if matches:
2315 return matches[0]
2316 return identity
2318 def _get_highlighted_model_name(self) -> str | None:
2319 """Return the registry-compatible model ref for the focused/highlighted row."""
2320 if not self._grid_view and self._list_widget.has_focus:
2321 row = self._list_widget.highlighted_row()
2322 return row_delete_id(row) if row else None
2323 focused_grid = self._focused_grid()
2324 if focused_grid is None or focused_grid.highlighted is None:
2325 return None
2326 if isinstance(focused_grid, ModelGrid):
2327 rows = focused_grid.rows
2328 index = focused_grid.highlighted
2329 if 0 <= index < len(rows):
2330 return row_delete_id(rows[index])
2331 return None
2332 # GridSelect path: cards are direct children indexed positionally.
2333 child = focused_grid.children[focused_grid.highlighted]
2334 if isinstance(child, ModelCard):
2335 return row_delete_id(child.row)
2336 return None
2338 @work(thread=True)
2339 def _run_delete(self, model_name: str) -> None:
2340 """Remove a model in a background thread."""
2341 delete_ref = self._resolve_delete_ref(model_name)
2342 try:
2343 removed = get_services().model_manager.remove(delete_ref)
2344 if removed:
2345 call_from_thread(self, self.notify, msg.CATALOG_DELETED.format(name=model_name))
2346 call_from_thread(self, self._refresh_after_delete)
2347 else:
2348 call_from_thread(
2349 self,
2350 self.notify,
2351 msg.CATALOG_DELETE_FAILED.format(error=model_name),
2352 severity="error",
2353 )
2354 except Exception as exc:
2355 log.warning("Delete failed for %s", model_name, exc_info=True)
2356 call_from_thread(
2357 self,
2358 self.notify,
2359 msg.CATALOG_DELETE_FAILED.format(error=exc),
2360 severity="error",
2361 )
2363 def _refresh_after_delete(self) -> None:
2364 """Re-fetch remote models and refresh after deletion."""
2365 self._fetch_installed_names()
2366 self._refresh_view()
2367 self._fetch_remote_models()
2369 def _focused_grid(self) -> ModelGrid | GridSelect | None:
2370 """Return the focused grid widget (grid view), else None."""
2371 if self._grid_view and isinstance(self.focused, (ModelGrid, GridSelect)):
2372 return self.focused
2373 return None
2375 def _list_count(self) -> int:
2376 """Total options currently shown in the list view (excluding headings)."""
2377 return self._list_widget.row_count
2379 def _focus_list_item(self, index: int) -> None:
2380 """Highlight the row at *index*, clamped to the visible range."""
2381 count = self._list_widget.option_count
2382 if not count:
2383 return
2384 clamped = max(0, min(index, count - 1))
2385 self._list_widget.highlighted = clamped
2386 # Same invariant as _mount_remaining_grid_sections: this is reached from
2387 # call_after_refresh, so it can run after the user has opened the filter
2388 # with `/`, and taking the cursor back would strand them mid-keystroke.
2389 if self._search_focused:
2390 return
2391 self.set_focus(self._list_widget)
2393 def _focused_list_index(self) -> int | None:
2394 """Index of the highlighted list row, or None when nothing is highlighted."""
2395 return self._list_widget.highlighted
2397 def _nudge_list(self, delta: int) -> None:
2398 idx = self._focused_list_index()
2399 if idx is None:
2400 self._focus_list_item(0)
2401 return
2402 self._focus_list_item(idx + delta)
2403 self._maybe_prefetch_on_nav()
2405 def _maybe_prefetch_on_nav(self) -> None:
2406 if self._grid_view or not self._active_task_has_more() or self._loading_more:
2407 return
2408 idx = self._focused_list_index()
2409 if idx is None:
2410 return
2411 if idx >= self._list_widget.option_count - _HF_LOAD_MORE_TRIGGER:
2412 self._load_more()
2414 def _maybe_prefetch_on_grid_nav(self) -> None:
2415 """Fire ``_load_more`` when the keyboard cursor lands within the last
2416 rows of the catalog. Mouse wheel triggers via ``_on_grid_scrolled`` at
2417 the 85 % scroll threshold, but cell-by-cell keyboard nav advances
2418 scroll_y too gradually to ever cross that threshold; this check
2419 guarantees keyboard reaches the same prefetch trigger.
2420 """
2421 if not self._grid_view or not self._active_task_has_more() or self._loading_more:
2422 return
2423 grids = list(self._grid_container.query(ModelGrid))
2424 if not grids:
2425 return
2426 focused = self._focused_grid()
2427 if not isinstance(focused, ModelGrid) or focused.highlighted is None:
2428 return
2429 # Absolute cursor position = cards in earlier grids + cursor in this grid.
2430 try:
2431 grid_index = grids.index(focused)
2432 except ValueError:
2433 return
2434 cards_before = sum(len(g.rows) for g in grids[:grid_index])
2435 absolute = cards_before + focused.highlighted
2436 total = sum(len(g.rows) for g in grids)
2437 if total <= 0:
2438 return
2439 if absolute >= total - _HF_LOAD_MORE_TRIGGER:
2440 self._load_more()
2442 _SCROLL_PREFETCH_RATIO = 0.85
2443 _SCROLL_PREFETCH_COOLDOWN = 0.8
2445 def _on_list_scrolled(self, _scroll_y: float) -> None:
2446 """Trigger _load_more when the user scrolls near the bottom of the list."""
2447 if not self._scroll_prefetch_due(self._list_widget):
2448 return
2449 self._scroll_prefetch_armed_at = time.monotonic()
2450 self._load_more()
2452 def _on_grid_scrolled(self, _scroll_y: float) -> None:
2453 """Trigger _load_more when the user scrolls near the bottom of the grid."""
2454 if not self._grid_view:
2455 return
2456 if not self._scroll_prefetch_due(self._grid_container):
2457 return
2458 self._scroll_prefetch_armed_at = time.monotonic()
2459 self._load_more()
2461 def on_mouse_scroll_down(self, event: MouseScrollDown) -> None:
2462 """Force pagination when wheeling beyond what the active scroll can scroll.
2464 Three collapsed triggers, both views: (1) content already fits the
2465 viewport so ``max_scroll_y == 0`` and wheel events produce no scroll
2466 delta, (2) the user has wheeled to ``scroll_y == max_scroll_y`` and
2467 further wheels produce no delta, (3) list view has the same problem
2468 as grid view -- the scroll watcher only fires on scroll_y changes,
2469 so a wheel at max_y is invisible to ``_on_list_scrolled`` /
2470 ``_on_grid_scrolled``. Re-check here and fetch the next page
2471 directly. Cooldown prevents a cascade as new rows shift max_scroll_y.
2472 """
2473 if not self._active_task_has_more() or self._loading_more:
2474 return
2475 container = self._grid_container if self._grid_view else self._list_widget
2476 max_y = container.max_scroll_y
2477 if max_y > 0 and container.scroll_y < max_y:
2478 return
2479 if self._scroll_prefetch_armed_at:
2480 elapsed = time.monotonic() - self._scroll_prefetch_armed_at
2481 if elapsed < self._SCROLL_PREFETCH_COOLDOWN:
2482 return
2483 self._scroll_prefetch_armed_at = time.monotonic()
2484 self._load_more()
2486 def _scroll_prefetch_due(self, widget: VerticalScroll | ModelList) -> bool:
2487 # Cooldown blocks a runaway cascade where appending rows shifts
2488 # max_scroll_y, the watcher refires, and load_more kicks off the
2489 # next fetch before the user notices.
2490 if not self._active_task_has_more() or self._loading_more:
2491 return False
2492 if self._scroll_prefetch_armed_at:
2493 elapsed = time.monotonic() - self._scroll_prefetch_armed_at
2494 if elapsed < self._SCROLL_PREFETCH_COOLDOWN:
2495 return False
2496 max_y = widget.max_scroll_y
2497 if max_y <= 0:
2498 return False
2499 return widget.scroll_y / max_y >= self._SCROLL_PREFETCH_RATIO
2501 def _page_rows(self) -> int:
2502 """How many cursor steps make up one 'page' in the active view."""
2503 return _GRID_PAGE_ROWS if self._grid_view else _LIST_PAGE_ROWS
2505 def action_page_down(self) -> None:
2506 if self._search_focused:
2507 return
2508 if self._grid_view:
2509 if (grid := self._focused_grid()) is not None:
2510 for _ in range(self._page_rows()):
2511 grid.action_cursor_down()
2512 else:
2513 self._nudge_list(self._page_rows())
2515 def action_page_up(self) -> None:
2516 if self._search_focused:
2517 return
2518 if self._grid_view:
2519 if (grid := self._focused_grid()) is not None:
2520 for _ in range(self._page_rows()):
2521 grid.action_cursor_up()
2522 else:
2523 self._nudge_list(-self._page_rows())
2525 def action_cursor_down(self) -> None:
2526 if self._search_focused:
2527 return
2528 if self._grid_view:
2529 grid = self._focused_grid() or self._first_grid_or_none()
2530 if grid is not None:
2531 grid.focus()
2532 grid.action_cursor_down()
2533 else:
2534 self._nudge_list(1)
2536 def action_cursor_up(self) -> None:
2537 if self._search_focused:
2538 return
2539 if self._grid_view:
2540 grid = self._focused_grid() or self._first_grid_or_none()
2541 if grid is not None:
2542 grid.focus()
2543 grid.action_cursor_up()
2544 else:
2545 self._nudge_list(-1)
2547 def _first_grid_or_none(self) -> ModelGrid | None:
2548 """Return the first non-empty ModelGrid in the active tab's container."""
2549 grids = self._pane_grids_with_rows()
2550 return grids[0] if grids else None
2552 def action_jump_top(self) -> None:
2553 if self._search_focused:
2554 return
2555 if self._grid_view:
2556 if (grid := self._focused_grid()) is not None:
2557 grid.highlight_first()
2558 else:
2559 self._focus_list_item(0)
2561 def action_jump_bottom(self) -> None:
2562 if self._search_focused:
2563 return
2564 if self._grid_view:
2565 if (grid := self._focused_grid()) is not None:
2566 grid.highlight_last()
2567 else:
2568 count = self._list_widget.option_count
2569 if count:
2570 self._focus_list_item(count - 1)
2571 self._maybe_prefetch_on_nav()