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