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