Coverage for src / lilbee / cli / tui / screens / catalog.py: 100%
1220 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-06-28 01:01 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-06-28 01:01 +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
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.events import Click, Key, MouseScrollDown
16from textual.message import Message
17from textual.screen import Screen
18from textual.timer import Timer
19from textual.widgets import Footer, Input, Static, TabbedContent, TabPane
20from textual.worker import Worker, WorkerState
22from lilbee.app.services import get_services
23from lilbee.catalog import (
24 CatalogModel,
25 ModelFamily,
26 ModelVariant,
27 get_catalog,
28 get_families,
29 resolve_filename,
30)
31from lilbee.catalog.types import ModelCompat, ModelSource, ModelTask
32from lilbee.cli.tui import messages as msg
33from lilbee.cli.tui.app import LilbeeApp, apply_active_model
34from lilbee.cli.tui.screens.catalog_grouping import (
35 GridSection,
36 for_you_sort_key,
37 group_frontier_rows,
38 group_rows_for_grid,
39 group_task_rows_with_picks,
40 row_cache_signature,
41)
42from lilbee.cli.tui.screens.catalog_utils import (
43 SORT_KEYS,
44 TAB_CHAT,
45 TAB_DISCOVER,
46 TAB_EMBED,
47 TAB_ID_TO_TASK,
48 TAB_LIBRARY,
49 TAB_RERANK,
50 TAB_VISION,
51 TASK_TAB_IDS,
52 CatalogRow,
53 CatalogRowKind,
54 FrontierCatalogRow,
55 KeyStatus,
56 LocalCatalogRow,
57 SourceMode,
58 catalog_to_row,
59 family_to_size_variants,
60 frontier_row_from_remote,
61 matches_search,
62 next_source_mode,
63 remote_to_row,
64 row_delete_id,
65 variant_to_row,
66)
67from lilbee.cli.tui.thread_safe import call_from_thread
68from lilbee.cli.tui.widgets.bottom_bars import BottomBars
69from lilbee.cli.tui.widgets.catalog_detail import CatalogDetailDrawer
70from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
71from lilbee.cli.tui.widgets.discover_rails import DiscoverRails
72from lilbee.cli.tui.widgets.grid_select import GridSelect
73from lilbee.cli.tui.widgets.model_card import ModelCard
74from lilbee.cli.tui.widgets.model_grid import ModelGrid
75from lilbee.cli.tui.widgets.model_list import ModelList, ModelListSection
76from lilbee.cli.tui.widgets.status_bar import ViewTabs
77from lilbee.cli.tui.widgets.task_bar import TaskBar
78from lilbee.cli.tui.widgets.top_bars import TopBars
79from lilbee.core.config import cfg
80from lilbee.modelhub.model_manager import RemoteModel, classify_all_remote_models
81from lilbee.providers.sdk_backend import get_provider_api_key
82from lilbee.runtime.hardware import available_memory_for_fit, compute_fit
84log = logging.getLogger(__name__)
86# Models fetched per task per page. We make one /api/models call per
87# task (chat / embedding / vision / rerank), so the user-visible page
88# size is _HF_PAGE_SIZE * 4. Small pages keep each HF round-trip well
89# under a second on a typical connection and keep the freshly-rendered
90# row count low so layout reflow stays cheap.
91_HF_PAGE_SIZE = 4
92_HF_LOAD_MORE_TRIGGER = 4
93_NOTIFY_SEARCHING_TIMEOUT_SECONDS = 4
94_ALL_TASKS = tuple(ModelTask)
96_WORKER_FETCH_HF = "fetch_hf_models"
97_WORKER_FETCH_MORE_HF = "fetch_more_hf"
98_WORKER_FETCH_REMOTE = "fetch_remote_models"
99_WORKER_FETCH_SEARCH = "fetch_hf_search"
100_WORKER_FETCH_FRONTIER = "fetch_frontier_models"
102_GRID_PAGE_ROWS = 3
103_LIST_PAGE_ROWS = 10
105# Per-tab DOM ids: f"grid-{tab_id}" / f"list-{tab_id}". Memoized on the
106# screen so each access is one dict lookup, not a DOM walk.
107_GRID_ID_PREFIX = "grid-"
108_LIST_ID_PREFIX = "list-"
110# Toggles the filter Input between revealed and `display: none` (catalog.tcss).
111_HIDDEN_CLASS = "-hidden"
113_SORT_CYCLE: tuple[str, ...] = ("Name", "Downloads", "Size", "Params")
115# Braille spinner frames for the catalog pagination/search loading
116# indicator. Cycled on a 100 ms timer while the catalog is fetching
117# more HF rows or a remote search is in flight, so the user always
118# has a moving signal during the wait instead of an empty pane.
119_SPINNER_FRAMES: tuple[str, ...] = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
120_SPINNER_INTERVAL_S = 0.1
122_RowCacheKey = tuple[int, int, int, int, int, int]
125@dataclass(frozen=True)
126class _RowCacheEntry:
127 """Memoized output of one ``_all_*_rows`` builder."""
129 key: _RowCacheKey
130 rows: list[LocalCatalogRow]
133class CatalogScreen(Screen[None]):
134 """Model catalog with grid (default) and list views."""
136 app: LilbeeApp # type: ignore[assignment]
138 CSS_PATH = "catalog.tcss"
139 AUTO_FOCUS = "" # GridSelect is mounted dynamically; focused in on_mount
141 HELP = (
142 "# Catalog\n"
143 "Six tabs: Discover (curated landing), Chat / Embed / Vision / Rerank,\n"
144 "and Library (your installed local + activated cloud APIs).\n\n"
145 "## Navigation\n"
146 "- Arrows / j k h l: move the card cursor.\n"
147 "- 1-6: jump to tab N.\n"
148 "- Tab / Shift+Tab: cycle focus.\n\n"
149 "## Actions\n"
150 "- Enter: install the highlighted model (or activate, if cloud).\n"
151 "- Space: toggle select.\n"
152 "- d / Backspace / x: delete an installed model (two presses to confirm).\n"
153 "- i: open the info modal for the highlighted card.\n"
154 "- Right Arrow: expand a family card to show its size variants.\n\n"
155 "## Filters and views\n"
156 "- /: filter the active tab (Esc clears).\n"
157 "- s: cycle sort (Name / Downloads / Size / Params).\n"
158 "- v: toggle Grid vs List view on a task tab.\n"
159 "- c: cycle source chip [local | cloud | both] on a task tab.\n"
160 "- n: load more HF rows (or just keep scrolling).\n\n"
161 "## Detail drawer\n"
162 "- Ctrl+B: toggle the right-pane detail drawer.\n"
163 " Shows fit chip, size variants with per-variant fit, license, description.\n\n"
164 "## Fit chip\n"
165 "- Green 'fits +N GB': model fits with at least 1 GB headroom.\n"
166 "- Amber 'tight +N GB': model fits but within the 0..1 GB band.\n"
167 '- Red "won\'t N GB": model overflows available memory by N GB.\n\n'
168 "## Other\n"
169 "- q / Esc: back."
170 )
172 _ACTION_GROUP = Binding.Group("Actions", compact=True)
173 _SCROLL_GROUP = Binding.Group("Scroll", compact=True)
175 BINDINGS: ClassVar[list[BindingType]] = [
176 Binding("q", "go_back", "Back", show=True, group=_ACTION_GROUP),
177 Binding("escape", "dismiss_filter", "", show=False),
178 # Surfaced outside _ACTION_GROUP so the "Grid/List" affordance prints
179 # in full in the footer instead of collapsing into the compact pill.
180 # Keep the label terse; the row of bindings runs out of space on
181 # narrow terminals and truncates the rightmost item mid-word
182 # (`^t Theme` -> `^t The`) when this one carried an `(faster)` tag.
183 Binding("v", "toggle_view", "Grid/List", show=True),
184 Binding("slash", "focus_search", "Search", show=True, group=_ACTION_GROUP),
185 # `d` / `i` are bound but hidden from the footer. The catalog row
186 # was overflowing on narrow terminals and truncating the rightmost
187 # global binding (`[ ] Navigate`) mid-word. Both stay discoverable
188 # via F2 (command palette) and F1 (help overlay).
189 Binding("d", "delete_model", "Delete", show=False),
190 Binding("backspace", "delete_model", "Delete", show=False),
191 Binding("x", "delete_model", "Delete", show=False),
192 Binding("i", "show_info", "Info", show=False, group=_ACTION_GROUP),
193 Binding("j", "cursor_down", "Nav", show=False, group=_SCROLL_GROUP),
194 Binding("k", "cursor_up", "Nav", show=False, group=_SCROLL_GROUP),
195 # Arrows move the card cursor too (auto-scrolls into view) so
196 # the highlight follows the visible region. Decoupling them
197 # into pure viewport scroll left a stale highlight on the
198 # previously-focused card.
199 Binding("down", "cursor_down", "Down", show=False, group=_SCROLL_GROUP),
200 Binding("up", "cursor_up", "Up", show=False, group=_SCROLL_GROUP),
201 # priority=True so vim jump-to-top/bottom always wins over the
202 # focused ModelGrid's enter/select binding when keys collide.
203 Binding("g", "jump_top", "Top", show=False, group=_SCROLL_GROUP, priority=True),
204 Binding("G", "jump_bottom", "End", show=False, group=_SCROLL_GROUP, priority=True),
205 Binding("space", "page_down", "PgDn", show=False, group=_SCROLL_GROUP),
206 Binding("ctrl+d", "page_down", "PgDn", show=False, group=_SCROLL_GROUP),
207 Binding("ctrl+u", "page_up", "PgUp", show=False, group=_SCROLL_GROUP),
208 # Hidden from the footer so catalog still has <=5 visible bindings;
209 # the sort-label surfaces "press n for more" and "press s to sort"
210 # to the user instead.
211 Binding("n", "load_more", "More", show=False, group=_ACTION_GROUP),
212 Binding("s", "cycle_sort", "Sort", show=False, group=_ACTION_GROUP),
213 Binding("ctrl+b", "toggle_drawer", "Detail", show=False, group=_ACTION_GROUP),
214 Binding("c", "cycle_source", "Source", show=False, group=_ACTION_GROUP),
215 # Numeric tab shortcuts; 1-6 jump to the corresponding tab in
216 # ALL_TAB_IDS order (Discover, Chat, Embed, Vision, Rerank, Library).
217 # priority=True so they win against any focused-widget binding that
218 # might already grab digits (Textual's Tabs/ContentTabs has its own
219 # numeric handling), and over-the-air shortcut feel matches the plan.
220 Binding("1", "select_tab(0)", "Discover", show=False, priority=True),
221 Binding("2", "select_tab(1)", "Chat", show=False, priority=True),
222 Binding("3", "select_tab(2)", "Embed", show=False, priority=True),
223 Binding("4", "select_tab(3)", "Vision", show=False, priority=True),
224 Binding("5", "select_tab(4)", "Rerank", show=False, priority=True),
225 Binding("6", "select_tab(5)", "Library", show=False, priority=True),
226 # Tab cycling. Hidden from the footer so the catalog row of
227 # bindings doesn't truncate the rightmost item mid-word; the tab
228 # strip at the top already shows the available tabs, and F1 / F2
229 # surface the shortcuts. ctrl+arrow conflicts with macOS desktop-
230 # space shortcuts, hence vim-style angle brackets. priority=True
231 # so the active ModelGrid's own focus cycling doesn't swallow them.
232 Binding("greater_than_sign", "cycle_tab(1)", "Next tab", show=False, priority=True),
233 Binding("less_than_sign", "cycle_tab(-1)", "Prev tab", show=False, priority=True),
234 ]
236 _search_input = getters.query_one("#catalog-search", Input)
238 def __init__(self, *, focus_task: str | None = None) -> None:
239 super().__init__()
240 self._focus_task: str | None = focus_task
241 self._families: list[ModelFamily] = get_families()
242 self._hf_models: list[CatalogModel] = []
243 self._remote_models: list[RemoteModel] = []
244 # Per-task pagination state. Each task tab tracks its own HF offset
245 # and has-more flag so paginating in one tab (e.g. Chat) only fetches
246 # that task's next page; sibling tabs stay untouched.
247 self._hf_offset_by_task: dict[ModelTask, int] = dict.fromkeys(_ALL_TASKS, 0)
248 self._hf_has_more_by_task: dict[ModelTask, bool] = dict.fromkeys(_ALL_TASKS, True)
249 self._hf_fetched_tasks: set[ModelTask] = set()
250 self._rows: list[LocalCatalogRow] = []
251 self._sort_column: str = "Name"
252 self._sort_ascending: bool = True
253 self._pending_delete: str | None = None
254 self._installed_names: set[str] = set()
255 self._grid_view: bool = True
256 self._loading_more: bool = False
257 # Per-tab grid/list cache keys. Each tab tracks its own last-rendered
258 # shape; switching between already-populated tabs is a no-op refresh.
259 self._grid_cache_keys: dict[str, tuple] = {}
260 self._list_cache_keys: dict[str, tuple] = {}
261 self._search_in_flight: bool = False
262 self._frontier_rows: list[FrontierCatalogRow] = []
263 # Bumped on every worker callback so the _all_*_rows caches
264 # invalidate even when collection lengths happen to coincide.
265 self._data_version: int = 0
266 self._family_rows_cache: _RowCacheEntry | None = None
267 self._hf_rows_cache: _RowCacheEntry | None = None
268 self._remote_rows_cache: _RowCacheEntry | None = None
269 self._view_switching: bool = False
270 self._frontier_refresh_timer: Timer | None = None
271 self._search_filter_timer: Timer | None = None
272 self._scroll_prefetch_armed_at: float = 0.0
273 self._spinner_timer: Timer | None = None
274 self._spinner_frame: int = 0
275 # Active-tab cache + per-tab widget memoization. Avoids a second
276 # query_one on every _grid_container / _list_widget access. Default
277 # matches the TabbedContent's initial= value below.
278 self._active_tab_id_cache: str = TAB_CHAT
279 self._tab_grid_cache: dict[str, VerticalScroll] = {}
280 self._tab_list_cache: dict[str, ModelList] = {}
281 # During initial mount Textual fires TabActivated for whichever pane
282 # ends up first in compose order (Discover) before our explicit
283 # call_after_refresh setter activates Chat. Suppressing cache writes
284 # while this flag is False keeps the cache pinned to its TAB_CHAT
285 # __init__ default through the race; user-driven tab switches after
286 # mount flip the flag and re-arm normal cache updates.
287 self._activation_settled: bool = False
288 # Per-tab source mode (local / cloud / both). Defaults to LOCAL on
289 # every task tab so the catalog opens on the same row set the
290 # mega-grid era surfaced; users opt into cloud-mixed views via `c`.
291 self._source_modes: dict[str, SourceMode] = {
292 tab_id: SourceMode.LOCAL for tab_id in TASK_TAB_IDS
293 }
294 # Hardware-fit baseline. Captured once at construction so the
295 # cached row-build path can stamp each row's fit chip without
296 # re-probing on every refresh.
297 self._available_memory_bytes: int | None = available_memory_for_fit()
299 def _grid_for_tab(self, tab_id: str) -> VerticalScroll:
300 """Return (and memoize) the VerticalScroll for *tab_id*.
302 Discover has no grid; falls through to TAB_CHAT so callers that
303 access ``_grid_container`` while Discover is active never crash.
304 Cached references are validated via ``is_running`` so a stale
305 post-remount handle gets refreshed transparently.
306 """
307 target = TAB_CHAT if tab_id == TAB_DISCOVER else tab_id
308 cached = self._tab_grid_cache.get(target)
309 if cached is not None and cached.is_running:
310 return cached
311 container = self.query_one(f"#{_GRID_ID_PREFIX}{target}", VerticalScroll)
312 self._tab_grid_cache[target] = container
313 return container
315 def _list_for_tab(self, tab_id: str) -> ModelList:
316 """Return (and memoize) the ModelList for *tab_id*. Same fallthrough as _grid_for_tab."""
317 target = TAB_CHAT if tab_id == TAB_DISCOVER else tab_id
318 cached = self._tab_list_cache.get(target)
319 if cached is not None and cached.is_running:
320 return cached
321 widget = self.query_one(f"#{_LIST_ID_PREFIX}{target}", ModelList)
322 self._tab_list_cache[target] = widget
323 return widget
325 @property
326 def _grid_container(self) -> VerticalScroll:
327 return self._grid_for_tab(self._active_tab_id_cache)
329 @property
330 def _list_widget(self) -> ModelList:
331 return self._list_for_tab(self._active_tab_id_cache)
333 @property
334 def _search_focused(self) -> bool:
335 """True when the search Input widget owns focus.
337 Used to short-circuit digit / single-character action handlers so the
338 keystroke lands in the search field instead of activating a tab.
339 """
340 return isinstance(self.focused, Input)
342 @property
343 def _filter_open(self) -> bool:
344 """True while the filter Input is revealed, independent of focus."""
345 return not self._search_input.has_class(_HIDDEN_CLASS)
347 def compose(self) -> ComposeResult:
348 from lilbee.cli.tui.widgets.grid_list_toggle import GridListToggle
350 with TopBars():
351 yield ViewTabs()
352 yield Input(
353 placeholder=msg.CATALOG_FILTER_PLACEHOLDER,
354 id="catalog-search",
355 classes=_HIDDEN_CLASS,
356 )
357 with Horizontal(id="catalog-toolbar"):
358 yield GridListToggle()
359 yield Static("", id="sort-label", shrink=True)
360 yield Static("", id="catalog-loading-spinner")
361 # Horizontal split: TabbedContent fills, CatalogDetailDrawer docks
362 # right at fixed width and toggles via the -collapsed class. Each
363 # per-task tab has its own VerticalScroll + ModelList so prefetch
364 # only extends the active tab's grid; the single mega-grid was the
365 # source of cross-section viewport jumps on pagination.
366 with Horizontal(id="catalog-body"):
367 with (
368 Container(id="catalog-tabs-wrap"),
369 TabbedContent(initial=TAB_CHAT, id="catalog-tabs"),
370 ):
371 with TabPane(msg.CATALOG_TAB_DISCOVER, id=TAB_DISCOVER):
372 yield DiscoverRails(id="discover-rails")
373 with TabPane(msg.CATALOG_TAB_CHAT, id=TAB_CHAT):
374 yield VerticalScroll(
375 id=f"{_GRID_ID_PREFIX}{TAB_CHAT}", classes="catalog-grid-pane"
376 )
377 yield ModelList(id=f"{_LIST_ID_PREFIX}{TAB_CHAT}")
378 with TabPane(msg.CATALOG_TAB_EMBED, id=TAB_EMBED):
379 yield VerticalScroll(
380 id=f"{_GRID_ID_PREFIX}{TAB_EMBED}", classes="catalog-grid-pane"
381 )
382 yield ModelList(id=f"{_LIST_ID_PREFIX}{TAB_EMBED}")
383 with TabPane(msg.CATALOG_TAB_VISION, id=TAB_VISION):
384 yield VerticalScroll(
385 id=f"{_GRID_ID_PREFIX}{TAB_VISION}", classes="catalog-grid-pane"
386 )
387 yield ModelList(id=f"{_LIST_ID_PREFIX}{TAB_VISION}")
388 with TabPane(msg.CATALOG_TAB_RERANK, id=TAB_RERANK):
389 yield VerticalScroll(
390 id=f"{_GRID_ID_PREFIX}{TAB_RERANK}", classes="catalog-grid-pane"
391 )
392 yield ModelList(id=f"{_LIST_ID_PREFIX}{TAB_RERANK}")
393 with TabPane(msg.CATALOG_TAB_LIBRARY, id=TAB_LIBRARY):
394 yield VerticalScroll(
395 id=f"{_GRID_ID_PREFIX}{TAB_LIBRARY}", classes="catalog-grid-pane"
396 )
397 yield ModelList(id=f"{_LIST_ID_PREFIX}{TAB_LIBRARY}")
398 yield CatalogDetailDrawer(id="catalog-detail-drawer", classes="-collapsed")
399 with BottomBars():
400 yield TaskBar()
401 yield Footer()
403 def on_mount(self) -> None:
404 self._fetch_installed_names()
405 # Force Chat as the initial active tab. `TabbedContent(initial=...)`
406 # doesn't take effect when panes are added via `with TabPane(...)`
407 # (Textual resolves initial at construction time but the panes mount
408 # after), so we set active explicitly via call_after_refresh so the
409 # TabActivated cascade has already settled before our setter runs.
410 # Chat is the most common landing destination; users opt into
411 # Discover via keyboard shortcut.
412 self.call_after_refresh(self._activate_initial_tab)
413 self.add_class("-grid-view")
415 def _activate_initial_tab(self) -> None:
416 try:
417 tabs = self.query_one("#catalog-tabs", TabbedContent)
418 except Exception:
419 self._activation_settled = True
420 return
421 if self._focus_task is not None:
422 # On-ramp: land directly on the requested task tab.
423 self._active_tab_id_cache = self._focus_task
424 if tabs.active != self._focus_task:
425 tabs.active = self._focus_task
426 elif self._active_tab_id_cache == TAB_CHAT and tabs.active != TAB_CHAT:
427 tabs.active = TAB_CHAT
428 if not self._activation_settled:
429 self._activation_settled = True
430 self.call_after_refresh(self._refresh_grid)
431 self.call_after_refresh(self._initial_focus_first_grid)
432 self._fetch_remote_models()
433 self._fetch_frontier_models()
434 # Eagerly load the HF catalog for the initial chat tab. Sibling
435 # task tabs fetch lazily on first activation (see
436 # `_on_catalog_tab_activated`) so opening the catalog only costs
437 # one HF round-trip instead of four.
438 self._ensure_task_initial_fetch(ModelTask.CHAT)
439 self.app.provider_availability_changed_signal.subscribe(
440 self, self._on_provider_availability_changed
441 )
442 # Auto-load more HF rows when scrolled near the bottom in either view.
443 # Watch every per-task tab's container plus the Library container.
444 # Inactive tabs never scroll, so the handler runs only for the active
445 # tab; this is cheaper than tearing down and re-installing the watch
446 # on every tab activation.
447 for tab_id in (*TASK_TAB_IDS, TAB_LIBRARY):
448 with contextlib.suppress(Exception):
449 self.watch(
450 self._list_for_tab(tab_id), "scroll_y", self._on_list_scrolled, init=False
451 )
452 self.watch(
453 self._grid_for_tab(tab_id), "scroll_y", self._on_grid_scrolled, init=False
454 )
456 def on_unmount(self) -> None:
457 with contextlib.suppress(Exception):
458 self.app.provider_availability_changed_signal.unsubscribe(self)
459 self._stop_spinner_timer()
461 def on_screen_suspend(self) -> None:
462 """Pause the spinner timer while the screen is offscreen.
464 Without this the 100 ms braille tick keeps firing for the full
465 TUI session even when the catalog is not visible, costing ~4%
466 of main-thread CPU forever.
467 """
468 self._stop_spinner_timer()
470 def on_screen_resume(self) -> None:
471 """Re-arm the spinner only if a fetch is still in flight."""
472 if self._loading_more or self._search_in_flight:
473 self._sync_loading_spinner()
475 def _stop_spinner_timer(self) -> None:
476 if self._spinner_timer is not None:
477 self._spinner_timer.stop()
478 self._spinner_timer = None
480 _FRONTIER_REFRESH_DEBOUNCE = 1.0
482 def _on_provider_availability_changed(self, _payload: tuple[str, object]) -> None:
483 """Debounced refetch of frontier rows when an API key changes."""
484 if self._frontier_refresh_timer is not None:
485 self._frontier_refresh_timer.stop()
486 self._frontier_refresh_timer = self.set_timer(
487 self._FRONTIER_REFRESH_DEBOUNCE, self._fetch_frontier_models
488 )
490 def _focus_first_grid(self) -> None:
491 """Focus the first grid widget in the active tab's container."""
492 for cls in (ModelGrid, GridSelect):
493 with contextlib.suppress(Exception):
494 self._grid_container.query(cls).first().focus()
495 return
497 def _initial_focus_first_grid(self) -> None:
498 """on_mount initial focus: skip if a later refresh-tick has already
499 landed focus elsewhere (e.g. a test focused #catalog-search before
500 the streaming-section mount drained its scheduled callbacks)."""
501 if self.focused is not None:
502 return
503 self._focus_first_grid()
505 def _fetch_installed_names(self) -> None:
506 """Populate installed identities from the shared ModelManager cache.
508 The set contains both the canonical ref (``hf_repo/filename``) and
509 the bare ``hf_repo`` so catalog rows whose ref is the repo alone
510 still light up as installed when at least one quant of that repo
511 has a manifest.
512 """
513 with contextlib.suppress(Exception):
514 self._installed_names = set(get_services().model_manager.list_native_identities())
515 self._data_version += 1
517 def _active_tab_id(self) -> str:
518 """Return the cached active tab id; falls back to TAB_CHAT pre-mount.
520 The cache is updated by ``_on_catalog_tab_activated`` so this is a
521 bare attribute read, not a DOM walk. Prefer this over a fresh
522 ``TabbedContent.active`` lookup on every check.
523 """
524 return self._active_tab_id_cache
526 def _active_task(self) -> ModelTask | None:
527 """Return the active tab's task, or None on Discover / Library."""
528 return TAB_ID_TO_TASK.get(self._active_tab_id())
530 def _active_task_has_more(self) -> bool:
531 """True iff the active task tab has another HF page available.
533 Discover and Library tabs return False; neither paginates.
534 """
535 task = self._active_task()
536 if task is None:
537 return False
538 return self._hf_has_more_by_task.get(task, False)
540 def _hf_fetched_any(self) -> bool:
541 """True iff any task has had its first HF page fetched.
543 Renders gate HF sections on this so the catalog doesn't paint
544 empty HF rows before the first per-task fetch lands.
545 """
546 return bool(self._hf_fetched_tasks)
548 def _ensure_task_initial_fetch(self, task: ModelTask) -> None:
549 """Fire the per-task initial HF fetch once; idempotent on repeats."""
550 if task in self._hf_fetched_tasks:
551 return
552 self._hf_fetched_tasks.add(task)
553 self._fetch_initial_hf_models_for_task(task)
555 def action_toggle_view(self) -> None:
556 """Toggle between grid and list view on the active task tab.
558 Mid-toggle re-entry would tear the DOM (one toggle's mount_all
559 running while the previous toggle's remove_children is still in
560 flight). The _view_switching gate makes the toggle atomic.
561 Discover and Library tabs don't expose the toggle.
562 """
563 if self._active_tab_id() not in TASK_TAB_IDS:
564 return
565 if self._view_switching:
566 return
567 self._view_switching = True
568 try:
569 if self._grid_view:
570 self._grid_view = False
571 self.remove_class("-grid-view")
572 self.add_class("-list-view")
573 active_task = TAB_ID_TO_TASK.get(self._active_tab_id())
574 if active_task is not None:
575 self._ensure_task_initial_fetch(active_task)
576 with self.app.batch_update():
577 self._refresh_list()
578 self._focus_list_item(0)
579 else:
580 self._grid_view = True
581 self.remove_class("-list-view")
582 self.add_class("-grid-view")
583 with self.app.batch_update():
584 self._refresh_grid()
585 with contextlib.suppress(Exception):
586 self._grid_container.query_one(ModelGrid).focus()
587 finally:
588 self._view_switching = False
589 self._sync_grid_list_toggle()
591 def _sync_grid_list_toggle(self) -> None:
592 from lilbee.cli.tui.widgets.grid_list_toggle import GridListToggle
594 with contextlib.suppress(Exception):
595 self.query_one(GridListToggle).set_grid(self._grid_view)
597 def action_focus_search(self) -> None:
598 """Reveal and focus the filter input. Bound to / key."""
599 self._search_input.remove_class(_HIDDEN_CLASS)
600 self._search_input.focus()
602 _SEARCH_FILTER_DEBOUNCE_SECONDS = 0.08
604 @on(Input.Changed, "#catalog-search")
605 def _on_search_changed(self, event: Input.Changed) -> None:
606 """Schedule a filter pass after a short debounce.
608 Each keystroke triggers a grid re-render or a list redraw, both of
609 which Textual treats as layout invalidations. Without the debounce
610 a 5-char term produces 5 full passes; with it, typing collapses
611 to a single pass once the user pauses.
612 """
613 if self._search_filter_timer is not None:
614 self._search_filter_timer.stop()
615 self._search_filter_timer = self.set_timer(
616 self._SEARCH_FILTER_DEBOUNCE_SECONDS,
617 self._apply_search_filter,
618 )
620 def _apply_search_filter(self) -> None:
621 if self._active_tab_id() == TAB_LIBRARY:
622 self._populate_library_list()
623 return
624 if self._active_tab_id() == TAB_DISCOVER:
625 return
626 if self._grid_view:
627 self._filter_grid()
628 else:
629 self._filter_list()
631 @on(Input.Submitted, "#catalog-search")
632 def _on_search_submitted(self, event: Input.Submitted) -> None:
633 """Enter installs the first visible match; falls through to a remote
634 HF search when nothing matches locally."""
635 if self._grid_view:
636 if any(grid.rows for grid in self._grid_container.query(ModelGrid)):
637 self._select_first_visible_grid_card()
638 return
639 elif self._list_widget.option_count:
640 self._select_first_visible_list_item()
641 return
642 self._trigger_remote_search(self._get_search_text())
644 def _trigger_remote_search(self, query: str) -> None:
645 """Fire the HF search worker for the active task, unless one is in flight.
647 Search is task-scoped so typing on the Chat tab only surfaces chat
648 models; embedding/vision/rerank rows can never leak into the active
649 list. Non-task tabs (Discover/Library) can't reach this path because
650 the search Input is hidden on them.
651 """
652 if self._search_in_flight or not query:
653 return
654 active_task = TAB_ID_TO_TASK.get(self._active_tab_id())
655 if active_task is None:
656 return
657 self._search_in_flight = True
658 self._update_sort_label()
659 self._sync_loading_spinner()
660 # Sort label is hidden in grid view, so the toast is the only feedback there.
661 self.notify(msg.CATALOG_SEARCHING_HF, timeout=_NOTIFY_SEARCHING_TIMEOUT_SECONDS)
662 self._fetch_hf_search(query, active_task)
664 @on(Click, ".search-hf-cta")
665 def _on_search_hf_cta_clicked(self) -> None:
666 self._trigger_remote_search(self._get_search_text())
668 def _select_first_visible_grid_card(self) -> None:
669 """Focus the first grid with a visible match and trigger its install.
671 Without the "first visible" walk, focusing any grid with
672 ``highlighted = 0`` could land on a card the filter just hid,
673 and Enter would install the wrong model. Setting
674 ``highlighted`` to the first visible index guarantees the
675 install fires on what the user can actually see.
676 """
677 with contextlib.suppress(Exception):
678 for grid in self._grid_container.query(ModelGrid):
679 if grid.rows:
680 grid.focus()
681 grid.highlighted = 0
682 grid.action_select()
683 return
685 def _select_first_visible_list_item(self) -> None:
686 """List-view counterpart: highlight + select the first row."""
687 with contextlib.suppress(Exception):
688 if self._list_widget.option_count:
689 self._list_widget.highlighted = 0
690 self._list_widget.focus()
691 self._list_widget.action_select()
693 def _fetch_hf_page_for_task(self, task: ModelTask) -> list[CatalogModel]:
694 """Fetch one HF page for *task* at the task's own offset.
696 Dedupes against repos already in ``self._hf_models`` so re-fetches
697 from a stale offset don't double-count rows. Writes the per-task
698 ``has_more`` directly on the screen from the worker thread; the
699 dict assignment is GIL-atomic and the main thread only reads.
700 """
701 offset = self._hf_offset_by_task[task]
702 result = get_catalog(
703 task=task,
704 featured=False,
705 limit=_HF_PAGE_SIZE,
706 offset=offset,
707 )
708 self._hf_has_more_by_task[task] = result.has_more
709 existing_repos = {m.hf_repo for m in self._hf_models}
710 return [m for m in result.models if not m.featured and m.hf_repo not in existing_repos]
712 @work(thread=True, name=_WORKER_FETCH_HF)
713 def _fetch_initial_hf_models_for_task(self, task: ModelTask) -> list[CatalogModel]:
714 """Fetch the first HF page for *task* (extends the merged store)."""
715 return self._fetch_hf_page_for_task(task)
717 @work(thread=True, name=_WORKER_FETCH_REMOTE)
718 def _fetch_remote_models(self) -> list[RemoteModel]:
719 return classify_all_remote_models()
721 @work(thread=True, name=_WORKER_FETCH_FRONTIER, exit_on_error=False)
722 def _fetch_frontier_models(self) -> list[FrontierCatalogRow]:
723 """Discover cloud chat models off the UI thread.
725 ``discover_api_models`` imports litellm (heavy, >50ms) and probes
726 every provider key, totaling several hundred ms even when no
727 keys are set. Running it on the main thread froze the catalog
728 on mount and on every signal-driven refresh; the worker keeps
729 the screen responsive."""
730 from lilbee.modelhub.model_manager import discover_api_models
732 try:
733 groups = discover_api_models()
734 except Exception:
735 log.debug("discover_api_models failed in worker", exc_info=True)
736 return []
738 rows: list[FrontierCatalogRow] = []
739 for display_name, models in groups.items():
740 provider_id = display_name.lower()
741 has_key = get_provider_api_key(provider_id) is not None
742 status = KeyStatus.READY if has_key else KeyStatus.MISSING_KEY
743 for rm in models:
744 rows.append(
745 frontier_row_from_remote(rm, provider_id=provider_id, key_status=status)
746 )
747 rows.sort(key=lambda r: (r.provider, r.name.lower()))
748 return rows
750 @work(thread=True, name=_WORKER_FETCH_MORE_HF)
751 def _fetch_more_hf_for_task(self, task: ModelTask) -> list[CatalogModel]:
752 """Fetch the next HF page for *task* (extends the merged store)."""
753 return self._fetch_hf_page_for_task(task)
755 @work(thread=True, name=_WORKER_FETCH_SEARCH, exit_on_error=False)
756 def _fetch_hf_search(self, query: str, task: ModelTask) -> list[CatalogModel]:
757 """Fetch HF models matching *query* for *task* only (worker thread)."""
758 existing_repos = {m.hf_repo for m in self._hf_models}
759 result = get_catalog(
760 task=task,
761 featured=False,
762 search=query,
763 limit=_HF_PAGE_SIZE,
764 offset=0,
765 )
766 return [m for m in result.models if not m.featured and m.hf_repo not in existing_repos]
768 def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
769 # PENDING/RUNNING fire here too; only ERROR/CANCELLED should release latches.
770 if event.state in (WorkerState.ERROR, WorkerState.CANCELLED):
771 self._handle_worker_error_or_cancel(event.worker.name)
772 return
773 if event.state != WorkerState.SUCCESS:
774 return
775 result = event.worker.result
776 if not isinstance(result, list):
777 return
778 worker_name = event.worker.name
779 if not self._apply_worker_result(worker_name, result):
780 return
781 # A fast worker can complete before TabbedContent finishes mounting
782 # its panes; tolerate that and let the deferred _refresh_grid that
783 # _activate_initial_tab schedules rebuild against the applied state.
784 from textual.css.query import NoMatches
786 with contextlib.suppress(NoMatches):
787 # FETCH_MORE_HF appends to the active view's tail; skip the full
788 # _refresh_view rebuild so scroll position and focus are preserved.
789 if worker_name == _WORKER_FETCH_MORE_HF:
790 if self._grid_view:
791 self._refresh_grid()
792 else:
793 self._append_more_hf_to_list(result)
794 return
795 self._refresh_view()
797 def _append_more_hf_to_list(self, new_models: list[CatalogModel]) -> None:
798 """Append newly-arrived HF rows to the active task tab's list.
800 Falls back to a full ``_refresh_view`` on the rare tab-switch
801 race where the worker's payload no longer matches the active
802 task; otherwise a blind extend would leak foreign rows into a
803 sibling tab's list.
804 """
805 active_task = self._active_task()
806 if active_task is None or any(m.task != active_task for m in new_models):
807 self._refresh_view()
808 return
809 new_rows = self._sort_rows(
810 [
811 catalog_to_row(m, installed=self._is_installed(m.ref, m.hf_repo, m.gguf_filename))
812 for m in new_models
813 ]
814 )
815 if not new_rows:
816 self._update_sort_label()
817 return
818 self._rows.extend(new_rows)
819 self._list_widget.append_rows(list(new_rows))
820 self._list_cache_key = (
821 tuple((r.name, r.installed) for r in self._rows),
822 self._get_search_text(),
823 )
824 self._update_sort_label()
826 def _handle_worker_error_or_cancel(self, name: str) -> None:
827 if name == _WORKER_FETCH_MORE_HF:
828 self._loading_more = False
829 if name == _WORKER_FETCH_SEARCH:
830 self._search_in_flight = False
831 self._update_sort_label()
832 self._sync_loading_spinner()
834 def _apply_worker_result(self, name: str, result: list) -> bool:
835 """Land worker results into the screen's caches.
837 Returns True when the screen should refresh its view, False when
838 the worker name is unrecognized (defensive: a future @work
839 decorator name won't silently rebuild the grid)."""
840 if name == _WORKER_FETCH_HF:
841 # Per-task initial fetches all share this worker name; each
842 # one carries dedup-filtered new rows (see
843 # ``_fetch_hf_page_for_task``) so extend is correct here.
844 self._hf_models.extend(result)
845 self._loading_more = False
846 elif name == _WORKER_FETCH_MORE_HF:
847 self._hf_models.extend(result)
848 self._loading_more = False
849 elif name == _WORKER_FETCH_SEARCH:
850 self._hf_models.extend(result)
851 self._search_in_flight = False
852 self._update_sort_label()
853 elif name == _WORKER_FETCH_REMOTE:
854 self._remote_models = result
855 elif name == _WORKER_FETCH_FRONTIER:
856 self._frontier_rows = result
857 self._populate_library_list()
858 else:
859 return False
860 self._data_version += 1
861 self._sync_loading_spinner()
862 # If the user is parked on Discover, re-populate the rails so the
863 # Fresh-on-the-Hub strip fills as HF rows arrive. Without this the
864 # rail stays empty for the lifetime of the Discover view because
865 # _populate_discover_rails fires only on tab activation.
866 if self._active_tab_id_cache == TAB_DISCOVER:
867 self._populate_discover_rails()
868 return True
870 def _populate_library_list(self) -> None:
871 """Render the Library tab: installed local + activated cloud APIs in both views."""
872 search = self._get_search_text()
873 installed_rows: list[LocalCatalogRow] = []
874 for source in (self._all_family_rows, self._all_hf_rows, self._all_remote_rows):
875 with contextlib.suppress(AttributeError):
876 installed_rows.extend(r for r in source() if r.installed)
877 if search:
878 installed_rows = [r for r in installed_rows if matches_search(r, search)]
879 frontier: list[FrontierCatalogRow] = []
880 with contextlib.suppress(AttributeError):
881 frontier = self._build_frontier_rows(search)
882 self._render_library_list(installed_rows, frontier)
883 self._render_library_grid(installed_rows, frontier)
885 def _render_library_list(
886 self,
887 installed_rows: list[LocalCatalogRow],
888 frontier: list[FrontierCatalogRow],
889 ) -> None:
890 try:
891 ml = self._list_for_tab(TAB_LIBRARY)
892 except Exception:
893 return
894 sections: list[ModelListSection] = []
895 if installed_rows:
896 sections.append(
897 ModelListSection(heading=msg.HEADING_INSTALLED, rows=list(installed_rows))
898 )
899 sections.extend(group_frontier_rows(frontier))
900 ml.set_rows(sections)
902 def _render_library_grid(
903 self,
904 installed_rows: list[LocalCatalogRow],
905 frontier: list[FrontierCatalogRow],
906 ) -> None:
907 try:
908 container = self._grid_for_tab(TAB_LIBRARY)
909 except Exception:
910 return
911 sections: list[GridSection] = []
912 if installed_rows:
913 sections.append(GridSection(heading=msg.HEADING_INSTALLED, rows=list(installed_rows)))
914 if frontier:
915 sections.append(GridSection(heading="Cloud", rows=list(frontier)))
916 existing_grids = list(container.query(ModelGrid))
917 existing_headings = [
918 w for w in container.query(".section-heading") if isinstance(w, Static)
919 ]
920 if existing_grids and len(existing_grids) == len(sections):
921 for grid, heading, section in zip(
922 existing_grids, existing_headings, sections, strict=False
923 ):
924 heading.update(section.heading)
925 grid.set_rows(section.rows)
926 return
927 container.remove_children()
928 for section in sections:
929 container.mount_all(
930 [
931 Static(section.heading, classes="section-heading"),
932 ModelGrid(section.rows, name=section.heading, classes="catalog-section"),
933 ]
934 )
936 def _get_search_text(self) -> str:
937 # Deferred refresh callbacks can land while the screen is between
938 # mount cycles (e.g. switch_view chaining); the descriptor query
939 # would otherwise raise NoMatches and crash the callback.
940 try:
941 return self._search_input.value.strip()
942 except Exception:
943 return ""
945 def _local_rows_data_key(self) -> _RowCacheKey:
946 """Cache key over the inputs that drive row construction.
948 ``_data_version`` covers replacements and extensions both;
949 search text deliberately omitted (we filter cached rows).
950 """
951 return (
952 len(self._families),
953 len(self._hf_models),
954 len(self._remote_models),
955 len(self._hf_fetched_tasks),
956 len(self._installed_names),
957 self._data_version,
958 )
960 def _all_family_rows(self) -> list[LocalCatalogRow]:
961 """One row per featured family, aggregating its quants into size_variants.
963 The mega-grid era emitted one row per ``ModelVariant``; the same
964 family showed up three or four times stacked next to each other,
965 once per quant. The redesign collapses each family into a single
966 card whose ``size_variants`` strip carries every quant. Primary
967 variant (recommended; otherwise the smallest) drives the card's
968 primary metadata + fit chip; the strip lets users pick a
969 non-primary size without leaving the grid.
970 """
971 key = self._local_rows_data_key()
972 cached = self._family_rows_cache
973 if cached is not None and cached.key == key:
974 return cached.rows
975 rows: list[LocalCatalogRow] = []
976 for fam in self._families:
977 if not fam.variants:
978 continue
979 primary = next(
980 (v for v in fam.variants if v.recommended),
981 min(fam.variants, key=lambda v: v.size_mb),
982 )
983 family_installed = any(
984 self._is_installed(v.hf_repo, repo=v.hf_repo, filename=v.filename)
985 for v in fam.variants
986 )
987 row = variant_to_row(primary, fam, family_installed)
988 row.size_variants = family_to_size_variants(fam)
989 rows.append(row)
990 self._stamp_fit(rows)
991 self._family_rows_cache = _RowCacheEntry(key=key, rows=rows)
992 return rows
994 def _all_hf_rows(self) -> list[LocalCatalogRow]:
995 key = self._local_rows_data_key()
996 cached = self._hf_rows_cache
997 if cached is not None and cached.key == key:
998 return cached.rows
999 rows: list[LocalCatalogRow] = []
1000 for m in self._hf_models:
1001 installed = self._is_installed(m.ref, repo=m.hf_repo, filename=m.gguf_filename)
1002 rows.append(catalog_to_row(m, installed))
1003 self._stamp_fit(rows)
1004 self._hf_rows_cache = _RowCacheEntry(key=key, rows=rows)
1005 return rows
1007 def _all_remote_rows(self) -> list[LocalCatalogRow]:
1008 key = self._local_rows_data_key()
1009 cached = self._remote_rows_cache
1010 if cached is not None and cached.key == key:
1011 return cached.rows
1012 rows = [remote_to_row(rm) for rm in self._remote_models]
1013 # Remote rows don't carry a known size; _stamp_fit no-ops on those.
1014 self._stamp_fit(rows)
1015 self._remote_rows_cache = _RowCacheEntry(key=key, rows=rows)
1016 return rows
1018 def _stamp_fit(self, rows: list[LocalCatalogRow]) -> None:
1019 """Stamp each row's hardware-fit chip in place.
1021 Runs only inside the cached row builders, so this is one pass per
1022 data refresh, not per render. Rows whose ``sort_size`` is zero
1023 (remote / unknown size) leave ``fit`` as ``None`` and the card
1024 renderer omits the chip. Available-memory probe is captured once
1025 at __init__; if the probe failed, every row falls through chip-less.
1026 """
1027 if self._available_memory_bytes is None:
1028 return
1029 bytes_per_gb = 1024**3
1030 for row in rows:
1031 if row.sort_size <= 0:
1032 continue
1033 row.fit = compute_fit(
1034 model_size_bytes=int(row.sort_size * bytes_per_gb),
1035 available_bytes=self._available_memory_bytes,
1036 )
1038 def _build_rows(self) -> list[LocalCatalogRow]:
1039 """Build filtered table rows from current data sources."""
1040 search = self._get_search_text()
1041 rows: list[LocalCatalogRow] = []
1042 rows.extend(self._build_family_rows(search))
1043 rows.extend(self._build_hf_rows(search))
1044 rows.extend(self._build_remote_rows(search))
1045 return rows
1047 def _build_family_rows(self, search: str) -> list[LocalCatalogRow]:
1048 """Filter the cached family rows against the active search."""
1049 if not search:
1050 return self._all_family_rows()
1051 return [r for r in self._all_family_rows() if matches_search(r, search)]
1053 def _build_hf_rows(self, search: str) -> list[LocalCatalogRow]:
1054 """Filter the cached HF rows against the active search."""
1055 if not search:
1056 return self._all_hf_rows()
1057 return [r for r in self._all_hf_rows() if matches_search(r, search)]
1059 def _build_remote_rows(self, search: str) -> list[LocalCatalogRow]:
1060 """Filter the cached remote rows against the active search."""
1061 if not search:
1062 return self._all_remote_rows()
1063 return [r for r in self._all_remote_rows() if matches_search(r, search)]
1065 def _build_frontier_rows(self, search: str) -> list[FrontierCatalogRow]:
1066 """Filter the cached frontier rows against the active search.
1068 The discovery itself runs in :meth:`_fetch_frontier_models` (a
1069 worker) because litellm import + key probing blocks the UI
1070 thread. Renderers call this synchronously to read the
1071 already-discovered rows, so no I/O happens here.
1072 """
1073 if not self._frontier_rows:
1074 return []
1075 return [row for row in self._frontier_rows if matches_search(row, search)]
1077 def _is_installed(self, name: str, repo: str = "", filename: str = "") -> bool:
1078 """Check if a model is installed by name or source repo/filename."""
1079 if name in self._installed_names:
1080 return True
1081 if repo and filename:
1082 return f"{repo}/{filename}" in self._installed_names
1083 return False
1085 def _sort_rows(self, rows: list[LocalCatalogRow]) -> list[LocalCatalogRow]:
1086 """Sort rows: featured first, then by current sort column."""
1087 key_fn = SORT_KEYS.get(self._sort_column, SORT_KEYS["Name"])
1088 # Stable sort: featured always first, then by column
1089 return sorted(
1090 rows,
1091 key=lambda r: (not r.featured, key_fn(r)),
1092 reverse=not self._sort_ascending,
1093 )
1095 def _refresh_view(self) -> None:
1096 """Refresh the active view (grid or list).
1098 Mount/remove of dozens of widgets is wrapped in batch_update so
1099 Textual coalesces layout passes; without it, the worker callback
1100 path can land inside an in-flight grid-list toggle and tear the
1101 DOM."""
1102 with self.app.batch_update():
1103 if self._grid_view:
1104 self._refresh_grid()
1105 else:
1106 self._refresh_list()
1108 def _refresh_grid(self) -> None:
1109 """Rebuild grid view; extend in-place when sections already mounted.
1111 Initial paint mounts everything (first time a tab is opened).
1112 Subsequent dataset updates (HF pagination, sort change, filter)
1113 update each existing ModelGrid via set_rows rather than tearing
1114 the container down and re-mounting from scratch. Avoids a 100%
1115 CPU spike on every "Browse more" return.
1116 """
1117 prep = self._prepare_grid_refresh()
1118 if prep is None:
1119 self._update_sort_label()
1120 return
1121 sections, hf_count = prep
1122 if not sections:
1123 self._grid_container.remove_children()
1124 self._mount_grid_ctas(hf_count=hf_count)
1125 self._update_sort_label()
1126 return
1127 if self._extend_grid_sections_in_place(sections, hf_count):
1128 return
1129 self._remount_grid_sections(sections, hf_count)
1130 self._update_sort_label()
1132 def _prepare_grid_refresh(self) -> tuple[list[GridSection], int] | None:
1133 """Build sections + cache them. Returns None when the cache is hot.
1135 On the None branch the caller refreshes the sort label so the
1136 cached path still picks up sort-toggle clicks.
1137 """
1138 search = self._get_search_text()
1139 family_rows = self._build_family_rows(search)
1140 remote_rows = self._build_remote_rows(search)
1141 hf_rows = self._build_hf_rows(search) if self._hf_fetched_any() else []
1142 all_rows = family_rows + remote_rows + hf_rows
1143 active_tab = self._active_tab_id_cache
1144 tab_rows = self._rows_for_active_tab(all_rows, active_tab)
1145 # Keep self._rows in sync (locals-only) so the toolbar sort-label
1146 # can render "{n} loaded" whichever view (grid or list) is active.
1147 # Frontier rows render in their own Cloud section but don't count
1148 # toward the local-row tally.
1149 local_tab_rows: list[LocalCatalogRow] = [
1150 r for r in tab_rows if r.kind == CatalogRowKind.LOCAL
1151 ]
1152 self._rows = local_tab_rows
1153 row_key = (
1154 tuple(row_cache_signature(r) for r in tab_rows),
1155 search,
1156 )
1157 # Per-tab cache key: switching back to an already-rendered tab
1158 # is a no-op refresh; only sort-label refreshes. Keyed by
1159 # active_tab so other tabs' caches survive in-place.
1160 if self._grid_cache_keys.get(active_tab) == row_key:
1161 return None
1162 self._grid_cache_keys[active_tab] = row_key
1163 if active_tab in TASK_TAB_IDS:
1164 active_task = TAB_ID_TO_TASK[active_tab]
1165 task_label = active_task.value.capitalize()
1166 # Split locals and frontier so the picks/installed grouping
1167 # only sees LocalCatalogRow (it reads .featured / .installed
1168 # which FrontierCatalogRow doesn't carry). Frontier rows land
1169 # under their own "Cloud" section appended below.
1170 frontier_only = [r for r in tab_rows if r.kind == CatalogRowKind.FRONTIER]
1171 sections = [s for s in group_task_rows_with_picks(local_tab_rows, task_label) if s.rows]
1172 if frontier_only:
1173 sections.append(GridSection(heading="Cloud", rows=list(frontier_only)))
1174 hf_count = sum(1 for r in hf_rows if r.task == active_task.value)
1175 else:
1176 sections = [s for s in group_rows_for_grid(local_tab_rows) if s.rows]
1177 hf_count = len(hf_rows)
1178 return sections, hf_count
1180 def _extend_grid_sections_in_place(self, sections: list[GridSection], hf_count: int) -> bool:
1181 """Update existing ModelGrids in place when section count matches.
1183 Returns True iff the in-place path applied; the caller falls
1184 through to a teardown + remount on False.
1185 """
1186 existing_grids = list(self._grid_container.query(ModelGrid))
1187 existing_headings = [
1188 w for w in self._grid_container.query(".section-heading") if isinstance(w, Static)
1189 ]
1190 if not existing_grids or len(existing_grids) != len(sections):
1191 return False
1192 # Heading + grid mounts each compose on their own frame, so a
1193 # partially-mounted state can land here with the heading list
1194 # one short of the grid list. Drop strict=True so we cleanly
1195 # update whatever pairs we have without forcing a full remount.
1196 for grid, heading, section in zip(
1197 existing_grids, existing_headings, sections, strict=False
1198 ):
1199 heading.update(section.heading)
1200 grid.set_rows(section.rows)
1201 self._refresh_grid_ctas(hf_count=hf_count)
1202 self._update_sort_label()
1203 return True
1205 def _remount_grid_sections(self, sections: list[GridSection], hf_count: int) -> None:
1206 """Teardown + remount the grid for a section-count change.
1208 Captures the user's current cursor + scroll position before the
1209 teardown so both can be restored after remount; otherwise the
1210 ``_focus_first_grid`` fallback snaps the cursor back to the top
1211 of the catalog mid-keypress, and the layout shift from extra
1212 sections drifts the visible window away from where the user was
1213 looking.
1214 """
1215 focus_anchor = self._capture_focused_section()
1216 container = self._grid_container
1217 prior_scroll_y = container.scroll_y
1218 container.remove_children()
1219 self._mount_grid_section(sections[0])
1220 self.call_after_refresh(
1221 self._mount_remaining_grid_sections,
1222 sections[1:],
1223 hf_count=hf_count,
1224 focus_anchor=focus_anchor,
1225 prior_scroll_y=prior_scroll_y,
1226 )
1228 def _capture_focused_section(self) -> tuple[str, int | None] | None:
1229 """Return ``(heading, highlighted_index)`` for the focused grid.
1231 Heading is read from ``ModelGrid.name`` (set by
1232 ``_mount_grid_section``). Used to restore the cursor across a
1233 teardown+remount in ``_refresh_grid`` so paginated loads don't
1234 yank the user back to the top of the catalog.
1235 """
1236 focused = self._focused_grid()
1237 if not isinstance(focused, ModelGrid) or focused.name is None:
1238 return None
1239 return (focused.name, focused.highlighted)
1241 def _restore_focused_section(self, anchor: tuple[str, int | None] | None) -> bool:
1242 """Refocus the grid whose ``name`` matches the captured anchor.
1244 Returns True when the previous focus position was successfully
1245 restored; False when no anchor was given or the matching section
1246 no longer exists (caller falls back to ``_focus_first_grid``).
1247 """
1248 if anchor is None:
1249 return False
1250 target_heading, target_highlighted = anchor
1251 for grid in self._grid_container.query(ModelGrid):
1252 if grid.name != target_heading:
1253 continue
1254 grid.focus()
1255 if target_highlighted is not None and grid.rows:
1256 grid.highlighted = min(target_highlighted, len(grid.rows) - 1)
1257 return True
1258 return False
1260 def _mount_grid_section(self, section: GridSection) -> None:
1261 # ``name=section.heading`` doubles as the section identity used by
1262 # ``_capture_focused_section`` / ``_restore_focused_section`` to
1263 # preserve the cursor across teardown + remount.
1264 grid = ModelGrid(section.rows, name=section.heading, classes="catalog-section")
1265 self._grid_container.mount_all(
1266 [
1267 Static(section.heading, classes="section-heading"),
1268 grid,
1269 ]
1270 )
1272 def _mount_remaining_grid_sections(
1273 self,
1274 remaining: list[GridSection],
1275 hf_count: int,
1276 focus_anchor: tuple[str, int | None] | None = None,
1277 prior_scroll_y: float = 0.0,
1278 ) -> None:
1279 for section in remaining:
1280 self._mount_grid_section(section)
1281 self._mount_grid_ctas(hf_count=hf_count)
1282 # Restore the prior viewport position; mounting fresh sections shifts
1283 # the layout and ``focus()`` below would otherwise overshoot.
1284 if prior_scroll_y:
1285 self._grid_container.scroll_to(y=prior_scroll_y, animate=False)
1286 # Lock focus onto a grid once mount completes so j / k / PgDn /
1287 # PgUp dispatch correctly. Without this, on first paint the focus
1288 # race can leave nothing focused and the catalog feels frozen
1289 # until the user toggles to list view and back. When the previous
1290 # paint had a focused grid, restore the cursor to the same
1291 # section + highlighted index instead of jumping to the top.
1292 if not self._grid_view or self._focused_grid() is not None:
1293 return
1294 if self._restore_focused_section(focus_anchor):
1295 return
1296 self._focus_first_grid()
1298 def _grid_scroll_hint_text(self, hf_count: int) -> str:
1299 """Pick the bottom scroll-hint text based on fetch state."""
1300 if self._loading_more:
1301 return msg.CATALOG_GRID_LOADING_MORE.format(frame=_SPINNER_FRAMES[self._spinner_frame])
1302 if self._active_task_has_more():
1303 return msg.CATALOG_GRID_LOAD_MORE.format(count=hf_count)
1304 return msg.CATALOG_GRID_ALL_LOADED.format(count=hf_count)
1306 def _mount_grid_ctas(self, *, hf_count: int) -> None:
1307 try:
1308 container = self._grid_container
1309 except Exception:
1310 return
1311 ctas: list[Static] = [
1312 Static(
1313 self._grid_scroll_hint_text(hf_count),
1314 classes="grid-cta scroll-hint",
1315 )
1316 ]
1317 search = self._get_search_text()
1318 if search:
1319 ctas.append(
1320 Static(
1321 msg.CATALOG_SEARCH_HF_CTA.format(query=search),
1322 classes="grid-cta search-hf-cta",
1323 )
1324 )
1325 container.mount_all(ctas)
1327 def _refresh_grid_ctas(self, *, hf_count: int) -> None:
1328 """Update the bottom CTA strip in place; remount when class changes."""
1329 try:
1330 container = self._grid_container
1331 except Exception:
1332 return
1333 existing = list(container.query(".grid-cta"))
1334 for w in existing:
1335 with contextlib.suppress(Exception):
1336 w.remove()
1337 self._mount_grid_ctas(hf_count=hf_count)
1339 def _rows_for_active_tab(
1340 self, all_rows: list[LocalCatalogRow], active_tab: str
1341 ) -> list[CatalogRow]:
1342 """Slice the source row list for what the active task tab should render.
1344 Library/Discover bypass this (their refresh paths build their own
1345 slices). For task tabs, returns rows for the matching ModelTask
1346 further filtered by the per-tab SourceMode chip; CLOUD and BOTH
1347 also union the matching frontier rows.
1348 """
1349 if active_tab not in TASK_TAB_IDS:
1350 return list(all_rows)
1351 active_task = TAB_ID_TO_TASK[active_tab]
1352 mode = self._source_modes.get(active_tab, SourceMode.LOCAL)
1353 local_for_task: list[CatalogRow] = []
1354 if mode is not SourceMode.CLOUD:
1355 local_for_task = [r for r in all_rows if r.task == active_task.value]
1356 frontier_for_task: list[CatalogRow] = []
1357 if mode is not SourceMode.LOCAL:
1358 frontier_for_task = [r for r in self._frontier_rows if r.task == active_task.value]
1359 return local_for_task + frontier_for_task
1361 def _filter_grid(self) -> None:
1362 """Re-render the grid with the current filter applied via _refresh_grid."""
1363 self._refresh_grid()
1365 @on(ModelGrid.Highlighted)
1366 def _on_grid_highlighted(self, event: ModelGrid.Highlighted) -> None:
1367 """Run keyboard-driven prefetch on every grid cursor move and, when
1368 the cursor lands on the last row of the last grid, scroll the parent
1369 VerticalScroll to its end so the inline scroll-hint Static comes into
1370 view (matches the natural overshoot mouse-scroll past the cards
1371 already produces). Also re-renders the detail drawer for the newly
1372 highlighted row.
1373 """
1374 self._maybe_prefetch_on_grid_nav()
1375 self._reveal_scroll_hint_at_catalog_end()
1376 self._update_drawer_for_grid(event.grid, event.index)
1378 def _update_drawer_for_grid(self, grid: ModelGrid, index: int) -> None:
1379 """Push the focused row into the drawer; no-op if drawer is detached."""
1380 try:
1381 drawer = self.query_one("#catalog-detail-drawer", CatalogDetailDrawer)
1382 except Exception:
1383 return
1384 rows = grid.rows
1385 row = rows[index] if 0 <= index < len(rows) else None
1386 drawer.update_for_row(row)
1388 def on_key(self, event: Key) -> None:
1389 """Intercept 1-6 to jump tabs even when a focused widget owns digits.
1391 Bindings with priority=True should win against focused-widget
1392 bindings, but Textual's TabbedContent's inner ContentTabs swallows
1393 numeric keypresses before they reach screen-level bindings. An
1394 explicit on_key handler intercepts the digit at the bubbling stage,
1395 triggers ``action_select_tab``, and stops further dispatch so the
1396 digit doesn't bleed into the search Input or another widget.
1397 """
1398 if self._search_focused:
1399 return
1400 digit_to_index = {"1": 0, "2": 1, "3": 2, "4": 3, "5": 4, "6": 5}
1401 index = digit_to_index.get(event.key)
1402 if index is None:
1403 return
1404 event.stop()
1405 event.prevent_default()
1406 self.action_select_tab(index)
1408 def action_select_tab(self, index: int) -> None:
1409 """Activate the tab at *index* in ALL_TAB_IDS (0..5)."""
1410 from lilbee.cli.tui.screens.catalog_utils import ALL_TAB_IDS
1412 if self._search_focused:
1413 return
1414 if not 0 <= index < len(ALL_TAB_IDS):
1415 return
1416 target = ALL_TAB_IDS[index]
1417 try:
1418 tabs = self.query_one("#catalog-tabs", TabbedContent)
1419 except Exception:
1420 return
1421 self.set_focus(None)
1422 if tabs.active != target:
1423 tabs.active = target
1424 self._active_tab_id_cache = target
1426 def action_cycle_tab(self, delta: int) -> None:
1427 """Step the active tab by *delta*, wrapping around the strip.
1429 ctrl+right -> next, ctrl+left -> prev. Wraps so the user can spin
1430 either direction without hitting an end stop.
1431 """
1432 from lilbee.cli.tui.screens.catalog_utils import ALL_TAB_IDS
1434 if self._search_focused:
1435 return
1436 try:
1437 current = ALL_TAB_IDS.index(self._active_tab_id_cache)
1438 except ValueError:
1439 current = 0
1440 next_index = (current + delta) % len(ALL_TAB_IDS)
1441 self.action_select_tab(next_index)
1443 def action_cycle_source(self) -> None:
1444 """Cycle the active task tab's source mode: LOCAL -> CLOUD -> BOTH.
1446 No-op outside the four task tabs (Discover/Library aren't filtered
1447 by source). Per-tab mode means flipping Chat to BOTH doesn't drag
1448 Embed along; users can keep different views per task.
1449 """
1450 if self._search_focused:
1451 return
1452 active = self._active_tab_id_cache
1453 if active not in TASK_TAB_IDS:
1454 return
1455 self._source_modes[active] = next_source_mode(self._source_modes[active])
1456 # Force a rebuild on this tab; cache key for this tab is now stale
1457 # because the source filter changed but the upstream row data didn't.
1458 self._grid_cache_keys.pop(active, None)
1459 self._list_cache_keys.pop(active, None)
1460 self._refresh_view()
1462 def action_toggle_drawer(self) -> None:
1463 """Toggle the detail drawer's visibility via the -collapsed class.
1465 Default state is collapsed; users opt in. Class toggle is a single
1466 layout pass; we don't dynamically mount/unmount the drawer because
1467 rendering it offscreen costs zero (display: none).
1468 """
1469 try:
1470 drawer = self.query_one("#catalog-detail-drawer", CatalogDetailDrawer)
1471 except Exception:
1472 return
1473 drawer.toggle_class("-collapsed")
1475 def _reveal_scroll_hint_at_catalog_end(self) -> None:
1476 """Scroll the catalog container to the end when the keyboard cursor
1477 is on the last row of the bottom-most grid; otherwise no-op so the
1478 ``watch_highlighted`` cell-into-view scroll keeps tracking the cursor.
1480 ``immediate=True`` so the overshoot lands in the same compositor
1481 frame as the cell-into-view scroll above it; deferred would let a
1482 subsequent ``parent.scroll_to_region`` re-pin scroll_y to the cell.
1483 """
1484 focused = self._focused_grid()
1485 if not isinstance(focused, ModelGrid) or focused.highlighted is None:
1486 return
1487 grids = list(self._grid_container.query(ModelGrid))
1488 if not grids or focused is not grids[-1]:
1489 return
1490 cols = max(1, focused.columns_per_row)
1491 last_row = (len(focused.rows) - 1) // cols
1492 if focused.highlighted // cols < last_row:
1493 return
1494 self._grid_container.scroll_end(animate=False, immediate=True)
1496 @on(GridSelect.LeaveDown)
1497 @on(ModelGrid.LeaveDown)
1498 def _on_grid_leave_down(self, event: Message) -> None:
1499 """Move focus to the next grid widget, or fetch more if at the end.
1501 On the bottom-most grid we expose the inline scroll-hint Static
1502 (mounted below the last grid via ``_mount_grid_ctas``) by scrolling
1503 the parent VerticalScroll to its end. That mirrors the way mouse
1504 wheel naturally overshoots past the last card to reveal the hint.
1505 Cursor stays parked on the last cell.
1506 """
1507 if isinstance(event, ModelGrid.LeaveDown):
1508 grids = list(self._grid_container.query(ModelGrid))
1509 if grids and event.grid is grids[-1]:
1510 self._grid_container.scroll_end(animate=False, immediate=True)
1511 if self._active_task_has_more() and not self._loading_more:
1512 self._load_more()
1513 return
1514 self.focus_next()
1516 @on(GridSelect.LeaveUp)
1517 @on(ModelGrid.LeaveUp)
1518 def _on_grid_leave_up(self, event: Message) -> None:
1519 """Move focus to the previous grid widget.
1521 On the topmost grid, return without moving focus so the cursor
1522 stays parked at the top row instead of leaking focus upward.
1523 """
1524 if isinstance(event, ModelGrid.LeaveUp):
1525 grids = list(self._grid_container.query(ModelGrid))
1526 if grids and event.grid is grids[0]:
1527 return
1528 self.focus_previous()
1530 @on(GridSelect.Selected)
1531 def _on_grid_select_selected(self, event: GridSelect.Selected) -> None:
1532 """Handle model selection from a GridSelect (setup wizard path)."""
1533 widget = event.widget
1534 if isinstance(widget, ModelCard):
1535 self._select_row(widget.row)
1537 @on(ModelGrid.Selected)
1538 def _on_grid_selected(self, event: ModelGrid.Selected) -> None:
1539 """Handle model selection from the catalog grid view."""
1540 self._select_row(event.row)
1542 @on(ModelList.Selected)
1543 def _on_model_list_selected(self, event: ModelList.Selected) -> None:
1544 """Handle model selection from any ModelList (Local list view or Frontier tab)."""
1545 self._select_row(event.row)
1547 def _refresh_list(self) -> None:
1548 """Rebuild the list view for the active tab; per-tab cache key skips no-op rebuilds."""
1549 active_tab = self._active_tab_id_cache
1550 all_rows = self._sort_rows(self._build_rows())
1551 if active_tab in TASK_TAB_IDS:
1552 active_task = TAB_ID_TO_TASK[active_tab]
1553 self._rows = [r for r in all_rows if r.task == active_task.value]
1554 else:
1555 self._rows = list(all_rows)
1556 search = self._get_search_text()
1557 list_key = (
1558 tuple((r.name, r.installed) for r in self._rows),
1559 search,
1560 )
1561 if self._list_cache_keys.get(active_tab) == list_key:
1562 self._update_sort_label()
1563 return
1564 self._list_cache_keys[active_tab] = list_key
1565 visible = [r for r in self._rows if not search or matches_search(r, search)]
1566 self._list_widget.set_rows([ModelListSection(heading=None, rows=list(visible))])
1567 self._update_sort_label()
1569 def _filter_list(self) -> None:
1570 """Filter the list view to rows matching the active search."""
1571 search = self._get_search_text()
1572 visible = [r for r in self._rows if not search or matches_search(r, search)]
1573 self._list_widget.set_rows([ModelListSection(heading=None, rows=list(visible))])
1574 # Cache key reflects the filtered shape so a no-op _refresh_list
1575 # immediately after a filter pass does not double-render.
1576 self._list_cache_keys[self._active_tab_id_cache] = (
1577 tuple((r.name, r.installed) for r in self._rows),
1578 search,
1579 )
1580 self._update_sort_label()
1582 def _sync_loading_spinner(self) -> None:
1583 """Show/hide the toolbar spinner based on active fetch state.
1585 Visible when a paginated HF fetch or a remote search is in
1586 flight (both grid and list views share the same toolbar
1587 widget). Cycles braille frames on a 100 ms timer so the
1588 wait reads as "moving" rather than "frozen".
1589 """
1590 try:
1591 spinner = self.query_one("#catalog-loading-spinner", Static)
1592 except Exception:
1593 return
1594 active = self._loading_more or self._search_in_flight
1595 if active:
1596 spinner.styles.display = "block"
1597 spinner.update(f"{_SPINNER_FRAMES[self._spinner_frame]} loading…")
1598 if self._spinner_timer is None:
1599 self._spinner_timer = self.set_interval(
1600 _SPINNER_INTERVAL_S, self._tick_loading_spinner
1601 )
1602 # Mirror the spinner into the inline scroll-hint so users
1603 # waiting at the bottom of the grid see the activity in the
1604 # same place mouse scroll surfaces it.
1605 if self._loading_more:
1606 with contextlib.suppress(Exception):
1607 hint = self._grid_container.query_one(".scroll-hint", Static)
1608 hint.update(
1609 msg.CATALOG_GRID_LOADING_MORE.format(
1610 frame=_SPINNER_FRAMES[self._spinner_frame]
1611 )
1612 )
1613 else:
1614 spinner.update("")
1615 spinner.styles.display = "none"
1616 if self._spinner_timer is not None:
1617 self._spinner_timer.stop()
1618 self._spinner_timer = None
1619 self._spinner_frame = 0
1620 # Restore the post-load CTA text now that the fetch settled.
1621 # Count is per active task tab so the hint matches what's rendered.
1622 hf_rows = self._build_hf_rows(self._get_search_text()) if self._hf_fetched_any() else []
1623 active_task = self._active_task()
1624 hf_count = (
1625 sum(1 for r in hf_rows if r.task == active_task.value)
1626 if active_task is not None
1627 else len(hf_rows)
1628 )
1629 self._refresh_grid_ctas(hf_count=hf_count)
1631 def _tick_loading_spinner(self) -> None:
1632 """Advance the spinner one braille frame; called by the interval timer."""
1633 self._spinner_frame = (self._spinner_frame + 1) % len(_SPINNER_FRAMES)
1634 with contextlib.suppress(Exception):
1635 spinner = self.query_one("#catalog-loading-spinner", Static)
1636 spinner.update(f"{_SPINNER_FRAMES[self._spinner_frame]} loading…")
1637 if self._loading_more:
1638 with contextlib.suppress(Exception):
1639 hint = self._grid_container.query_one(".scroll-hint", Static)
1640 hint.update(
1641 msg.CATALOG_GRID_LOADING_MORE.format(frame=_SPINNER_FRAMES[self._spinner_frame])
1642 )
1644 def _update_sort_label(self) -> None:
1645 """Update the sort indicator label, switching copy by active tab.
1647 Wrapped in NoMatches suppression because the worker callbacks that
1648 trigger an update (``_fetch_remote_models``, ``_fetch_frontier_models``)
1649 can fire on the next loop tick after a screen switch, before the
1650 new screen's ``compose`` has finished mounting ``#sort-label``.
1651 On Windows that race lands often enough to fail CI.
1652 """
1653 from textual.css.query import NoMatches
1655 try:
1656 label = self.query_one("#sort-label", Static)
1657 except NoMatches:
1658 return
1659 if self._active_tab_id() == TAB_LIBRARY:
1660 label.update(self._frontier_label_text())
1661 return
1662 direction = "asc" if self._sort_ascending else "desc"
1663 n_total = len(self._rows)
1664 if self._loading_more:
1665 count = f"{n_total} models · loading more…"
1666 elif self._active_task_has_more():
1667 count = f"{n_total} models · press [b]n[/b] for more"
1668 else:
1669 count = f"{n_total} models"
1670 hint = msg.CATALOG_SEARCHING_HF if self._search_in_flight else msg.CATALOG_VIEW_TOGGLE_LIST
1671 label.update(f"Sort: {self._sort_column} ({direction}) | {count} | {hint}")
1673 def _frontier_label_text(self) -> str:
1674 provider_count = len({r.provider for r in self._frontier_rows})
1675 return msg.CATALOG_FRONTIER_SUMMARY.format(
1676 count=len(self._frontier_rows), providers=provider_count
1677 )
1679 def action_cycle_sort(self) -> None:
1680 """Cycle the list-view sort column ascending: Name, Downloads, Size, Params."""
1681 if self._search_focused:
1682 return
1683 if self._active_tab_id() not in TASK_TAB_IDS:
1684 return
1685 if self._grid_view:
1686 self.notify(msg.CATALOG_SORT_LIST_ONLY)
1687 return
1688 try:
1689 idx = _SORT_CYCLE.index(self._sort_column)
1690 except ValueError:
1691 idx = -1
1692 self._sort_column = _SORT_CYCLE[(idx + 1) % len(_SORT_CYCLE)]
1693 self._sort_ascending = True
1694 self._refresh_list()
1695 # mount_all is async; focus the first row after Textual's next
1696 # refresh so the filter Input doesn't swallow the next `s` press.
1697 self.call_after_refresh(self._focus_list_item, 0)
1699 def _select_row(self, row: CatalogRow) -> None:
1700 """Handle row selection: install, switch model, or open settings."""
1701 if row.kind == CatalogRowKind.FRONTIER: # sealed-union dispatch
1702 self._select_frontier_row(row)
1703 return
1704 if row.variant and row.family:
1705 self._install_variant(row.variant, row.family)
1706 elif row.catalog_model:
1707 self._install_model(row.catalog_model)
1708 elif row.remote_model:
1709 apply_active_model(self.app, "chat_model", row.ref)
1710 self.notify(msg.CATALOG_USING_REMOTE.format(name=row.remote_model.name))
1712 def _select_frontier_row(self, row: FrontierCatalogRow) -> None:
1713 """Activate a cloud model, or jump to settings when the key is missing."""
1714 if row.key_status == KeyStatus.READY:
1715 apply_active_model(self.app, "chat_model", row.ref)
1716 self.notify(msg.CATALOG_USING_FRONTIER.format(name=row.name, provider=row.provider))
1717 return
1718 key_field = f"{row.provider_id}_api_key"
1719 self.notify(
1720 msg.CATALOG_NEEDS_KEY.format(provider=row.provider, key_field=key_field),
1721 severity="warning",
1722 timeout=10,
1723 )
1724 self.app.switch_view("Settings")
1726 def _load_more(self) -> None:
1727 """Load the next HF page for the active task tab.
1729 Pagination is per-task: only the active tab's offset advances, only
1730 the active tab's task is fetched. Discover and Library short-circuit
1731 because they have no associated task and can't paginate.
1732 """
1733 if self._loading_more:
1734 return
1735 task = self._active_task()
1736 if task is None or not self._hf_has_more_by_task.get(task, False):
1737 return
1738 self._loading_more = True
1739 self._sync_loading_spinner()
1740 self._hf_offset_by_task[task] += _HF_PAGE_SIZE
1741 self._fetch_more_hf_for_task(task)
1743 def action_load_more(self) -> None:
1744 """Keyboard trigger (``n``) so users can page without scrolling."""
1745 if self._active_tab_id() not in TASK_TAB_IDS:
1746 return
1747 self._load_more()
1749 @on(TabbedContent.TabActivated, "#catalog-tabs")
1750 def _on_catalog_tab_activated(self, event: TabbedContent.TabActivated) -> None:
1751 """Update active-tab cache, refresh sort label, populate the active pane.
1753 Cache update is the load-bearing line: every later check that asks
1754 ``_active_tab_id()`` reads this cache, not a fresh DOM query, so
1755 per-render overhead stays constant regardless of tab count.
1756 """
1757 new_tab = event.pane.id or TAB_CHAT
1758 if not self._activation_settled:
1759 return
1760 self._active_tab_id_cache = new_tab
1761 # Stale per-tab widget caches survive across tab activations,
1762 # but if the user switched after a remount, the cached handle
1763 # may be detached. _grid_for_tab/_list_for_tab validate via
1764 # is_running and refetch as needed.
1765 self._update_sort_label()
1766 if new_tab == TAB_LIBRARY:
1767 self._populate_library_list()
1768 elif new_tab == TAB_DISCOVER:
1769 self._populate_discover_rails()
1770 elif new_tab in TASK_TAB_IDS:
1771 # Lazy first-fetch: tabs other than Chat skip their HF round-trip
1772 # at mount and hit the API only when first activated. Cached
1773 # after, so re-activations stay free.
1774 self._ensure_task_initial_fetch(TAB_ID_TO_TASK[new_tab])
1775 # Refresh the newly active task tab. Per-tab cache key skips
1776 # the rebuild when the row shape hasn't changed since last paint.
1777 self._refresh_view()
1779 def _populate_discover_rails(self) -> None:
1780 """Push three curated row slices into the Discover landing.
1782 - For You: featured rows ranked by fit (FITS first, TIGHT, then
1783 WONT_RUN), capped at 6 to keep the rail compact.
1784 - Your Collection: every installed local row + every activated
1785 cloud API. Mirrors the Library tab's spirit but capped to a
1786 single rail-friendly slice.
1787 - Fresh on the Hub: most-downloaded non-featured HF rows as a
1788 recency-ish proxy (the API doesn't expose 'newly uploaded' as
1789 a sort key today; downloads-desc surfaces buzzy recent uploads).
1790 """
1791 try:
1792 rails = self.query_one("#discover-rails", DiscoverRails)
1793 except Exception:
1794 return
1795 family_rows = self._all_family_rows()
1796 hf_rows = self._all_hf_rows() if self._hf_fetched_any() else []
1797 remote_rows = self._all_remote_rows()
1798 for_you = sorted(
1799 (r for r in family_rows + hf_rows if r.featured),
1800 key=for_you_sort_key,
1801 )[:6]
1802 collection = [r for r in family_rows + remote_rows if r.installed][:6]
1803 fresh = sorted(
1804 (r for r in hf_rows if not r.featured),
1805 key=lambda r: -r.sort_downloads,
1806 )[:6]
1807 rails.set_rails(for_you=for_you, collection=collection, fresh=fresh)
1809 def _install_variant(self, variant: ModelVariant, family: ModelFamily) -> None:
1810 """Convert a variant back to a CatalogModel and trigger install."""
1811 entry = CatalogModel(
1812 hf_repo=variant.hf_repo,
1813 gguf_filename=variant.filename,
1814 size_gb=variant.size_mb / 1024,
1815 min_ram_gb=max(2.0, (variant.size_mb / 1024) * 1.5),
1816 description=family.description,
1817 featured=True,
1818 downloads=0,
1819 task=family.task,
1820 recommended=variant.recommended,
1821 )
1822 self._install_model(entry)
1824 def _install_model(self, model: CatalogModel) -> None:
1825 try:
1826 filename = resolve_filename(model)
1827 dest = cfg.models_dir / filename
1828 if dest.exists():
1829 self.notify(msg.CATALOG_ALREADY_INSTALLED.format(name=model.display_name))
1830 return
1831 except Exception:
1832 log.debug("Could not resolve filename", exc_info=True)
1834 self._enqueue_download(model)
1836 def _enqueue_download(self, model: CatalogModel) -> None:
1837 """Submit the download to the app-level TaskBarController.
1839 The controller owns the worker thread; this screen just fires the
1840 request and returns. Progress is visible from every screen and
1841 survives navigation. When the row's architecture is known-unsupported,
1842 confirm with a modal before enqueuing; the modal returns True to
1843 proceed with ``allow_unsupported=True`` or False to cancel.
1844 """
1845 if model.compat is ModelCompat.UNSUPPORTED:
1847 def _after_confirm(verdict: bool | None) -> None:
1848 if not verdict:
1849 return
1850 self.app.task_bar.start_download(model, allow_unsupported=True)
1851 self.notify(msg.CATALOG_QUEUED_DOWNLOAD.format(name=model.display_name))
1853 self.app.push_screen(
1854 ConfirmDialog(
1855 msg.COMPAT_MODAL_TITLE,
1856 msg.COMPAT_MODAL_BODY.format(arch=model.architecture or "unknown"),
1857 ),
1858 _after_confirm,
1859 )
1860 return
1862 self.app.task_bar.start_download(model)
1863 self.notify(msg.CATALOG_QUEUED_DOWNLOAD.format(name=model.display_name))
1865 def action_go_back(self) -> None:
1866 # An open filter collapses to hidden (restoring grid/list focus);
1867 # otherwise q leaves for Chat.
1868 if self._filter_open:
1869 self._search_input.value = ""
1870 self._search_input.add_class(_HIDDEN_CLASS)
1871 self._focus_list_or_grid()
1872 return
1873 self.app.switch_view("Chat")
1875 def action_dismiss_filter(self) -> None:
1876 """Esc: hide the filter Input + restore grid/list focus; never dismiss.
1878 Heavy-interaction QA showed a stray Esc (from info-modal-then-Esc
1879 cycles, drawer thrash, filter typing chains) would dismiss the
1880 catalog mid-task and leak subsequent keystrokes into the chat
1881 Input on the next screen. Esc now only handles the filter; the
1882 dismiss path is `q` (action_go_back) which still does both.
1883 """
1884 if self._filter_open:
1885 self._search_input.value = ""
1886 self._search_input.add_class(_HIDDEN_CLASS)
1887 self._focus_list_or_grid()
1889 def _focus_list_or_grid(self) -> None:
1890 """Move focus from the filter input to the active view's list/grid."""
1891 if self._grid_view:
1892 self._focus_first_grid()
1893 else:
1894 self._focus_list_item(0)
1896 def action_show_info(self) -> None:
1897 """Pop up an info modal for the highlighted catalog row."""
1898 if self._search_focused:
1899 return
1900 row = self._highlighted_row()
1901 if row is None:
1902 self.notify(msg.CATALOG_SELECT_FOR_INFO, severity="warning")
1903 return
1904 if row.kind != CatalogRowKind.LOCAL:
1905 self.notify(msg.CATALOG_FRONTIER_NO_INFO, severity="warning")
1906 return
1907 from lilbee.cli.tui.screens.model_info import ModelInfoModal
1909 self.app.push_screen(ModelInfoModal(row))
1911 def _highlighted_row(self) -> CatalogRow | None:
1912 """Return the focused row in either grid or list view, or None."""
1913 if not self._grid_view and self._list_widget.has_focus:
1914 return self._list_widget.highlighted_row()
1915 focused_grid = self._focused_grid()
1916 if focused_grid is None or focused_grid.highlighted is None:
1917 return None
1918 if isinstance(focused_grid, ModelGrid):
1919 rows = focused_grid.rows
1920 index = focused_grid.highlighted
1921 return rows[index] if 0 <= index < len(rows) else None
1922 child = focused_grid.children[focused_grid.highlighted]
1923 if isinstance(child, ModelCard):
1924 return child.row
1925 return None
1927 def action_delete_model(self) -> None:
1928 """Delete an installed model. First press asks confirmation, second confirms."""
1929 if self._search_focused:
1930 return
1931 model_name = self._get_highlighted_model_name()
1932 if model_name is None:
1933 self.notify(msg.CATALOG_SELECT_TO_DELETE, severity="warning")
1934 return
1936 if not self._row_is_installed(model_name):
1937 self.notify(msg.CATALOG_NOT_INSTALLED.format(name=model_name), severity="warning")
1938 return
1940 if self._pending_delete == model_name:
1941 self._pending_delete = None
1942 self._run_delete(model_name)
1943 else:
1944 self._pending_delete = model_name
1945 self.notify(msg.CATALOG_CONFIRM_DELETE.format(name=model_name))
1947 def _row_is_installed(self, model_name: str) -> bool:
1948 """True if *model_name* names an installed native or remote model.
1950 ``_installed_names`` carries both the full ``<repo>/<file>.gguf``
1951 ref and the bare ``hf_repo`` for every installed native model,
1952 so it answers either ref shape; remote presence is asked of the
1953 manager directly.
1954 """
1955 if model_name in self._installed_names:
1956 return True
1957 return get_services().model_manager.is_installed(model_name, ModelSource.REMOTE)
1959 def _resolve_delete_ref(self, identity: str) -> str:
1960 """Pick the single registry ref that deleting *identity* maps to.
1962 Featured / HF browse rows surface a bare hf_repo while the
1963 registry deletes by ``<hf_repo>/<file>.gguf``. Bare repos
1964 resolve to the lexicographically-first matching installed
1965 manifest; full refs and remote names pass through.
1966 """
1967 if "/" in identity and identity.endswith(".gguf"):
1968 return identity
1969 prefix = identity + "/"
1970 matches = sorted(n for n in self._installed_names if n.startswith(prefix))
1971 if matches:
1972 return matches[0]
1973 return identity
1975 def _get_highlighted_model_name(self) -> str | None:
1976 """Return the registry-compatible model ref for the focused/highlighted row."""
1977 if not self._grid_view and self._list_widget.has_focus:
1978 row = self._list_widget.highlighted_row()
1979 return row_delete_id(row) if row else None
1980 focused_grid = self._focused_grid()
1981 if focused_grid is None or focused_grid.highlighted is None:
1982 return None
1983 if isinstance(focused_grid, ModelGrid):
1984 rows = focused_grid.rows
1985 index = focused_grid.highlighted
1986 if 0 <= index < len(rows):
1987 return row_delete_id(rows[index])
1988 return None
1989 # GridSelect path: cards are direct children indexed positionally.
1990 child = focused_grid.children[focused_grid.highlighted]
1991 if isinstance(child, ModelCard):
1992 return row_delete_id(child.row)
1993 return None
1995 @work(thread=True)
1996 def _run_delete(self, model_name: str) -> None:
1997 """Remove a model in a background thread."""
1998 delete_ref = self._resolve_delete_ref(model_name)
1999 try:
2000 removed = get_services().model_manager.remove(delete_ref)
2001 if removed:
2002 call_from_thread(self, self.notify, msg.CATALOG_DELETED.format(name=model_name))
2003 call_from_thread(self, self._refresh_after_delete)
2004 else:
2005 call_from_thread(
2006 self,
2007 self.notify,
2008 msg.CATALOG_DELETE_FAILED.format(error=model_name),
2009 severity="error",
2010 )
2011 except Exception as exc:
2012 log.warning("Delete failed for %s", model_name, exc_info=True)
2013 call_from_thread(
2014 self,
2015 self.notify,
2016 msg.CATALOG_DELETE_FAILED.format(error=exc),
2017 severity="error",
2018 )
2020 def _refresh_after_delete(self) -> None:
2021 """Re-fetch remote models and refresh after deletion."""
2022 self._fetch_installed_names()
2023 self._refresh_view()
2024 self._fetch_remote_models()
2026 def _focused_grid(self) -> ModelGrid | GridSelect | None:
2027 """Return the focused grid widget (grid view), else None."""
2028 if self._grid_view and isinstance(self.focused, (ModelGrid, GridSelect)):
2029 return self.focused
2030 return None
2032 def _list_count(self) -> int:
2033 """Total options currently shown in the list view (excluding headings)."""
2034 return self._list_widget.row_count
2036 def _focus_list_item(self, index: int) -> None:
2037 """Highlight the row at *index*, clamped to the visible range."""
2038 count = self._list_widget.option_count
2039 if not count:
2040 return
2041 clamped = max(0, min(index, count - 1))
2042 self._list_widget.highlighted = clamped
2043 self._list_widget.focus()
2045 def _focused_list_index(self) -> int | None:
2046 """Index of the highlighted list row, or None when nothing is highlighted."""
2047 return self._list_widget.highlighted
2049 def _nudge_list(self, delta: int) -> None:
2050 idx = self._focused_list_index()
2051 if idx is None:
2052 self._focus_list_item(0)
2053 return
2054 self._focus_list_item(idx + delta)
2055 self._maybe_prefetch_on_nav()
2057 def _maybe_prefetch_on_nav(self) -> None:
2058 if self._grid_view or not self._active_task_has_more() or self._loading_more:
2059 return
2060 idx = self._focused_list_index()
2061 if idx is None:
2062 return
2063 if idx >= self._list_widget.option_count - _HF_LOAD_MORE_TRIGGER:
2064 self._load_more()
2066 def _maybe_prefetch_on_grid_nav(self) -> None:
2067 """Fire ``_load_more`` when the keyboard cursor lands within the last
2068 rows of the catalog. Mouse wheel triggers via ``_on_grid_scrolled`` at
2069 the 85 % scroll threshold, but cell-by-cell keyboard nav advances
2070 scroll_y too gradually to ever cross that threshold; this check
2071 guarantees keyboard reaches the same prefetch trigger.
2072 """
2073 if not self._grid_view or not self._active_task_has_more() or self._loading_more:
2074 return
2075 grids = list(self._grid_container.query(ModelGrid))
2076 if not grids:
2077 return
2078 focused = self._focused_grid()
2079 if not isinstance(focused, ModelGrid) or focused.highlighted is None:
2080 return
2081 # Absolute cursor position = cards in earlier grids + cursor in this grid.
2082 try:
2083 grid_index = grids.index(focused)
2084 except ValueError:
2085 return
2086 cards_before = sum(len(g.rows) for g in grids[:grid_index])
2087 absolute = cards_before + focused.highlighted
2088 total = sum(len(g.rows) for g in grids)
2089 if total <= 0:
2090 return
2091 if absolute >= total - _HF_LOAD_MORE_TRIGGER:
2092 self._load_more()
2094 _SCROLL_PREFETCH_RATIO = 0.85
2095 _SCROLL_PREFETCH_COOLDOWN = 0.8
2097 def _on_list_scrolled(self, _scroll_y: float) -> None:
2098 """Trigger _load_more when the user scrolls near the bottom of the list."""
2099 if not self._scroll_prefetch_due(self._list_widget):
2100 return
2101 self._scroll_prefetch_armed_at = time.monotonic()
2102 self._load_more()
2104 def _on_grid_scrolled(self, _scroll_y: float) -> None:
2105 """Trigger _load_more when the user scrolls near the bottom of the grid."""
2106 if not self._grid_view:
2107 return
2108 if not self._scroll_prefetch_due(self._grid_container):
2109 return
2110 self._scroll_prefetch_armed_at = time.monotonic()
2111 self._load_more()
2113 def on_mouse_scroll_down(self, event: MouseScrollDown) -> None:
2114 """Force pagination when wheeling beyond what the active scroll can scroll.
2116 Three collapsed triggers, both views: (1) content already fits the
2117 viewport so ``max_scroll_y == 0`` and wheel events produce no scroll
2118 delta, (2) the user has wheeled to ``scroll_y == max_scroll_y`` and
2119 further wheels produce no delta, (3) list view has the same problem
2120 as grid view -- the scroll watcher only fires on scroll_y changes,
2121 so a wheel at max_y is invisible to ``_on_list_scrolled`` /
2122 ``_on_grid_scrolled``. Re-check here and fetch the next page
2123 directly. Cooldown prevents a cascade as new rows shift max_scroll_y.
2124 """
2125 if not self._active_task_has_more() or self._loading_more:
2126 return
2127 container = self._grid_container if self._grid_view else self._list_widget
2128 max_y = container.max_scroll_y
2129 if max_y > 0 and container.scroll_y < max_y:
2130 return
2131 if self._scroll_prefetch_armed_at:
2132 elapsed = time.monotonic() - self._scroll_prefetch_armed_at
2133 if elapsed < self._SCROLL_PREFETCH_COOLDOWN:
2134 return
2135 self._scroll_prefetch_armed_at = time.monotonic()
2136 self._load_more()
2138 def _scroll_prefetch_due(self, widget: VerticalScroll | ModelList) -> bool:
2139 # Cooldown blocks a runaway cascade where appending rows shifts
2140 # max_scroll_y, the watcher refires, and load_more kicks off the
2141 # next fetch before the user notices.
2142 if not self._active_task_has_more() or self._loading_more:
2143 return False
2144 if self._scroll_prefetch_armed_at:
2145 elapsed = time.monotonic() - self._scroll_prefetch_armed_at
2146 if elapsed < self._SCROLL_PREFETCH_COOLDOWN:
2147 return False
2148 max_y = widget.max_scroll_y
2149 if max_y <= 0:
2150 return False
2151 return widget.scroll_y / max_y >= self._SCROLL_PREFETCH_RATIO
2153 def _page_rows(self) -> int:
2154 """How many cursor steps make up one 'page' in the active view."""
2155 return _GRID_PAGE_ROWS if self._grid_view else _LIST_PAGE_ROWS
2157 def action_page_down(self) -> None:
2158 if self._search_focused:
2159 return
2160 if self._grid_view:
2161 if (grid := self._focused_grid()) is not None:
2162 for _ in range(self._page_rows()):
2163 grid.action_cursor_down()
2164 else:
2165 self._nudge_list(self._page_rows())
2167 def action_page_up(self) -> None:
2168 if self._search_focused:
2169 return
2170 if self._grid_view:
2171 if (grid := self._focused_grid()) is not None:
2172 for _ in range(self._page_rows()):
2173 grid.action_cursor_up()
2174 else:
2175 self._nudge_list(-self._page_rows())
2177 def action_cursor_down(self) -> None:
2178 if self._search_focused:
2179 return
2180 if self._grid_view:
2181 grid = self._focused_grid() or self._first_grid_or_none()
2182 if grid is not None:
2183 grid.focus()
2184 grid.action_cursor_down()
2185 else:
2186 self._nudge_list(1)
2188 def action_cursor_up(self) -> None:
2189 if self._search_focused:
2190 return
2191 if self._grid_view:
2192 grid = self._focused_grid() or self._first_grid_or_none()
2193 if grid is not None:
2194 grid.focus()
2195 grid.action_cursor_up()
2196 else:
2197 self._nudge_list(-1)
2199 def _first_grid_or_none(self) -> ModelGrid | None:
2200 """Return the first ModelGrid in the active tab's container, or None."""
2201 from textual.css.query import NoMatches
2203 try:
2204 return self._grid_container.query(ModelGrid).first()
2205 except NoMatches:
2206 return None
2208 def action_jump_top(self) -> None:
2209 if self._search_focused:
2210 return
2211 if self._grid_view:
2212 if (grid := self._focused_grid()) is not None:
2213 grid.highlight_first()
2214 else:
2215 self._focus_list_item(0)
2217 def action_jump_bottom(self) -> None:
2218 if self._search_focused:
2219 return
2220 if self._grid_view:
2221 if (grid := self._focused_grid()) is not None:
2222 grid.highlight_last()
2223 else:
2224 count = self._list_widget.option_count
2225 if count:
2226 self._focus_list_item(count - 1)
2227 self._maybe_prefetch_on_nav()