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