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

1"""Catalog screen -- browse and install models via grid or list view.""" 

2 

3from __future__ import annotations 

4 

5import contextlib 

6import logging 

7import time 

8from dataclasses import dataclass 

9from typing import ClassVar 

10 

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 

21 

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 

83 

84log = logging.getLogger(__name__) 

85 

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) 

95 

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" 

101 

102_GRID_PAGE_ROWS = 3 

103_LIST_PAGE_ROWS = 10 

104 

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-" 

109 

110# Toggles the filter Input between revealed and `display: none` (catalog.tcss). 

111_HIDDEN_CLASS = "-hidden" 

112 

113_SORT_CYCLE: tuple[str, ...] = ("Name", "Downloads", "Size", "Params") 

114 

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 

121 

122_RowCacheKey = tuple[int, int, int, int, int, int] 

123 

124 

125@dataclass(frozen=True) 

126class _RowCacheEntry: 

127 """Memoized output of one ``_all_*_rows`` builder.""" 

128 

129 key: _RowCacheKey 

130 rows: list[LocalCatalogRow] 

131 

132 

133class CatalogScreen(Screen[None]): 

134 """Model catalog with grid (default) and list views.""" 

135 

136 app: LilbeeApp # type: ignore[assignment] 

137 

138 CSS_PATH = "catalog.tcss" 

139 AUTO_FOCUS = "" # GridSelect is mounted dynamically; focused in on_mount 

140 

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 ) 

171 

172 _ACTION_GROUP = Binding.Group("Actions", compact=True) 

173 _SCROLL_GROUP = Binding.Group("Scroll", compact=True) 

174 

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 ] 

235 

236 _search_input = getters.query_one("#catalog-search", Input) 

237 

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() 

298 

299 def _grid_for_tab(self, tab_id: str) -> VerticalScroll: 

300 """Return (and memoize) the VerticalScroll for *tab_id*. 

301 

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 

314 

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 

324 

325 @property 

326 def _grid_container(self) -> VerticalScroll: 

327 return self._grid_for_tab(self._active_tab_id_cache) 

328 

329 @property 

330 def _list_widget(self) -> ModelList: 

331 return self._list_for_tab(self._active_tab_id_cache) 

332 

333 @property 

334 def _search_focused(self) -> bool: 

335 """True when the search Input widget owns focus. 

336 

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) 

341 

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) 

346 

347 def compose(self) -> ComposeResult: 

348 from lilbee.cli.tui.widgets.grid_list_toggle import GridListToggle 

349 

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() 

402 

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") 

414 

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 ) 

455 

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() 

460 

461 def on_screen_suspend(self) -> None: 

462 """Pause the spinner timer while the screen is offscreen. 

463 

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() 

469 

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() 

474 

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 

479 

480 _FRONTIER_REFRESH_DEBOUNCE = 1.0 

481 

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 ) 

489 

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 

496 

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() 

504 

505 def _fetch_installed_names(self) -> None: 

506 """Populate installed identities from the shared ModelManager cache. 

507 

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 

516 

517 def _active_tab_id(self) -> str: 

518 """Return the cached active tab id; falls back to TAB_CHAT pre-mount. 

519 

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 

525 

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()) 

529 

530 def _active_task_has_more(self) -> bool: 

531 """True iff the active task tab has another HF page available. 

532 

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) 

539 

540 def _hf_fetched_any(self) -> bool: 

541 """True iff any task has had its first HF page fetched. 

542 

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) 

547 

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) 

554 

555 def action_toggle_view(self) -> None: 

556 """Toggle between grid and list view on the active task tab. 

557 

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() 

590 

591 def _sync_grid_list_toggle(self) -> None: 

592 from lilbee.cli.tui.widgets.grid_list_toggle import GridListToggle 

593 

594 with contextlib.suppress(Exception): 

595 self.query_one(GridListToggle).set_grid(self._grid_view) 

596 

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() 

601 

602 _SEARCH_FILTER_DEBOUNCE_SECONDS = 0.08 

603 

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. 

607 

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 ) 

619 

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() 

630 

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()) 

643 

644 def _trigger_remote_search(self, query: str) -> None: 

645 """Fire the HF search worker for the active task, unless one is in flight. 

646 

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) 

663 

664 @on(Click, ".search-hf-cta") 

665 def _on_search_hf_cta_clicked(self) -> None: 

666 self._trigger_remote_search(self._get_search_text()) 

667 

668 def _select_first_visible_grid_card(self) -> None: 

669 """Focus the first grid with a visible match and trigger its install. 

670 

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 

684 

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() 

692 

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. 

695 

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] 

711 

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) 

716 

717 @work(thread=True, name=_WORKER_FETCH_REMOTE) 

718 def _fetch_remote_models(self) -> list[RemoteModel]: 

719 return classify_all_remote_models() 

720 

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. 

724 

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 

731 

732 try: 

733 groups = discover_api_models() 

734 except Exception: 

735 log.debug("discover_api_models failed in worker", exc_info=True) 

736 return [] 

737 

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 

749 

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) 

754 

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] 

767 

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 

785 

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() 

796 

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. 

799 

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() 

825 

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() 

833 

834 def _apply_worker_result(self, name: str, result: list) -> bool: 

835 """Land worker results into the screen's caches. 

836 

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 

869 

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) 

884 

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) 

901 

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 ) 

935 

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 "" 

944 

945 def _local_rows_data_key(self) -> _RowCacheKey: 

946 """Cache key over the inputs that drive row construction. 

947 

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 ) 

959 

960 def _all_family_rows(self) -> list[LocalCatalogRow]: 

961 """One row per featured family, aggregating its quants into size_variants. 

962 

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 

993 

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 

1006 

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 

1017 

1018 def _stamp_fit(self, rows: list[LocalCatalogRow]) -> None: 

1019 """Stamp each row's hardware-fit chip in place. 

1020 

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 ) 

1037 

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 

1046 

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)] 

1052 

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)] 

1058 

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)] 

1064 

1065 def _build_frontier_rows(self, search: str) -> list[FrontierCatalogRow]: 

1066 """Filter the cached frontier rows against the active search. 

1067 

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)] 

1076 

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 

1084 

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 ) 

1094 

1095 def _refresh_view(self) -> None: 

1096 """Refresh the active view (grid or list). 

1097 

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() 

1107 

1108 def _refresh_grid(self) -> None: 

1109 """Rebuild grid view; extend in-place when sections already mounted. 

1110 

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() 

1131 

1132 def _prepare_grid_refresh(self) -> tuple[list[GridSection], int] | None: 

1133 """Build sections + cache them. Returns None when the cache is hot. 

1134 

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 

1179 

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. 

1182 

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 

1204 

1205 def _remount_grid_sections(self, sections: list[GridSection], hf_count: int) -> None: 

1206 """Teardown + remount the grid for a section-count change. 

1207 

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 ) 

1227 

1228 def _capture_focused_section(self) -> tuple[str, int | None] | None: 

1229 """Return ``(heading, highlighted_index)`` for the focused grid. 

1230 

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) 

1240 

1241 def _restore_focused_section(self, anchor: tuple[str, int | None] | None) -> bool: 

1242 """Refocus the grid whose ``name`` matches the captured anchor. 

1243 

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 

1259 

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 ) 

1271 

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() 

1297 

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) 

1305 

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) 

1326 

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) 

1338 

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. 

1343 

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 

1360 

1361 def _filter_grid(self) -> None: 

1362 """Re-render the grid with the current filter applied via _refresh_grid.""" 

1363 self._refresh_grid() 

1364 

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) 

1377 

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) 

1387 

1388 def on_key(self, event: Key) -> None: 

1389 """Intercept 1-6 to jump tabs even when a focused widget owns digits. 

1390 

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) 

1407 

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 

1411 

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 

1425 

1426 def action_cycle_tab(self, delta: int) -> None: 

1427 """Step the active tab by *delta*, wrapping around the strip. 

1428 

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 

1433 

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) 

1442 

1443 def action_cycle_source(self) -> None: 

1444 """Cycle the active task tab's source mode: LOCAL -> CLOUD -> BOTH. 

1445 

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() 

1461 

1462 def action_toggle_drawer(self) -> None: 

1463 """Toggle the detail drawer's visibility via the -collapsed class. 

1464 

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") 

1474 

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. 

1479 

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) 

1495 

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. 

1500 

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() 

1515 

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. 

1520 

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() 

1529 

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) 

1536 

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) 

1541 

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) 

1546 

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() 

1568 

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() 

1581 

1582 def _sync_loading_spinner(self) -> None: 

1583 """Show/hide the toolbar spinner based on active fetch state. 

1584 

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) 

1630 

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 ) 

1643 

1644 def _update_sort_label(self) -> None: 

1645 """Update the sort indicator label, switching copy by active tab. 

1646 

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 

1654 

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}") 

1672 

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 ) 

1678 

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) 

1698 

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)) 

1711 

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") 

1725 

1726 def _load_more(self) -> None: 

1727 """Load the next HF page for the active task tab. 

1728 

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) 

1742 

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() 

1748 

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. 

1752 

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() 

1778 

1779 def _populate_discover_rails(self) -> None: 

1780 """Push three curated row slices into the Discover landing. 

1781 

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) 

1808 

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) 

1823 

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) 

1833 

1834 self._enqueue_download(model) 

1835 

1836 def _enqueue_download(self, model: CatalogModel) -> None: 

1837 """Submit the download to the app-level TaskBarController. 

1838 

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: 

1846 

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)) 

1852 

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 

1861 

1862 self.app.task_bar.start_download(model) 

1863 self.notify(msg.CATALOG_QUEUED_DOWNLOAD.format(name=model.display_name)) 

1864 

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") 

1874 

1875 def action_dismiss_filter(self) -> None: 

1876 """Esc: hide the filter Input + restore grid/list focus; never dismiss. 

1877 

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() 

1888 

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) 

1895 

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 

1908 

1909 self.app.push_screen(ModelInfoModal(row)) 

1910 

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 

1926 

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 

1935 

1936 if not self._row_is_installed(model_name): 

1937 self.notify(msg.CATALOG_NOT_INSTALLED.format(name=model_name), severity="warning") 

1938 return 

1939 

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)) 

1946 

1947 def _row_is_installed(self, model_name: str) -> bool: 

1948 """True if *model_name* names an installed native or remote model. 

1949 

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) 

1958 

1959 def _resolve_delete_ref(self, identity: str) -> str: 

1960 """Pick the single registry ref that deleting *identity* maps to. 

1961 

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 

1974 

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 

1994 

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 ) 

2019 

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() 

2025 

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 

2031 

2032 def _list_count(self) -> int: 

2033 """Total options currently shown in the list view (excluding headings).""" 

2034 return self._list_widget.row_count 

2035 

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() 

2044 

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 

2048 

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() 

2056 

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() 

2065 

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() 

2093 

2094 _SCROLL_PREFETCH_RATIO = 0.85 

2095 _SCROLL_PREFETCH_COOLDOWN = 0.8 

2096 

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() 

2103 

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() 

2112 

2113 def on_mouse_scroll_down(self, event: MouseScrollDown) -> None: 

2114 """Force pagination when wheeling beyond what the active scroll can scroll. 

2115 

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() 

2137 

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 

2152 

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 

2156 

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()) 

2166 

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()) 

2176 

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) 

2187 

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) 

2198 

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 

2202 

2203 try: 

2204 return self._grid_container.query(ModelGrid).first() 

2205 except NoMatches: 

2206 return None 

2207 

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) 

2216 

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()