Coverage for src / lilbee / cli / tui / screens / catalog.py: 100%

1202 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-05-15 20:55 +0000

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 ModelSource, ModelTask 

32from lilbee.cli.tui import messages as msg 

33from lilbee.cli.tui.app import LilbeeApp, apply_active_model 

34from lilbee.cli.tui.screens.catalog_grouping import ( 

35 GridSection, 

36 for_you_sort_key, 

37 group_frontier_rows, 

38 group_rows_for_grid, 

39 group_task_rows_with_picks, 

40 row_cache_signature, 

41) 

42from lilbee.cli.tui.screens.catalog_utils import ( 

43 SORT_KEYS, 

44 TAB_CHAT, 

45 TAB_DISCOVER, 

46 TAB_EMBED, 

47 TAB_ID_TO_TASK, 

48 TAB_LIBRARY, 

49 TAB_RERANK, 

50 TAB_VISION, 

51 TASK_TAB_IDS, 

52 CatalogRow, 

53 CatalogRowKind, 

54 FrontierCatalogRow, 

55 KeyStatus, 

56 LocalCatalogRow, 

57 SourceMode, 

58 catalog_to_row, 

59 family_to_size_variants, 

60 frontier_row_from_remote, 

61 matches_search, 

62 next_source_mode, 

63 remote_to_row, 

64 row_delete_id, 

65 variant_to_row, 

66) 

67from lilbee.cli.tui.thread_safe import call_from_thread 

68from lilbee.cli.tui.widgets.bottom_bars import BottomBars 

69from lilbee.cli.tui.widgets.catalog_detail import CatalogDetailDrawer 

70from lilbee.cli.tui.widgets.discover_rails import DiscoverRails 

71from lilbee.cli.tui.widgets.grid_select import GridSelect 

72from lilbee.cli.tui.widgets.model_card import ModelCard 

73from lilbee.cli.tui.widgets.model_grid import ModelGrid 

74from lilbee.cli.tui.widgets.model_list import ModelList, ModelListSection 

75from lilbee.cli.tui.widgets.status_bar import ViewTabs 

76from lilbee.cli.tui.widgets.task_bar import TaskBar 

77from lilbee.cli.tui.widgets.top_bars import TopBars 

78from lilbee.core.config import cfg 

79from lilbee.modelhub.model_manager import RemoteModel, classify_remote_models 

80from lilbee.providers.sdk_backend import get_provider_api_key 

81from lilbee.runtime.hardware import available_memory_for_fit, compute_fit 

82 

83log = logging.getLogger(__name__) 

84 

85# Models fetched per task per page. We make one /api/models call per 

86# task (chat / embedding / vision / rerank), so the user-visible page 

87# size is _HF_PAGE_SIZE * 4. Small pages keep each HF round-trip well 

88# under a second on a typical connection and keep the freshly-rendered 

89# row count low so layout reflow stays cheap. 

90_HF_PAGE_SIZE = 4 

91_HF_LOAD_MORE_TRIGGER = 4 

92_NOTIFY_SEARCHING_TIMEOUT_SECONDS = 4 

93_ALL_TASKS = tuple(ModelTask) 

94 

95_WORKER_FETCH_HF = "fetch_hf_models" 

96_WORKER_FETCH_MORE_HF = "fetch_more_hf" 

97_WORKER_FETCH_REMOTE = "fetch_remote_models" 

98_WORKER_FETCH_SEARCH = "fetch_hf_search" 

99_WORKER_FETCH_FRONTIER = "fetch_frontier_models" 

100 

101_GRID_PAGE_ROWS = 3 

102_LIST_PAGE_ROWS = 10 

103 

104# Per-tab DOM ids: f"grid-{tab_id}" / f"list-{tab_id}". Memoized on the 

105# screen so each access is one dict lookup, not a DOM walk. 

106_GRID_ID_PREFIX = "grid-" 

107_LIST_ID_PREFIX = "list-" 

108 

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

110 

111# Braille spinner frames for the catalog pagination/search loading 

112# indicator. Cycled on a 100 ms timer while the catalog is fetching 

113# more HF rows or a remote search is in flight, so the user always 

114# has a moving signal during the wait instead of an empty pane. 

115_SPINNER_FRAMES: tuple[str, ...] = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") 

116_SPINNER_INTERVAL_S = 0.1 

117 

118_RowCacheKey = tuple[int, int, int, int, int, int] 

119 

120 

121@dataclass(frozen=True) 

122class _RowCacheEntry: 

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

124 

125 key: _RowCacheKey 

126 rows: list[LocalCatalogRow] 

127 

128 

129class CatalogScreen(Screen[None]): 

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

131 

132 app: LilbeeApp # type: ignore[assignment] 

133 

134 CSS_PATH = "catalog.tcss" 

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

136 

137 HELP = ( 

138 "# Catalog\n" 

139 "Six tabs: Discover (curated landing), Chat / Embed / Vision / Rerank,\n" 

140 "and Library (your installed local + activated cloud APIs).\n\n" 

141 "## Navigation\n" 

142 "- Arrows / j k h l: move the card cursor.\n" 

143 "- 1-6: jump to tab N.\n" 

144 "- Tab / Shift+Tab: cycle focus.\n\n" 

145 "## Actions\n" 

146 "- Enter: install the highlighted model (or activate, if cloud).\n" 

147 "- Space: toggle select.\n" 

148 "- d / Backspace / x: delete an installed model (two presses to confirm).\n" 

149 "- i: open the info modal for the highlighted card.\n" 

150 "- Right Arrow: expand a family card to show its size variants.\n\n" 

151 "## Filters and views\n" 

152 "- /: filter the active tab (Esc clears).\n" 

153 "- s: cycle sort (Name / Downloads / Size / Params).\n" 

154 "- v: toggle Grid vs List view on a task tab.\n" 

155 "- c: cycle source chip [local | cloud | both] on a task tab.\n" 

156 "- n: load more HF rows (or just keep scrolling).\n\n" 

157 "## Detail drawer\n" 

158 "- Ctrl+B: toggle the right-pane detail drawer.\n" 

159 " Shows fit chip, size variants with per-variant fit, license, description.\n\n" 

160 "## Fit chip\n" 

161 "- Green 'fits +N GB': model fits with at least 1 GB headroom.\n" 

162 "- Amber 'tight +N GB': model fits but within the 0..1 GB band.\n" 

163 '- Red "won\'t N GB": model overflows available memory by N GB.\n\n' 

164 "## Other\n" 

165 "- q / Esc: back." 

166 ) 

167 

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

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

170 

171 BINDINGS: ClassVar[list[BindingType]] = [ 

172 Binding("q", "go_back", "Back", show=True, group=_ACTION_GROUP), 

173 Binding("escape", "dismiss_filter", "", show=False), 

174 # Surfaced outside _ACTION_GROUP so the "Grid/List" affordance prints 

175 # in full in the footer instead of collapsing into the compact pill. 

176 # The "(faster)" tag tells users list view paginates without the 

177 # card layout overhead. 

178 Binding("v", "toggle_view", "Grid/List (faster)", show=True), 

179 Binding("slash", "focus_search", "Search", show=True, group=_ACTION_GROUP), 

180 # Delete sits outside _ACTION_GROUP so the footer renders it as 

181 # its own "D Delete" entry rather than collapsing it into the 

182 # compact "qv/di Actions" pill. Removing an installed model 

183 # needs to be obvious, not buried. 

184 Binding("d", "delete_model", "Delete", show=True), 

185 Binding("backspace", "delete_model", "Delete", show=False), 

186 Binding("x", "delete_model", "Delete", show=False), 

187 Binding("i", "show_info", "Info", show=True, group=_ACTION_GROUP), 

188 Binding("j", "cursor_down", "Nav", show=False, group=_SCROLL_GROUP), 

189 Binding("k", "cursor_up", "Nav", show=False, group=_SCROLL_GROUP), 

190 # Arrows move the card cursor too (auto-scrolls into view) so 

191 # the highlight follows the visible region. Decoupling them 

192 # into pure viewport scroll left a stale highlight on the 

193 # previously-focused card. 

194 Binding("down", "cursor_down", "Down", show=False, group=_SCROLL_GROUP), 

195 Binding("up", "cursor_up", "Up", show=False, group=_SCROLL_GROUP), 

196 # priority=True so vim jump-to-top/bottom always wins over the 

197 # focused ModelGrid's enter/select binding when keys collide. 

198 Binding("g", "jump_top", "Top", show=False, group=_SCROLL_GROUP, priority=True), 

199 Binding("G", "jump_bottom", "End", show=False, group=_SCROLL_GROUP, priority=True), 

200 Binding("space", "page_down", "PgDn", show=False, group=_SCROLL_GROUP), 

201 Binding("ctrl+d", "page_down", "PgDn", show=False, group=_SCROLL_GROUP), 

202 Binding("ctrl+u", "page_up", "PgUp", show=False, group=_SCROLL_GROUP), 

203 # Hidden from the footer so catalog still has <=5 visible bindings; 

204 # the sort-label surfaces "press n for more" and "press s to sort" 

205 # to the user instead. 

206 Binding("n", "load_more", "More", show=False, group=_ACTION_GROUP), 

207 Binding("s", "cycle_sort", "Sort", show=False, group=_ACTION_GROUP), 

208 Binding("ctrl+b", "toggle_drawer", "Detail", show=False, group=_ACTION_GROUP), 

209 Binding("c", "cycle_source", "Source", show=False, group=_ACTION_GROUP), 

210 # Numeric tab shortcuts; 1-6 jump to the corresponding tab in 

211 # ALL_TAB_IDS order (Discover, Chat, Embed, Vision, Rerank, Library). 

212 # priority=True so they win against any focused-widget binding that 

213 # might already grab digits (Textual's Tabs/ContentTabs has its own 

214 # numeric handling), and over-the-air shortcut feel matches the plan. 

215 Binding("1", "select_tab(0)", "Discover", show=False, priority=True), 

216 Binding("2", "select_tab(1)", "Chat", show=False, priority=True), 

217 Binding("3", "select_tab(2)", "Embed", show=False, priority=True), 

218 Binding("4", "select_tab(3)", "Vision", show=False, priority=True), 

219 Binding("5", "select_tab(4)", "Rerank", show=False, priority=True), 

220 Binding("6", "select_tab(5)", "Library", show=False, priority=True), 

221 # Discoverable tab cycling. The numeric jumps (1-6) above are quick 

222 # but hidden; > / < show in the footer so users learn the affordance. 

223 # ctrl+arrow conflicts with macOS desktop-space shortcuts, hence 

224 # vim-style angle brackets. priority=True so the active ModelGrid's 

225 # own focus cycling doesn't swallow them. 

226 Binding("greater_than_sign", "cycle_tab(1)", "Next tab", show=True, priority=True), 

227 Binding("less_than_sign", "cycle_tab(-1)", "Prev tab", show=True, priority=True), 

228 ] 

229 

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

231 

232 def __init__(self) -> None: 

233 super().__init__() 

234 self._families: list[ModelFamily] = get_families() 

235 self._hf_models: list[CatalogModel] = [] 

236 self._remote_models: list[RemoteModel] = [] 

237 # Per-task pagination state. Each task tab tracks its own HF offset 

238 # and has-more flag so paginating in one tab (e.g. Chat) only fetches 

239 # that task's next page; sibling tabs stay untouched. 

240 self._hf_offset_by_task: dict[ModelTask, int] = dict.fromkeys(_ALL_TASKS, 0) 

241 self._hf_has_more_by_task: dict[ModelTask, bool] = dict.fromkeys(_ALL_TASKS, True) 

242 self._hf_fetched_tasks: set[ModelTask] = set() 

243 self._rows: list[LocalCatalogRow] = [] 

244 self._sort_column: str = "Name" 

245 self._sort_ascending: bool = True 

246 self._pending_delete: str | None = None 

247 self._installed_names: set[str] = set() 

248 self._grid_view: bool = True 

249 self._loading_more: bool = False 

250 # Per-tab grid/list cache keys. Each tab tracks its own last-rendered 

251 # shape; switching between already-populated tabs is a no-op refresh. 

252 self._grid_cache_keys: dict[str, tuple] = {} 

253 self._list_cache_keys: dict[str, tuple] = {} 

254 self._search_in_flight: bool = False 

255 self._frontier_rows: list[FrontierCatalogRow] = [] 

256 # Bumped on every worker callback so the _all_*_rows caches 

257 # invalidate even when collection lengths happen to coincide. 

258 self._data_version: int = 0 

259 self._family_rows_cache: _RowCacheEntry | None = None 

260 self._hf_rows_cache: _RowCacheEntry | None = None 

261 self._remote_rows_cache: _RowCacheEntry | None = None 

262 self._view_switching: bool = False 

263 self._frontier_refresh_timer: Timer | None = None 

264 self._search_filter_timer: Timer | None = None 

265 self._scroll_prefetch_armed_at: float = 0.0 

266 self._spinner_timer: Timer | None = None 

267 self._spinner_frame: int = 0 

268 # Active-tab cache + per-tab widget memoization. Avoids a second 

269 # query_one on every _grid_container / _list_widget access. Default 

270 # matches the TabbedContent's initial= value below. 

271 self._active_tab_id_cache: str = TAB_CHAT 

272 self._tab_grid_cache: dict[str, VerticalScroll] = {} 

273 self._tab_list_cache: dict[str, ModelList] = {} 

274 # During initial mount Textual fires TabActivated for whichever pane 

275 # ends up first in compose order (Discover) before our explicit 

276 # call_after_refresh setter activates Chat. Suppressing cache writes 

277 # while this flag is False keeps the cache pinned to its TAB_CHAT 

278 # __init__ default through the race; user-driven tab switches after 

279 # mount flip the flag and re-arm normal cache updates. 

280 self._activation_settled: bool = False 

281 # Per-tab source mode (local / cloud / both). Defaults to LOCAL on 

282 # every task tab so the catalog opens on the same row set the 

283 # mega-grid era surfaced; users opt into cloud-mixed views via `c`. 

284 self._source_modes: dict[str, SourceMode] = { 

285 tab_id: SourceMode.LOCAL for tab_id in TASK_TAB_IDS 

286 } 

287 # Hardware-fit baseline. Captured once at construction so the 

288 # cached row-build path can stamp each row's fit chip without 

289 # re-probing on every refresh. 

290 self._available_memory_bytes: int | None = available_memory_for_fit() 

291 

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

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

294 

295 Discover has no grid; falls through to TAB_CHAT so callers that 

296 access ``_grid_container`` while Discover is active never crash. 

297 Cached references are validated via ``is_running`` so a stale 

298 post-remount handle gets refreshed transparently. 

299 """ 

300 target = TAB_CHAT if tab_id == TAB_DISCOVER else tab_id 

301 cached = self._tab_grid_cache.get(target) 

302 if cached is not None and cached.is_running: 

303 return cached 

304 container = self.query_one(f"#{_GRID_ID_PREFIX}{target}", VerticalScroll) 

305 self._tab_grid_cache[target] = container 

306 return container 

307 

308 def _list_for_tab(self, tab_id: str) -> ModelList: 

309 """Return (and memoize) the ModelList for *tab_id*. Same fallthrough as _grid_for_tab.""" 

310 target = TAB_CHAT if tab_id == TAB_DISCOVER else tab_id 

311 cached = self._tab_list_cache.get(target) 

312 if cached is not None and cached.is_running: 

313 return cached 

314 widget = self.query_one(f"#{_LIST_ID_PREFIX}{target}", ModelList) 

315 self._tab_list_cache[target] = widget 

316 return widget 

317 

318 @property 

319 def _grid_container(self) -> VerticalScroll: 

320 return self._grid_for_tab(self._active_tab_id_cache) 

321 

322 @property 

323 def _list_widget(self) -> ModelList: 

324 return self._list_for_tab(self._active_tab_id_cache) 

325 

326 @property 

327 def _search_focused(self) -> bool: 

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

329 

330 Used to short-circuit digit / single-character action handlers so the 

331 keystroke lands in the search field instead of activating a tab. 

332 """ 

333 return isinstance(self.focused, Input) 

334 

335 def compose(self) -> ComposeResult: 

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

337 

338 with TopBars(): 

339 yield ViewTabs() 

340 yield Input( 

341 placeholder=msg.CATALOG_FILTER_PLACEHOLDER, 

342 id="catalog-search", 

343 classes="-hidden", 

344 ) 

345 with Horizontal(id="catalog-toolbar"): 

346 yield GridListToggle() 

347 yield Static("", id="sort-label", shrink=True) 

348 yield Static("", id="catalog-loading-spinner") 

349 # Horizontal split: TabbedContent fills, CatalogDetailDrawer docks 

350 # right at fixed width and toggles via the -collapsed class. Each 

351 # per-task tab has its own VerticalScroll + ModelList so prefetch 

352 # only extends the active tab's grid; the single mega-grid was the 

353 # source of cross-section viewport jumps on pagination. 

354 with Horizontal(id="catalog-body"): 

355 with ( 

356 Container(id="catalog-tabs-wrap"), 

357 TabbedContent(initial=TAB_CHAT, id="catalog-tabs"), 

358 ): 

359 with TabPane(msg.CATALOG_TAB_DISCOVER, id=TAB_DISCOVER): 

360 yield DiscoverRails(id="discover-rails") 

361 with TabPane(msg.CATALOG_TAB_CHAT, id=TAB_CHAT): 

362 yield VerticalScroll( 

363 id=f"{_GRID_ID_PREFIX}{TAB_CHAT}", classes="catalog-grid-pane" 

364 ) 

365 yield ModelList(id=f"{_LIST_ID_PREFIX}{TAB_CHAT}") 

366 with TabPane(msg.CATALOG_TAB_EMBED, id=TAB_EMBED): 

367 yield VerticalScroll( 

368 id=f"{_GRID_ID_PREFIX}{TAB_EMBED}", classes="catalog-grid-pane" 

369 ) 

370 yield ModelList(id=f"{_LIST_ID_PREFIX}{TAB_EMBED}") 

371 with TabPane(msg.CATALOG_TAB_VISION, id=TAB_VISION): 

372 yield VerticalScroll( 

373 id=f"{_GRID_ID_PREFIX}{TAB_VISION}", classes="catalog-grid-pane" 

374 ) 

375 yield ModelList(id=f"{_LIST_ID_PREFIX}{TAB_VISION}") 

376 with TabPane(msg.CATALOG_TAB_RERANK, id=TAB_RERANK): 

377 yield VerticalScroll( 

378 id=f"{_GRID_ID_PREFIX}{TAB_RERANK}", classes="catalog-grid-pane" 

379 ) 

380 yield ModelList(id=f"{_LIST_ID_PREFIX}{TAB_RERANK}") 

381 with TabPane(msg.CATALOG_TAB_LIBRARY, id=TAB_LIBRARY): 

382 yield VerticalScroll( 

383 id=f"{_GRID_ID_PREFIX}{TAB_LIBRARY}", classes="catalog-grid-pane" 

384 ) 

385 yield ModelList(id=f"{_LIST_ID_PREFIX}{TAB_LIBRARY}") 

386 yield CatalogDetailDrawer(id="catalog-detail-drawer", classes="-collapsed") 

387 with BottomBars(): 

388 yield TaskBar() 

389 yield Footer() 

390 

391 def on_mount(self) -> None: 

392 self._fetch_installed_names() 

393 # Force Chat as the initial active tab. `TabbedContent(initial=...)` 

394 # doesn't take effect when panes are added via `with TabPane(...)` 

395 # (Textual resolves initial at construction time but the panes mount 

396 # after), so we set active explicitly via call_after_refresh so the 

397 # TabActivated cascade has already settled before our setter runs. 

398 # Chat is the most common landing destination; users opt into 

399 # Discover via keyboard shortcut. 

400 self.call_after_refresh(self._activate_initial_tab) 

401 self.add_class("-grid-view") 

402 

403 def _activate_initial_tab(self) -> None: 

404 try: 

405 tabs = self.query_one("#catalog-tabs", TabbedContent) 

406 except Exception: 

407 self._activation_settled = True 

408 return 

409 if self._active_tab_id_cache == TAB_CHAT and tabs.active != TAB_CHAT: 

410 tabs.active = TAB_CHAT 

411 if not self._activation_settled: 

412 self._activation_settled = True 

413 self.call_after_refresh(self._refresh_grid) 

414 self.call_after_refresh(self._initial_focus_first_grid) 

415 self._fetch_remote_models() 

416 self._fetch_frontier_models() 

417 # Eagerly load the HF catalog for the initial chat tab. Sibling 

418 # task tabs fetch lazily on first activation (see 

419 # `_on_catalog_tab_activated`) so opening the catalog only costs 

420 # one HF round-trip instead of four. 

421 self._ensure_task_initial_fetch(ModelTask.CHAT) 

422 self.app.provider_availability_changed_signal.subscribe( 

423 self, self._on_provider_availability_changed 

424 ) 

425 # Auto-load more HF rows when scrolled near the bottom in either view. 

426 # Watch every per-task tab's container plus the Library container. 

427 # Inactive tabs never scroll, so the handler runs only for the active 

428 # tab; this is cheaper than tearing down and re-installing the watch 

429 # on every tab activation. 

430 for tab_id in (*TASK_TAB_IDS, TAB_LIBRARY): 

431 with contextlib.suppress(Exception): 

432 self.watch( 

433 self._list_for_tab(tab_id), "scroll_y", self._on_list_scrolled, init=False 

434 ) 

435 self.watch( 

436 self._grid_for_tab(tab_id), "scroll_y", self._on_grid_scrolled, init=False 

437 ) 

438 

439 def on_unmount(self) -> None: 

440 with contextlib.suppress(Exception): 

441 self.app.provider_availability_changed_signal.unsubscribe(self) 

442 self._stop_spinner_timer() 

443 

444 def on_screen_suspend(self) -> None: 

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

446 

447 Without this the 100 ms braille tick keeps firing for the full 

448 TUI session even when the catalog is not visible, costing ~4% 

449 of main-thread CPU forever. 

450 """ 

451 self._stop_spinner_timer() 

452 

453 def on_screen_resume(self) -> None: 

454 """Re-arm the spinner only if a fetch is still in flight.""" 

455 if self._loading_more or self._search_in_flight: 

456 self._sync_loading_spinner() 

457 

458 def _stop_spinner_timer(self) -> None: 

459 if self._spinner_timer is not None: 

460 self._spinner_timer.stop() 

461 self._spinner_timer = None 

462 

463 _FRONTIER_REFRESH_DEBOUNCE = 1.0 

464 

465 def _on_provider_availability_changed(self, _payload: tuple[str, object]) -> None: 

466 """Debounced refetch of frontier rows when an API key changes.""" 

467 if self._frontier_refresh_timer is not None: 

468 self._frontier_refresh_timer.stop() 

469 self._frontier_refresh_timer = self.set_timer( 

470 self._FRONTIER_REFRESH_DEBOUNCE, self._fetch_frontier_models 

471 ) 

472 

473 def _focus_first_grid(self) -> None: 

474 """Focus the first grid widget in the active tab's container.""" 

475 for cls in (ModelGrid, GridSelect): 

476 with contextlib.suppress(Exception): 

477 self._grid_container.query(cls).first().focus() 

478 return 

479 

480 def _initial_focus_first_grid(self) -> None: 

481 """on_mount initial focus: skip if a later refresh-tick has already 

482 landed focus elsewhere (e.g. a test focused #catalog-search before 

483 the streaming-section mount drained its scheduled callbacks).""" 

484 if self.focused is not None: 

485 return 

486 self._focus_first_grid() 

487 

488 def _fetch_installed_names(self) -> None: 

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

490 

491 The set contains both the canonical ref (``hf_repo/filename``) and 

492 the bare ``hf_repo`` so catalog rows whose ref is the repo alone 

493 still light up as installed when at least one quant of that repo 

494 has a manifest. 

495 """ 

496 with contextlib.suppress(Exception): 

497 self._installed_names = set(get_services().model_manager.list_native_identities()) 

498 self._data_version += 1 

499 

500 def _active_tab_id(self) -> str: 

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

502 

503 The cache is updated by ``_on_catalog_tab_activated`` so this is a 

504 bare attribute read, not a DOM walk. Prefer this over a fresh 

505 ``TabbedContent.active`` lookup on every check. 

506 """ 

507 return self._active_tab_id_cache 

508 

509 def _active_task(self) -> ModelTask | None: 

510 """Return the active tab's task, or None on Discover / Library.""" 

511 return TAB_ID_TO_TASK.get(self._active_tab_id()) 

512 

513 def _active_task_has_more(self) -> bool: 

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

515 

516 Discover and Library tabs return False; neither paginates. 

517 """ 

518 task = self._active_task() 

519 if task is None: 

520 return False 

521 return self._hf_has_more_by_task.get(task, False) 

522 

523 def _hf_fetched_any(self) -> bool: 

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

525 

526 Renders gate HF sections on this so the catalog doesn't paint 

527 empty HF rows before the first per-task fetch lands. 

528 """ 

529 return bool(self._hf_fetched_tasks) 

530 

531 def _ensure_task_initial_fetch(self, task: ModelTask) -> None: 

532 """Fire the per-task initial HF fetch once; idempotent on repeats.""" 

533 if task in self._hf_fetched_tasks: 

534 return 

535 self._hf_fetched_tasks.add(task) 

536 self._fetch_initial_hf_models_for_task(task) 

537 

538 def action_toggle_view(self) -> None: 

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

540 

541 Mid-toggle re-entry would tear the DOM (one toggle's mount_all 

542 running while the previous toggle's remove_children is still in 

543 flight). The _view_switching gate makes the toggle atomic. 

544 Discover and Library tabs don't expose the toggle. 

545 """ 

546 if self._active_tab_id() not in TASK_TAB_IDS: 

547 return 

548 if self._view_switching: 

549 return 

550 self._view_switching = True 

551 try: 

552 if self._grid_view: 

553 self._grid_view = False 

554 self.remove_class("-grid-view") 

555 self.add_class("-list-view") 

556 active_task = TAB_ID_TO_TASK.get(self._active_tab_id()) 

557 if active_task is not None: 

558 self._ensure_task_initial_fetch(active_task) 

559 with self.app.batch_update(): 

560 self._refresh_list() 

561 self._focus_list_item(0) 

562 else: 

563 self._grid_view = True 

564 self.remove_class("-list-view") 

565 self.add_class("-grid-view") 

566 with self.app.batch_update(): 

567 self._refresh_grid() 

568 with contextlib.suppress(Exception): 

569 self._grid_container.query_one(ModelGrid).focus() 

570 finally: 

571 self._view_switching = False 

572 self._sync_grid_list_toggle() 

573 

574 def _sync_grid_list_toggle(self) -> None: 

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

576 

577 with contextlib.suppress(Exception): 

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

579 

580 def action_focus_search(self) -> None: 

581 """Reveal and focus the filter input. Bound to / key.""" 

582 self._search_input.remove_class("-hidden") 

583 self._search_input.focus() 

584 

585 _SEARCH_FILTER_DEBOUNCE_SECONDS = 0.08 

586 

587 @on(Input.Changed, "#catalog-search") 

588 def _on_search_changed(self, event: Input.Changed) -> None: 

589 """Schedule a filter pass after a short debounce. 

590 

591 Each keystroke triggers a grid re-render or a list redraw, both of 

592 which Textual treats as layout invalidations. Without the debounce 

593 a 5-char term produces 5 full passes; with it, typing collapses 

594 to a single pass once the user pauses. 

595 """ 

596 if self._search_filter_timer is not None: 

597 self._search_filter_timer.stop() 

598 self._search_filter_timer = self.set_timer( 

599 self._SEARCH_FILTER_DEBOUNCE_SECONDS, 

600 self._apply_search_filter, 

601 ) 

602 

603 def _apply_search_filter(self) -> None: 

604 if self._active_tab_id() == TAB_LIBRARY: 

605 self._populate_library_list() 

606 return 

607 if self._active_tab_id() == TAB_DISCOVER: 

608 return 

609 if self._grid_view: 

610 self._filter_grid() 

611 else: 

612 self._filter_list() 

613 

614 @on(Input.Submitted, "#catalog-search") 

615 def _on_search_submitted(self, event: Input.Submitted) -> None: 

616 """Enter installs the first visible match; falls through to a remote 

617 HF search when nothing matches locally.""" 

618 if self._grid_view: 

619 if any(grid.rows for grid in self._grid_container.query(ModelGrid)): 

620 self._select_first_visible_grid_card() 

621 return 

622 elif self._list_widget.option_count: 

623 self._select_first_visible_list_item() 

624 return 

625 self._trigger_remote_search(self._get_search_text()) 

626 

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

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

629 

630 Search is task-scoped so typing on the Chat tab only surfaces chat 

631 models; embedding/vision/rerank rows can never leak into the active 

632 list. Non-task tabs (Discover/Library) can't reach this path because 

633 the search Input is hidden on them. 

634 """ 

635 if self._search_in_flight or not query: 

636 return 

637 active_task = TAB_ID_TO_TASK.get(self._active_tab_id()) 

638 if active_task is None: 

639 return 

640 self._search_in_flight = True 

641 self._update_sort_label() 

642 self._sync_loading_spinner() 

643 # Sort label is hidden in grid view, so the toast is the only feedback there. 

644 self.notify(msg.CATALOG_SEARCHING_HF, timeout=_NOTIFY_SEARCHING_TIMEOUT_SECONDS) 

645 self._fetch_hf_search(query, active_task) 

646 

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

648 def _on_search_hf_cta_clicked(self) -> None: 

649 self._trigger_remote_search(self._get_search_text()) 

650 

651 def _select_first_visible_grid_card(self) -> None: 

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

653 

654 Without the "first visible" walk, focusing any grid with 

655 ``highlighted = 0`` could land on a card the filter just hid, 

656 and Enter would install the wrong model. Setting 

657 ``highlighted`` to the first visible index guarantees the 

658 install fires on what the user can actually see. 

659 """ 

660 with contextlib.suppress(Exception): 

661 for grid in self._grid_container.query(ModelGrid): 

662 if grid.rows: 

663 grid.focus() 

664 grid.highlighted = 0 

665 grid.action_select() 

666 return 

667 

668 def _select_first_visible_list_item(self) -> None: 

669 """List-view counterpart: highlight + select the first row.""" 

670 with contextlib.suppress(Exception): 

671 if self._list_widget.option_count: 

672 self._list_widget.highlighted = 0 

673 self._list_widget.focus() 

674 self._list_widget.action_select() 

675 

676 def _fetch_hf_page_for_task(self, task: ModelTask) -> list[CatalogModel]: 

677 """Fetch one HF page for *task* at the task's own offset. 

678 

679 Dedupes against repos already in ``self._hf_models`` so re-fetches 

680 from a stale offset don't double-count rows. Writes the per-task 

681 ``has_more`` directly on the screen from the worker thread; the 

682 dict assignment is GIL-atomic and the main thread only reads. 

683 """ 

684 offset = self._hf_offset_by_task[task] 

685 result = get_catalog( 

686 task=task, 

687 featured=False, 

688 limit=_HF_PAGE_SIZE, 

689 offset=offset, 

690 ) 

691 self._hf_has_more_by_task[task] = result.has_more 

692 existing_repos = {m.hf_repo for m in self._hf_models} 

693 return [m for m in result.models if not m.featured and m.hf_repo not in existing_repos] 

694 

695 @work(thread=True, name=_WORKER_FETCH_HF) 

696 def _fetch_initial_hf_models_for_task(self, task: ModelTask) -> list[CatalogModel]: 

697 """Fetch the first HF page for *task* (extends the merged store).""" 

698 return self._fetch_hf_page_for_task(task) 

699 

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

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

702 return classify_remote_models(cfg.remote_base_url) 

703 

704 @work(thread=True, name=_WORKER_FETCH_FRONTIER, exit_on_error=False) 

705 def _fetch_frontier_models(self) -> list[FrontierCatalogRow]: 

706 """Discover cloud chat models off the UI thread. 

707 

708 ``discover_api_models`` imports litellm (heavy, >50ms) and probes 

709 every provider key, totaling several hundred ms even when no 

710 keys are set. Running it on the main thread froze the catalog 

711 on mount and on every signal-driven refresh; the worker keeps 

712 the screen responsive.""" 

713 from lilbee.modelhub.model_manager import discover_api_models 

714 

715 try: 

716 groups = discover_api_models() 

717 except Exception: 

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

719 return [] 

720 

721 rows: list[FrontierCatalogRow] = [] 

722 for display_name, models in groups.items(): 

723 provider_id = display_name.lower() 

724 has_key = get_provider_api_key(provider_id) is not None 

725 status = KeyStatus.READY if has_key else KeyStatus.MISSING_KEY 

726 for rm in models: 

727 rows.append( 

728 frontier_row_from_remote(rm, provider_id=provider_id, key_status=status) 

729 ) 

730 rows.sort(key=lambda r: (r.provider, r.name.lower())) 

731 return rows 

732 

733 @work(thread=True, name=_WORKER_FETCH_MORE_HF) 

734 def _fetch_more_hf_for_task(self, task: ModelTask) -> list[CatalogModel]: 

735 """Fetch the next HF page for *task* (extends the merged store).""" 

736 return self._fetch_hf_page_for_task(task) 

737 

738 @work(thread=True, name=_WORKER_FETCH_SEARCH, exit_on_error=False) 

739 def _fetch_hf_search(self, query: str, task: ModelTask) -> list[CatalogModel]: 

740 """Fetch HF models matching *query* for *task* only (worker thread).""" 

741 existing_repos = {m.hf_repo for m in self._hf_models} 

742 result = get_catalog( 

743 task=task, 

744 featured=False, 

745 search=query, 

746 limit=_HF_PAGE_SIZE, 

747 offset=0, 

748 ) 

749 return [m for m in result.models if not m.featured and m.hf_repo not in existing_repos] 

750 

751 def on_worker_state_changed(self, event: Worker.StateChanged) -> None: 

752 # PENDING/RUNNING fire here too; only ERROR/CANCELLED should release latches. 

753 if event.state in (WorkerState.ERROR, WorkerState.CANCELLED): 

754 self._handle_worker_error_or_cancel(event.worker.name) 

755 return 

756 if event.state != WorkerState.SUCCESS: 

757 return 

758 result = event.worker.result 

759 if not isinstance(result, list): 

760 return 

761 worker_name = event.worker.name 

762 if not self._apply_worker_result(worker_name, result): 

763 return 

764 # A fast worker can complete before TabbedContent finishes mounting 

765 # its panes; tolerate that and let the deferred _refresh_grid that 

766 # _activate_initial_tab schedules rebuild against the applied state. 

767 from textual.css.query import NoMatches 

768 

769 with contextlib.suppress(NoMatches): 

770 # FETCH_MORE_HF appends to the active view's tail; skip the full 

771 # _refresh_view rebuild so scroll position and focus are preserved. 

772 if worker_name == _WORKER_FETCH_MORE_HF: 

773 if self._grid_view: 

774 self._refresh_grid() 

775 else: 

776 self._append_more_hf_to_list(result) 

777 return 

778 self._refresh_view() 

779 

780 def _append_more_hf_to_list(self, new_models: list[CatalogModel]) -> None: 

781 """Append newly-arrived HF rows to the active task tab's list. 

782 

783 Falls back to a full ``_refresh_view`` on the rare tab-switch 

784 race where the worker's payload no longer matches the active 

785 task; otherwise a blind extend would leak foreign rows into a 

786 sibling tab's list. 

787 """ 

788 active_task = self._active_task() 

789 if active_task is None or any(m.task != active_task for m in new_models): 

790 self._refresh_view() 

791 return 

792 new_rows = self._sort_rows( 

793 [ 

794 catalog_to_row(m, installed=self._is_installed(m.ref, m.hf_repo, m.gguf_filename)) 

795 for m in new_models 

796 ] 

797 ) 

798 if not new_rows: 

799 self._update_sort_label() 

800 return 

801 self._rows.extend(new_rows) 

802 self._list_widget.append_rows(list(new_rows)) 

803 self._list_cache_key = ( 

804 tuple((r.name, r.installed) for r in self._rows), 

805 self._get_search_text(), 

806 ) 

807 self._update_sort_label() 

808 

809 def _handle_worker_error_or_cancel(self, name: str) -> None: 

810 if name == _WORKER_FETCH_MORE_HF: 

811 self._loading_more = False 

812 if name == _WORKER_FETCH_SEARCH: 

813 self._search_in_flight = False 

814 self._update_sort_label() 

815 self._sync_loading_spinner() 

816 

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

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

819 

820 Returns True when the screen should refresh its view, False when 

821 the worker name is unrecognized (defensive: a future @work 

822 decorator name won't silently rebuild the grid).""" 

823 if name == _WORKER_FETCH_HF: 

824 # Per-task initial fetches all share this worker name; each 

825 # one carries dedup-filtered new rows (see 

826 # ``_fetch_hf_page_for_task``) so extend is correct here. 

827 self._hf_models.extend(result) 

828 self._loading_more = False 

829 elif name == _WORKER_FETCH_MORE_HF: 

830 self._hf_models.extend(result) 

831 self._loading_more = False 

832 elif name == _WORKER_FETCH_SEARCH: 

833 self._hf_models.extend(result) 

834 self._search_in_flight = False 

835 self._update_sort_label() 

836 elif name == _WORKER_FETCH_REMOTE: 

837 self._remote_models = result 

838 elif name == _WORKER_FETCH_FRONTIER: 

839 self._frontier_rows = result 

840 self._populate_library_list() 

841 else: 

842 return False 

843 self._data_version += 1 

844 self._sync_loading_spinner() 

845 # If the user is parked on Discover, re-populate the rails so the 

846 # Fresh-on-the-Hub strip fills as HF rows arrive. Without this the 

847 # rail stays empty for the lifetime of the Discover view because 

848 # _populate_discover_rails fires only on tab activation. 

849 if self._active_tab_id_cache == TAB_DISCOVER: 

850 self._populate_discover_rails() 

851 return True 

852 

853 def _populate_library_list(self) -> None: 

854 """Render the Library tab: installed local + activated cloud APIs in both views.""" 

855 search = self._get_search_text() 

856 installed_rows: list[LocalCatalogRow] = [] 

857 for source in (self._all_family_rows, self._all_hf_rows, self._all_remote_rows): 

858 with contextlib.suppress(AttributeError): 

859 installed_rows.extend(r for r in source() if r.installed) 

860 if search: 

861 installed_rows = [r for r in installed_rows if matches_search(r, search)] 

862 frontier: list[FrontierCatalogRow] = [] 

863 with contextlib.suppress(AttributeError): 

864 frontier = self._build_frontier_rows(search) 

865 self._render_library_list(installed_rows, frontier) 

866 self._render_library_grid(installed_rows, frontier) 

867 

868 def _render_library_list( 

869 self, 

870 installed_rows: list[LocalCatalogRow], 

871 frontier: list[FrontierCatalogRow], 

872 ) -> None: 

873 try: 

874 ml = self._list_for_tab(TAB_LIBRARY) 

875 except Exception: 

876 return 

877 sections: list[ModelListSection] = [] 

878 if installed_rows: 

879 sections.append( 

880 ModelListSection(heading=msg.HEADING_INSTALLED, rows=list(installed_rows)) 

881 ) 

882 sections.extend(group_frontier_rows(frontier)) 

883 ml.set_rows(sections) 

884 

885 def _render_library_grid( 

886 self, 

887 installed_rows: list[LocalCatalogRow], 

888 frontier: list[FrontierCatalogRow], 

889 ) -> None: 

890 try: 

891 container = self._grid_for_tab(TAB_LIBRARY) 

892 except Exception: 

893 return 

894 sections: list[GridSection] = [] 

895 if installed_rows: 

896 sections.append(GridSection(heading=msg.HEADING_INSTALLED, rows=list(installed_rows))) 

897 if frontier: 

898 sections.append(GridSection(heading="Cloud", rows=list(frontier))) 

899 existing_grids = list(container.query(ModelGrid)) 

900 existing_headings = [ 

901 w for w in container.query(".section-heading") if isinstance(w, Static) 

902 ] 

903 if existing_grids and len(existing_grids) == len(sections): 

904 for grid, heading, section in zip( 

905 existing_grids, existing_headings, sections, strict=False 

906 ): 

907 heading.update(section.heading) 

908 grid.set_rows(section.rows) 

909 return 

910 container.remove_children() 

911 for section in sections: 

912 container.mount_all( 

913 [ 

914 Static(section.heading, classes="section-heading"), 

915 ModelGrid(section.rows, name=section.heading, classes="catalog-section"), 

916 ] 

917 ) 

918 

919 def _get_search_text(self) -> str: 

920 # Deferred refresh callbacks can land while the screen is between 

921 # mount cycles (e.g. switch_view chaining); the descriptor query 

922 # would otherwise raise NoMatches and crash the callback. 

923 try: 

924 return self._search_input.value.strip() 

925 except Exception: 

926 return "" 

927 

928 def _local_rows_data_key(self) -> _RowCacheKey: 

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

930 

931 ``_data_version`` covers replacements and extensions both; 

932 search text deliberately omitted (we filter cached rows). 

933 """ 

934 return ( 

935 len(self._families), 

936 len(self._hf_models), 

937 len(self._remote_models), 

938 len(self._hf_fetched_tasks), 

939 len(self._installed_names), 

940 self._data_version, 

941 ) 

942 

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

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

945 

946 The mega-grid era emitted one row per ``ModelVariant``; the same 

947 family showed up three or four times stacked next to each other, 

948 once per quant. The redesign collapses each family into a single 

949 card whose ``size_variants`` strip carries every quant. Primary 

950 variant (recommended; otherwise the smallest) drives the card's 

951 primary metadata + fit chip; the strip lets users pick a 

952 non-primary size without leaving the grid. 

953 """ 

954 key = self._local_rows_data_key() 

955 cached = self._family_rows_cache 

956 if cached is not None and cached.key == key: 

957 return cached.rows 

958 rows: list[LocalCatalogRow] = [] 

959 for fam in self._families: 

960 if not fam.variants: 

961 continue 

962 primary = next( 

963 (v for v in fam.variants if v.recommended), 

964 min(fam.variants, key=lambda v: v.size_mb), 

965 ) 

966 family_installed = any( 

967 self._is_installed(v.hf_repo, repo=v.hf_repo, filename=v.filename) 

968 for v in fam.variants 

969 ) 

970 row = variant_to_row(primary, fam, family_installed) 

971 row.size_variants = family_to_size_variants(fam) 

972 rows.append(row) 

973 self._stamp_fit(rows) 

974 self._family_rows_cache = _RowCacheEntry(key=key, rows=rows) 

975 return rows 

976 

977 def _all_hf_rows(self) -> list[LocalCatalogRow]: 

978 key = self._local_rows_data_key() 

979 cached = self._hf_rows_cache 

980 if cached is not None and cached.key == key: 

981 return cached.rows 

982 rows: list[LocalCatalogRow] = [] 

983 for m in self._hf_models: 

984 installed = self._is_installed(m.ref, repo=m.hf_repo, filename=m.gguf_filename) 

985 rows.append(catalog_to_row(m, installed)) 

986 self._stamp_fit(rows) 

987 self._hf_rows_cache = _RowCacheEntry(key=key, rows=rows) 

988 return rows 

989 

990 def _all_remote_rows(self) -> list[LocalCatalogRow]: 

991 key = self._local_rows_data_key() 

992 cached = self._remote_rows_cache 

993 if cached is not None and cached.key == key: 

994 return cached.rows 

995 rows = [remote_to_row(rm) for rm in self._remote_models] 

996 # Remote rows don't carry a known size; _stamp_fit no-ops on those. 

997 self._stamp_fit(rows) 

998 self._remote_rows_cache = _RowCacheEntry(key=key, rows=rows) 

999 return rows 

1000 

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

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

1003 

1004 Runs only inside the cached row builders, so this is one pass per 

1005 data refresh, not per render. Rows whose ``sort_size`` is zero 

1006 (remote / unknown size) leave ``fit`` as ``None`` and the card 

1007 renderer omits the chip. Available-memory probe is captured once 

1008 at __init__; if the probe failed, every row falls through chip-less. 

1009 """ 

1010 if self._available_memory_bytes is None: 

1011 return 

1012 bytes_per_gb = 1024**3 

1013 for row in rows: 

1014 if row.sort_size <= 0: 

1015 continue 

1016 row.fit = compute_fit( 

1017 model_size_bytes=int(row.sort_size * bytes_per_gb), 

1018 available_bytes=self._available_memory_bytes, 

1019 ) 

1020 

1021 def _build_rows(self) -> list[LocalCatalogRow]: 

1022 """Build filtered table rows from current data sources.""" 

1023 search = self._get_search_text() 

1024 rows: list[LocalCatalogRow] = [] 

1025 rows.extend(self._build_family_rows(search)) 

1026 rows.extend(self._build_hf_rows(search)) 

1027 rows.extend(self._build_remote_rows(search)) 

1028 return rows 

1029 

1030 def _build_family_rows(self, search: str) -> list[LocalCatalogRow]: 

1031 """Filter the cached family rows against the active search.""" 

1032 if not search: 

1033 return self._all_family_rows() 

1034 return [r for r in self._all_family_rows() if matches_search(r, search)] 

1035 

1036 def _build_hf_rows(self, search: str) -> list[LocalCatalogRow]: 

1037 """Filter the cached HF rows against the active search.""" 

1038 if not search: 

1039 return self._all_hf_rows() 

1040 return [r for r in self._all_hf_rows() if matches_search(r, search)] 

1041 

1042 def _build_remote_rows(self, search: str) -> list[LocalCatalogRow]: 

1043 """Filter the cached remote rows against the active search.""" 

1044 if not search: 

1045 return self._all_remote_rows() 

1046 return [r for r in self._all_remote_rows() if matches_search(r, search)] 

1047 

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

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

1050 

1051 The discovery itself runs in :meth:`_fetch_frontier_models` (a 

1052 worker) because litellm import + key probing blocks the UI 

1053 thread. Renderers call this synchronously to read the 

1054 already-discovered rows, so no I/O happens here. 

1055 """ 

1056 if not self._frontier_rows: 

1057 return [] 

1058 return [row for row in self._frontier_rows if matches_search(row, search)] 

1059 

1060 def _is_installed(self, name: str, repo: str = "", filename: str = "") -> bool: 

1061 """Check if a model is installed by name or source repo/filename.""" 

1062 if name in self._installed_names: 

1063 return True 

1064 if repo and filename: 

1065 return f"{repo}/{filename}" in self._installed_names 

1066 return False 

1067 

1068 def _sort_rows(self, rows: list[LocalCatalogRow]) -> list[LocalCatalogRow]: 

1069 """Sort rows: featured first, then by current sort column.""" 

1070 key_fn = SORT_KEYS.get(self._sort_column, SORT_KEYS["Name"]) 

1071 # Stable sort: featured always first, then by column 

1072 return sorted( 

1073 rows, 

1074 key=lambda r: (not r.featured, key_fn(r)), 

1075 reverse=not self._sort_ascending, 

1076 ) 

1077 

1078 def _refresh_view(self) -> None: 

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

1080 

1081 Mount/remove of dozens of widgets is wrapped in batch_update so 

1082 Textual coalesces layout passes; without it, the worker callback 

1083 path can land inside an in-flight grid-list toggle and tear the 

1084 DOM.""" 

1085 with self.app.batch_update(): 

1086 if self._grid_view: 

1087 self._refresh_grid() 

1088 else: 

1089 self._refresh_list() 

1090 

1091 def _refresh_grid(self) -> None: 

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

1093 

1094 Initial paint mounts everything (first time a tab is opened). 

1095 Subsequent dataset updates (HF pagination, sort change, filter) 

1096 update each existing ModelGrid via set_rows rather than tearing 

1097 the container down and re-mounting from scratch. Avoids a 100% 

1098 CPU spike on every "Browse more" return. 

1099 """ 

1100 prep = self._prepare_grid_refresh() 

1101 if prep is None: 

1102 self._update_sort_label() 

1103 return 

1104 sections, hf_count = prep 

1105 if not sections: 

1106 self._grid_container.remove_children() 

1107 self._mount_grid_ctas(hf_count=hf_count) 

1108 self._update_sort_label() 

1109 return 

1110 if self._extend_grid_sections_in_place(sections, hf_count): 

1111 return 

1112 self._remount_grid_sections(sections, hf_count) 

1113 self._update_sort_label() 

1114 

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

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

1117 

1118 On the None branch the caller refreshes the sort label so the 

1119 cached path still picks up sort-toggle clicks. 

1120 """ 

1121 search = self._get_search_text() 

1122 family_rows = self._build_family_rows(search) 

1123 remote_rows = self._build_remote_rows(search) 

1124 hf_rows = self._build_hf_rows(search) if self._hf_fetched_any() else [] 

1125 all_rows = family_rows + remote_rows + hf_rows 

1126 active_tab = self._active_tab_id_cache 

1127 tab_rows = self._rows_for_active_tab(all_rows, active_tab) 

1128 # Keep self._rows in sync (locals-only) so the toolbar sort-label 

1129 # can render "{n} loaded" whichever view (grid or list) is active. 

1130 # Frontier rows render in their own Cloud section but don't count 

1131 # toward the local-row tally. 

1132 local_tab_rows: list[LocalCatalogRow] = [ 

1133 r for r in tab_rows if r.kind == CatalogRowKind.LOCAL 

1134 ] 

1135 self._rows = local_tab_rows 

1136 row_key = ( 

1137 tuple(row_cache_signature(r) for r in tab_rows), 

1138 search, 

1139 ) 

1140 # Per-tab cache key: switching back to an already-rendered tab 

1141 # is a no-op refresh; only sort-label refreshes. Keyed by 

1142 # active_tab so other tabs' caches survive in-place. 

1143 if self._grid_cache_keys.get(active_tab) == row_key: 

1144 return None 

1145 self._grid_cache_keys[active_tab] = row_key 

1146 if active_tab in TASK_TAB_IDS: 

1147 active_task = TAB_ID_TO_TASK[active_tab] 

1148 task_label = active_task.value.capitalize() 

1149 # Split locals and frontier so the picks/installed grouping 

1150 # only sees LocalCatalogRow (it reads .featured / .installed 

1151 # which FrontierCatalogRow doesn't carry). Frontier rows land 

1152 # under their own "Cloud" section appended below. 

1153 frontier_only = [r for r in tab_rows if r.kind == CatalogRowKind.FRONTIER] 

1154 sections = [s for s in group_task_rows_with_picks(local_tab_rows, task_label) if s.rows] 

1155 if frontier_only: 

1156 sections.append(GridSection(heading="Cloud", rows=list(frontier_only))) 

1157 hf_count = sum(1 for r in hf_rows if r.task == active_task.value) 

1158 else: 

1159 sections = [s for s in group_rows_for_grid(local_tab_rows) if s.rows] 

1160 hf_count = len(hf_rows) 

1161 return sections, hf_count 

1162 

1163 def _extend_grid_sections_in_place(self, sections: list[GridSection], hf_count: int) -> bool: 

1164 """Update existing ModelGrids in place when section count matches. 

1165 

1166 Returns True iff the in-place path applied; the caller falls 

1167 through to a teardown + remount on False. 

1168 """ 

1169 existing_grids = list(self._grid_container.query(ModelGrid)) 

1170 existing_headings = [ 

1171 w for w in self._grid_container.query(".section-heading") if isinstance(w, Static) 

1172 ] 

1173 if not existing_grids or len(existing_grids) != len(sections): 

1174 return False 

1175 # Heading + grid mounts each compose on their own frame, so a 

1176 # partially-mounted state can land here with the heading list 

1177 # one short of the grid list. Drop strict=True so we cleanly 

1178 # update whatever pairs we have without forcing a full remount. 

1179 for grid, heading, section in zip( 

1180 existing_grids, existing_headings, sections, strict=False 

1181 ): 

1182 heading.update(section.heading) 

1183 grid.set_rows(section.rows) 

1184 self._refresh_grid_ctas(hf_count=hf_count) 

1185 self._update_sort_label() 

1186 return True 

1187 

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

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

1190 

1191 Captures the user's current cursor + scroll position before the 

1192 teardown so both can be restored after remount; otherwise the 

1193 ``_focus_first_grid`` fallback snaps the cursor back to the top 

1194 of the catalog mid-keypress, and the layout shift from extra 

1195 sections drifts the visible window away from where the user was 

1196 looking. 

1197 """ 

1198 focus_anchor = self._capture_focused_section() 

1199 container = self._grid_container 

1200 prior_scroll_y = container.scroll_y 

1201 container.remove_children() 

1202 self._mount_grid_section(sections[0]) 

1203 self.call_after_refresh( 

1204 self._mount_remaining_grid_sections, 

1205 sections[1:], 

1206 hf_count=hf_count, 

1207 focus_anchor=focus_anchor, 

1208 prior_scroll_y=prior_scroll_y, 

1209 ) 

1210 

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

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

1213 

1214 Heading is read from ``ModelGrid.name`` (set by 

1215 ``_mount_grid_section``). Used to restore the cursor across a 

1216 teardown+remount in ``_refresh_grid`` so paginated loads don't 

1217 yank the user back to the top of the catalog. 

1218 """ 

1219 focused = self._focused_grid() 

1220 if not isinstance(focused, ModelGrid) or focused.name is None: 

1221 return None 

1222 return (focused.name, focused.highlighted) 

1223 

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

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

1226 

1227 Returns True when the previous focus position was successfully 

1228 restored; False when no anchor was given or the matching section 

1229 no longer exists (caller falls back to ``_focus_first_grid``). 

1230 """ 

1231 if anchor is None: 

1232 return False 

1233 target_heading, target_highlighted = anchor 

1234 for grid in self._grid_container.query(ModelGrid): 

1235 if grid.name != target_heading: 

1236 continue 

1237 grid.focus() 

1238 if target_highlighted is not None and grid.rows: 

1239 grid.highlighted = min(target_highlighted, len(grid.rows) - 1) 

1240 return True 

1241 return False 

1242 

1243 def _mount_grid_section(self, section: GridSection) -> None: 

1244 # ``name=section.heading`` doubles as the section identity used by 

1245 # ``_capture_focused_section`` / ``_restore_focused_section`` to 

1246 # preserve the cursor across teardown + remount. 

1247 grid = ModelGrid(section.rows, name=section.heading, classes="catalog-section") 

1248 self._grid_container.mount_all( 

1249 [ 

1250 Static(section.heading, classes="section-heading"), 

1251 grid, 

1252 ] 

1253 ) 

1254 

1255 def _mount_remaining_grid_sections( 

1256 self, 

1257 remaining: list[GridSection], 

1258 hf_count: int, 

1259 focus_anchor: tuple[str, int | None] | None = None, 

1260 prior_scroll_y: float = 0.0, 

1261 ) -> None: 

1262 for section in remaining: 

1263 self._mount_grid_section(section) 

1264 self._mount_grid_ctas(hf_count=hf_count) 

1265 # Restore the prior viewport position; mounting fresh sections shifts 

1266 # the layout and ``focus()`` below would otherwise overshoot. 

1267 if prior_scroll_y: 

1268 self._grid_container.scroll_to(y=prior_scroll_y, animate=False) 

1269 # Lock focus onto a grid once mount completes so j / k / PgDn / 

1270 # PgUp dispatch correctly. Without this, on first paint the focus 

1271 # race can leave nothing focused and the catalog feels frozen 

1272 # until the user toggles to list view and back. When the previous 

1273 # paint had a focused grid, restore the cursor to the same 

1274 # section + highlighted index instead of jumping to the top. 

1275 if not self._grid_view or self._focused_grid() is not None: 

1276 return 

1277 if self._restore_focused_section(focus_anchor): 

1278 return 

1279 self._focus_first_grid() 

1280 

1281 def _grid_scroll_hint_text(self, hf_count: int) -> str: 

1282 """Pick the bottom scroll-hint text based on fetch state.""" 

1283 if self._loading_more: 

1284 return msg.CATALOG_GRID_LOADING_MORE.format(frame=_SPINNER_FRAMES[self._spinner_frame]) 

1285 if self._active_task_has_more(): 

1286 return msg.CATALOG_GRID_LOAD_MORE.format(count=hf_count) 

1287 return msg.CATALOG_GRID_ALL_LOADED.format(count=hf_count) 

1288 

1289 def _mount_grid_ctas(self, *, hf_count: int) -> None: 

1290 try: 

1291 container = self._grid_container 

1292 except Exception: 

1293 return 

1294 ctas: list[Static] = [ 

1295 Static( 

1296 self._grid_scroll_hint_text(hf_count), 

1297 classes="grid-cta scroll-hint", 

1298 ) 

1299 ] 

1300 search = self._get_search_text() 

1301 if search: 

1302 ctas.append( 

1303 Static( 

1304 msg.CATALOG_SEARCH_HF_CTA.format(query=search), 

1305 classes="grid-cta search-hf-cta", 

1306 ) 

1307 ) 

1308 container.mount_all(ctas) 

1309 

1310 def _refresh_grid_ctas(self, *, hf_count: int) -> None: 

1311 """Update the bottom CTA strip in place; remount when class changes.""" 

1312 try: 

1313 container = self._grid_container 

1314 except Exception: 

1315 return 

1316 existing = list(container.query(".grid-cta")) 

1317 for w in existing: 

1318 with contextlib.suppress(Exception): 

1319 w.remove() 

1320 self._mount_grid_ctas(hf_count=hf_count) 

1321 

1322 def _rows_for_active_tab( 

1323 self, all_rows: list[LocalCatalogRow], active_tab: str 

1324 ) -> list[CatalogRow]: 

1325 """Slice the source row list for what the active task tab should render. 

1326 

1327 Library/Discover bypass this (their refresh paths build their own 

1328 slices). For task tabs, returns rows for the matching ModelTask 

1329 further filtered by the per-tab SourceMode chip; CLOUD and BOTH 

1330 also union the matching frontier rows. 

1331 """ 

1332 if active_tab not in TASK_TAB_IDS: 

1333 return list(all_rows) 

1334 active_task = TAB_ID_TO_TASK[active_tab] 

1335 mode = self._source_modes.get(active_tab, SourceMode.LOCAL) 

1336 local_for_task: list[CatalogRow] = [] 

1337 if mode is not SourceMode.CLOUD: 

1338 local_for_task = [r for r in all_rows if r.task == active_task.value] 

1339 frontier_for_task: list[CatalogRow] = [] 

1340 if mode is not SourceMode.LOCAL: 

1341 frontier_for_task = [r for r in self._frontier_rows if r.task == active_task.value] 

1342 return local_for_task + frontier_for_task 

1343 

1344 def _filter_grid(self) -> None: 

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

1346 self._refresh_grid() 

1347 

1348 @on(ModelGrid.Highlighted) 

1349 def _on_grid_highlighted(self, event: ModelGrid.Highlighted) -> None: 

1350 """Run keyboard-driven prefetch on every grid cursor move and, when 

1351 the cursor lands on the last row of the last grid, scroll the parent 

1352 VerticalScroll to its end so the inline scroll-hint Static comes into 

1353 view (matches the natural overshoot mouse-scroll past the cards 

1354 already produces). Also re-renders the detail drawer for the newly 

1355 highlighted row. 

1356 """ 

1357 self._maybe_prefetch_on_grid_nav() 

1358 self._reveal_scroll_hint_at_catalog_end() 

1359 self._update_drawer_for_grid(event.grid, event.index) 

1360 

1361 def _update_drawer_for_grid(self, grid: ModelGrid, index: int) -> None: 

1362 """Push the focused row into the drawer; no-op if drawer is detached.""" 

1363 try: 

1364 drawer = self.query_one("#catalog-detail-drawer", CatalogDetailDrawer) 

1365 except Exception: 

1366 return 

1367 rows = grid.rows 

1368 row = rows[index] if 0 <= index < len(rows) else None 

1369 drawer.update_for_row(row) 

1370 

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

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

1373 

1374 Bindings with priority=True should win against focused-widget 

1375 bindings, but Textual's TabbedContent's inner ContentTabs swallows 

1376 numeric keypresses before they reach screen-level bindings. An 

1377 explicit on_key handler intercepts the digit at the bubbling stage, 

1378 triggers ``action_select_tab``, and stops further dispatch so the 

1379 digit doesn't bleed into the search Input or another widget. 

1380 """ 

1381 if self._search_focused: 

1382 return 

1383 digit_to_index = {"1": 0, "2": 1, "3": 2, "4": 3, "5": 4, "6": 5} 

1384 index = digit_to_index.get(event.key) 

1385 if index is None: 

1386 return 

1387 event.stop() 

1388 event.prevent_default() 

1389 self.action_select_tab(index) 

1390 

1391 def action_select_tab(self, index: int) -> None: 

1392 """Activate the tab at *index* in ALL_TAB_IDS (0..5).""" 

1393 from lilbee.cli.tui.screens.catalog_utils import ALL_TAB_IDS 

1394 

1395 if self._search_focused: 

1396 return 

1397 if not 0 <= index < len(ALL_TAB_IDS): 

1398 return 

1399 target = ALL_TAB_IDS[index] 

1400 try: 

1401 tabs = self.query_one("#catalog-tabs", TabbedContent) 

1402 except Exception: 

1403 return 

1404 self.set_focus(None) 

1405 if tabs.active != target: 

1406 tabs.active = target 

1407 self._active_tab_id_cache = target 

1408 

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

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

1411 

1412 ctrl+right -> next, ctrl+left -> prev. Wraps so the user can spin 

1413 either direction without hitting an end stop. 

1414 """ 

1415 from lilbee.cli.tui.screens.catalog_utils import ALL_TAB_IDS 

1416 

1417 if self._search_focused: 

1418 return 

1419 try: 

1420 current = ALL_TAB_IDS.index(self._active_tab_id_cache) 

1421 except ValueError: 

1422 current = 0 

1423 next_index = (current + delta) % len(ALL_TAB_IDS) 

1424 self.action_select_tab(next_index) 

1425 

1426 def action_cycle_source(self) -> None: 

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

1428 

1429 No-op outside the four task tabs (Discover/Library aren't filtered 

1430 by source). Per-tab mode means flipping Chat to BOTH doesn't drag 

1431 Embed along; users can keep different views per task. 

1432 """ 

1433 if self._search_focused: 

1434 return 

1435 active = self._active_tab_id_cache 

1436 if active not in TASK_TAB_IDS: 

1437 return 

1438 self._source_modes[active] = next_source_mode(self._source_modes[active]) 

1439 # Force a rebuild on this tab; cache key for this tab is now stale 

1440 # because the source filter changed but the upstream row data didn't. 

1441 self._grid_cache_keys.pop(active, None) 

1442 self._list_cache_keys.pop(active, None) 

1443 self._refresh_view() 

1444 

1445 def action_toggle_drawer(self) -> None: 

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

1447 

1448 Default state is collapsed; users opt in. Class toggle is a single 

1449 layout pass; we don't dynamically mount/unmount the drawer because 

1450 rendering it offscreen costs zero (display: none). 

1451 """ 

1452 try: 

1453 drawer = self.query_one("#catalog-detail-drawer", CatalogDetailDrawer) 

1454 except Exception: 

1455 return 

1456 drawer.toggle_class("-collapsed") 

1457 

1458 def _reveal_scroll_hint_at_catalog_end(self) -> None: 

1459 """Scroll the catalog container to the end when the keyboard cursor 

1460 is on the last row of the bottom-most grid; otherwise no-op so the 

1461 ``watch_highlighted`` cell-into-view scroll keeps tracking the cursor. 

1462 

1463 ``immediate=True`` so the overshoot lands in the same compositor 

1464 frame as the cell-into-view scroll above it; deferred would let a 

1465 subsequent ``parent.scroll_to_region`` re-pin scroll_y to the cell. 

1466 """ 

1467 focused = self._focused_grid() 

1468 if not isinstance(focused, ModelGrid) or focused.highlighted is None: 

1469 return 

1470 grids = list(self._grid_container.query(ModelGrid)) 

1471 if not grids or focused is not grids[-1]: 

1472 return 

1473 cols = max(1, focused.columns_per_row) 

1474 last_row = (len(focused.rows) - 1) // cols 

1475 if focused.highlighted // cols < last_row: 

1476 return 

1477 self._grid_container.scroll_end(animate=False, immediate=True) 

1478 

1479 @on(GridSelect.LeaveDown) 

1480 @on(ModelGrid.LeaveDown) 

1481 def _on_grid_leave_down(self, event: Message) -> None: 

1482 """Move focus to the next grid widget, or fetch more if at the end. 

1483 

1484 On the bottom-most grid we expose the inline scroll-hint Static 

1485 (mounted below the last grid via ``_mount_grid_ctas``) by scrolling 

1486 the parent VerticalScroll to its end. That mirrors the way mouse 

1487 wheel naturally overshoots past the last card to reveal the hint. 

1488 Cursor stays parked on the last cell. 

1489 """ 

1490 if isinstance(event, ModelGrid.LeaveDown): 

1491 grids = list(self._grid_container.query(ModelGrid)) 

1492 if grids and event.grid is grids[-1]: 

1493 self._grid_container.scroll_end(animate=False, immediate=True) 

1494 if self._active_task_has_more() and not self._loading_more: 

1495 self._load_more() 

1496 return 

1497 self.focus_next() 

1498 

1499 @on(GridSelect.LeaveUp) 

1500 @on(ModelGrid.LeaveUp) 

1501 def _on_grid_leave_up(self, event: Message) -> None: 

1502 """Move focus to the previous grid widget. 

1503 

1504 On the topmost grid, return without moving focus so the cursor 

1505 stays parked at the top row instead of leaking focus upward. 

1506 """ 

1507 if isinstance(event, ModelGrid.LeaveUp): 

1508 grids = list(self._grid_container.query(ModelGrid)) 

1509 if grids and event.grid is grids[0]: 

1510 return 

1511 self.focus_previous() 

1512 

1513 @on(GridSelect.Selected) 

1514 def _on_grid_select_selected(self, event: GridSelect.Selected) -> None: 

1515 """Handle model selection from a GridSelect (setup wizard path).""" 

1516 widget = event.widget 

1517 if isinstance(widget, ModelCard): 

1518 self._select_row(widget.row) 

1519 

1520 @on(ModelGrid.Selected) 

1521 def _on_grid_selected(self, event: ModelGrid.Selected) -> None: 

1522 """Handle model selection from the catalog grid view.""" 

1523 self._select_row(event.row) 

1524 

1525 @on(ModelList.Selected) 

1526 def _on_model_list_selected(self, event: ModelList.Selected) -> None: 

1527 """Handle model selection from any ModelList (Local list view or Frontier tab).""" 

1528 self._select_row(event.row) 

1529 

1530 def _refresh_list(self) -> None: 

1531 """Rebuild the list view for the active tab; per-tab cache key skips no-op rebuilds.""" 

1532 active_tab = self._active_tab_id_cache 

1533 all_rows = self._sort_rows(self._build_rows()) 

1534 if active_tab in TASK_TAB_IDS: 

1535 active_task = TAB_ID_TO_TASK[active_tab] 

1536 self._rows = [r for r in all_rows if r.task == active_task.value] 

1537 else: 

1538 self._rows = list(all_rows) 

1539 search = self._get_search_text() 

1540 list_key = ( 

1541 tuple((r.name, r.installed) for r in self._rows), 

1542 search, 

1543 ) 

1544 if self._list_cache_keys.get(active_tab) == list_key: 

1545 self._update_sort_label() 

1546 return 

1547 self._list_cache_keys[active_tab] = list_key 

1548 visible = [r for r in self._rows if not search or matches_search(r, search)] 

1549 self._list_widget.set_rows([ModelListSection(heading=None, rows=list(visible))]) 

1550 self._update_sort_label() 

1551 

1552 def _filter_list(self) -> None: 

1553 """Filter the list view to rows matching the active search.""" 

1554 search = self._get_search_text() 

1555 visible = [r for r in self._rows if not search or matches_search(r, search)] 

1556 self._list_widget.set_rows([ModelListSection(heading=None, rows=list(visible))]) 

1557 # Cache key reflects the filtered shape so a no-op _refresh_list 

1558 # immediately after a filter pass does not double-render. 

1559 self._list_cache_keys[self._active_tab_id_cache] = ( 

1560 tuple((r.name, r.installed) for r in self._rows), 

1561 search, 

1562 ) 

1563 self._update_sort_label() 

1564 

1565 def _sync_loading_spinner(self) -> None: 

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

1567 

1568 Visible when a paginated HF fetch or a remote search is in 

1569 flight (both grid and list views share the same toolbar 

1570 widget). Cycles braille frames on a 100 ms timer so the 

1571 wait reads as "moving" rather than "frozen". 

1572 """ 

1573 try: 

1574 spinner = self.query_one("#catalog-loading-spinner", Static) 

1575 except Exception: 

1576 return 

1577 active = self._loading_more or self._search_in_flight 

1578 if active: 

1579 spinner.styles.display = "block" 

1580 spinner.update(f"{_SPINNER_FRAMES[self._spinner_frame]} loading…") 

1581 if self._spinner_timer is None: 

1582 self._spinner_timer = self.set_interval( 

1583 _SPINNER_INTERVAL_S, self._tick_loading_spinner 

1584 ) 

1585 # Mirror the spinner into the inline scroll-hint so users 

1586 # waiting at the bottom of the grid see the activity in the 

1587 # same place mouse scroll surfaces it. 

1588 if self._loading_more: 

1589 with contextlib.suppress(Exception): 

1590 hint = self._grid_container.query_one(".scroll-hint", Static) 

1591 hint.update( 

1592 msg.CATALOG_GRID_LOADING_MORE.format( 

1593 frame=_SPINNER_FRAMES[self._spinner_frame] 

1594 ) 

1595 ) 

1596 else: 

1597 spinner.update("") 

1598 spinner.styles.display = "none" 

1599 if self._spinner_timer is not None: 

1600 self._spinner_timer.stop() 

1601 self._spinner_timer = None 

1602 self._spinner_frame = 0 

1603 # Restore the post-load CTA text now that the fetch settled. 

1604 # Count is per active task tab so the hint matches what's rendered. 

1605 hf_rows = self._build_hf_rows(self._get_search_text()) if self._hf_fetched_any() else [] 

1606 active_task = self._active_task() 

1607 hf_count = ( 

1608 sum(1 for r in hf_rows if r.task == active_task.value) 

1609 if active_task is not None 

1610 else len(hf_rows) 

1611 ) 

1612 self._refresh_grid_ctas(hf_count=hf_count) 

1613 

1614 def _tick_loading_spinner(self) -> None: 

1615 """Advance the spinner one braille frame; called by the interval timer.""" 

1616 self._spinner_frame = (self._spinner_frame + 1) % len(_SPINNER_FRAMES) 

1617 with contextlib.suppress(Exception): 

1618 spinner = self.query_one("#catalog-loading-spinner", Static) 

1619 spinner.update(f"{_SPINNER_FRAMES[self._spinner_frame]} loading…") 

1620 if self._loading_more: 

1621 with contextlib.suppress(Exception): 

1622 hint = self._grid_container.query_one(".scroll-hint", Static) 

1623 hint.update( 

1624 msg.CATALOG_GRID_LOADING_MORE.format(frame=_SPINNER_FRAMES[self._spinner_frame]) 

1625 ) 

1626 

1627 def _update_sort_label(self) -> None: 

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

1629 

1630 Wrapped in NoMatches suppression because the worker callbacks that 

1631 trigger an update (``_fetch_remote_models``, ``_fetch_frontier_models``) 

1632 can fire on the next loop tick after a screen switch, before the 

1633 new screen's ``compose`` has finished mounting ``#sort-label``. 

1634 On Windows that race lands often enough to fail CI. 

1635 """ 

1636 from textual.css.query import NoMatches 

1637 

1638 try: 

1639 label = self.query_one("#sort-label", Static) 

1640 except NoMatches: 

1641 return 

1642 if self._active_tab_id() == TAB_LIBRARY: 

1643 label.update(self._frontier_label_text()) 

1644 return 

1645 direction = "asc" if self._sort_ascending else "desc" 

1646 n_total = len(self._rows) 

1647 if self._loading_more: 

1648 count = f"{n_total} models · loading more…" 

1649 elif self._active_task_has_more(): 

1650 count = f"{n_total} models · press [b]n[/b] for more" 

1651 else: 

1652 count = f"{n_total} models" 

1653 hint = msg.CATALOG_SEARCHING_HF if self._search_in_flight else msg.CATALOG_VIEW_TOGGLE_LIST 

1654 label.update(f"Sort: {self._sort_column} ({direction}) | {count} | {hint}") 

1655 

1656 def _frontier_label_text(self) -> str: 

1657 provider_count = len({r.provider for r in self._frontier_rows}) 

1658 return msg.CATALOG_FRONTIER_SUMMARY.format( 

1659 count=len(self._frontier_rows), providers=provider_count 

1660 ) 

1661 

1662 def action_cycle_sort(self) -> None: 

1663 """Cycle the list-view sort column ascending: Name, Downloads, Size, Params.""" 

1664 if self._search_focused: 

1665 return 

1666 if self._active_tab_id() not in TASK_TAB_IDS: 

1667 return 

1668 if self._grid_view: 

1669 self.notify(msg.CATALOG_SORT_LIST_ONLY) 

1670 return 

1671 try: 

1672 idx = _SORT_CYCLE.index(self._sort_column) 

1673 except ValueError: 

1674 idx = -1 

1675 self._sort_column = _SORT_CYCLE[(idx + 1) % len(_SORT_CYCLE)] 

1676 self._sort_ascending = True 

1677 self._refresh_list() 

1678 # mount_all is async; focus the first row after Textual's next 

1679 # refresh so the filter Input doesn't swallow the next `s` press. 

1680 self.call_after_refresh(self._focus_list_item, 0) 

1681 

1682 def _select_row(self, row: CatalogRow) -> None: 

1683 """Handle row selection: install, switch model, or open settings.""" 

1684 if row.kind == CatalogRowKind.FRONTIER: # sealed-union dispatch 

1685 self._select_frontier_row(row) 

1686 return 

1687 if row.variant and row.family: 

1688 self._install_variant(row.variant, row.family) 

1689 elif row.catalog_model: 

1690 self._install_model(row.catalog_model) 

1691 elif row.remote_model: 

1692 apply_active_model(self.app, "chat_model", row.ref) 

1693 self.notify(msg.CATALOG_USING_REMOTE.format(name=row.remote_model.name)) 

1694 

1695 def _select_frontier_row(self, row: FrontierCatalogRow) -> None: 

1696 """Activate a cloud model, or jump to settings when the key is missing.""" 

1697 if row.key_status == KeyStatus.READY: 

1698 apply_active_model(self.app, "chat_model", row.ref) 

1699 self.notify(msg.CATALOG_USING_FRONTIER.format(name=row.name, provider=row.provider)) 

1700 return 

1701 key_field = f"{row.provider_id}_api_key" 

1702 self.notify( 

1703 msg.CATALOG_NEEDS_KEY.format(provider=row.provider, key_field=key_field), 

1704 severity="warning", 

1705 timeout=10, 

1706 ) 

1707 self.app.switch_view("Settings") 

1708 

1709 def _load_more(self) -> None: 

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

1711 

1712 Pagination is per-task: only the active tab's offset advances, only 

1713 the active tab's task is fetched. Discover and Library short-circuit 

1714 because they have no associated task and can't paginate. 

1715 """ 

1716 if self._loading_more: 

1717 return 

1718 task = self._active_task() 

1719 if task is None or not self._hf_has_more_by_task.get(task, False): 

1720 return 

1721 self._loading_more = True 

1722 self._sync_loading_spinner() 

1723 self._hf_offset_by_task[task] += _HF_PAGE_SIZE 

1724 self._fetch_more_hf_for_task(task) 

1725 

1726 def action_load_more(self) -> None: 

1727 """Keyboard trigger (``n``) so users can page without scrolling.""" 

1728 if self._active_tab_id() not in TASK_TAB_IDS: 

1729 return 

1730 self._load_more() 

1731 

1732 @on(TabbedContent.TabActivated, "#catalog-tabs") 

1733 def _on_catalog_tab_activated(self, event: TabbedContent.TabActivated) -> None: 

1734 """Update active-tab cache, refresh sort label, populate the active pane. 

1735 

1736 Cache update is the load-bearing line: every later check that asks 

1737 ``_active_tab_id()`` reads this cache, not a fresh DOM query, so 

1738 per-render overhead stays constant regardless of tab count. 

1739 """ 

1740 new_tab = event.pane.id or TAB_CHAT 

1741 if not self._activation_settled: 

1742 return 

1743 self._active_tab_id_cache = new_tab 

1744 # Stale per-tab widget caches survive across tab activations, 

1745 # but if the user switched after a remount, the cached handle 

1746 # may be detached. _grid_for_tab/_list_for_tab validate via 

1747 # is_running and refetch as needed. 

1748 self._update_sort_label() 

1749 if new_tab == TAB_LIBRARY: 

1750 self._populate_library_list() 

1751 elif new_tab == TAB_DISCOVER: 

1752 self._populate_discover_rails() 

1753 elif new_tab in TASK_TAB_IDS: 

1754 # Lazy first-fetch: tabs other than Chat skip their HF round-trip 

1755 # at mount and hit the API only when first activated. Cached 

1756 # after, so re-activations stay free. 

1757 self._ensure_task_initial_fetch(TAB_ID_TO_TASK[new_tab]) 

1758 # Refresh the newly active task tab. Per-tab cache key skips 

1759 # the rebuild when the row shape hasn't changed since last paint. 

1760 self._refresh_view() 

1761 

1762 def _populate_discover_rails(self) -> None: 

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

1764 

1765 - For You: featured rows ranked by fit (FITS first, TIGHT, then 

1766 WONT_RUN), capped at 6 to keep the rail compact. 

1767 - Your Collection: every installed local row + every activated 

1768 cloud API. Mirrors the Library tab's spirit but capped to a 

1769 single rail-friendly slice. 

1770 - Fresh on the Hub: most-downloaded non-featured HF rows as a 

1771 recency-ish proxy (the API doesn't expose 'newly uploaded' as 

1772 a sort key today; downloads-desc surfaces buzzy recent uploads). 

1773 """ 

1774 try: 

1775 rails = self.query_one("#discover-rails", DiscoverRails) 

1776 except Exception: 

1777 return 

1778 family_rows = self._all_family_rows() 

1779 hf_rows = self._all_hf_rows() if self._hf_fetched_any() else [] 

1780 remote_rows = self._all_remote_rows() 

1781 for_you = sorted( 

1782 (r for r in family_rows + hf_rows if r.featured), 

1783 key=for_you_sort_key, 

1784 )[:6] 

1785 collection = [r for r in family_rows + remote_rows if r.installed][:6] 

1786 fresh = sorted( 

1787 (r for r in hf_rows if not r.featured), 

1788 key=lambda r: -r.sort_downloads, 

1789 )[:6] 

1790 rails.set_rails(for_you=for_you, collection=collection, fresh=fresh) 

1791 

1792 def _install_variant(self, variant: ModelVariant, family: ModelFamily) -> None: 

1793 """Convert a variant back to a CatalogModel and trigger install.""" 

1794 entry = CatalogModel( 

1795 hf_repo=variant.hf_repo, 

1796 gguf_filename=variant.filename, 

1797 size_gb=variant.size_mb / 1024, 

1798 min_ram_gb=max(2.0, (variant.size_mb / 1024) * 1.5), 

1799 description=family.description, 

1800 featured=True, 

1801 downloads=0, 

1802 task=family.task, 

1803 recommended=variant.recommended, 

1804 ) 

1805 self._install_model(entry) 

1806 

1807 def _install_model(self, model: CatalogModel) -> None: 

1808 try: 

1809 filename = resolve_filename(model) 

1810 dest = cfg.models_dir / filename 

1811 if dest.exists(): 

1812 self.notify(msg.CATALOG_ALREADY_INSTALLED.format(name=model.display_name)) 

1813 return 

1814 except Exception: 

1815 log.debug("Could not resolve filename", exc_info=True) 

1816 

1817 self._enqueue_download(model) 

1818 

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

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

1821 

1822 The controller owns the worker thread; this screen just fires the 

1823 request and returns. Progress is visible from every screen and 

1824 survives navigation. 

1825 """ 

1826 self.app.task_bar.start_download(model) 

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

1828 

1829 def action_go_back(self) -> None: 

1830 # Escape from a focused filter input collapses the input back to 

1831 # hidden and restores focus to the grid/list, so screen-level 

1832 # keys (s / v) reach the screen instead of the (now-hidden) input. 

1833 if self._search_focused: 

1834 self._search_input.value = "" 

1835 self._search_input.add_class("-hidden") 

1836 self._focus_list_or_grid() 

1837 return 

1838 self.app.switch_view("Chat") 

1839 

1840 def action_dismiss_filter(self) -> None: 

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

1842 

1843 Heavy-interaction QA showed a stray Esc (from info-modal-then-Esc 

1844 cycles, drawer thrash, filter typing chains) would dismiss the 

1845 catalog mid-task and leak subsequent keystrokes into the chat 

1846 Input on the next screen. Esc now only handles the filter; the 

1847 dismiss path is `q` (action_go_back) which still does both. 

1848 """ 

1849 if self._search_focused: 

1850 self._search_input.value = "" 

1851 self._search_input.add_class("-hidden") 

1852 self._focus_list_or_grid() 

1853 

1854 def _focus_list_or_grid(self) -> None: 

1855 """Move focus from the filter input to the active view's list/grid.""" 

1856 if self._grid_view: 

1857 self._focus_first_grid() 

1858 else: 

1859 self._focus_list_item(0) 

1860 

1861 def action_show_info(self) -> None: 

1862 """Pop up an info modal for the highlighted catalog row.""" 

1863 if self._search_focused: 

1864 return 

1865 row = self._highlighted_row() 

1866 if row is None: 

1867 self.notify(msg.CATALOG_SELECT_FOR_INFO, severity="warning") 

1868 return 

1869 if row.kind != CatalogRowKind.LOCAL: 

1870 self.notify(msg.CATALOG_FRONTIER_NO_INFO, severity="warning") 

1871 return 

1872 from lilbee.cli.tui.screens.model_info import ModelInfoModal 

1873 

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

1875 

1876 def _highlighted_row(self) -> CatalogRow | None: 

1877 """Return the focused row in either grid or list view, or None.""" 

1878 if not self._grid_view and self._list_widget.has_focus: 

1879 return self._list_widget.highlighted_row() 

1880 focused_grid = self._focused_grid() 

1881 if focused_grid is None or focused_grid.highlighted is None: 

1882 return None 

1883 if isinstance(focused_grid, ModelGrid): 

1884 rows = focused_grid.rows 

1885 index = focused_grid.highlighted 

1886 return rows[index] if 0 <= index < len(rows) else None 

1887 child = focused_grid.children[focused_grid.highlighted] 

1888 if isinstance(child, ModelCard): 

1889 return child.row 

1890 return None 

1891 

1892 def action_delete_model(self) -> None: 

1893 """Delete an installed model. First press asks confirmation, second confirms.""" 

1894 if self._search_focused: 

1895 return 

1896 model_name = self._get_highlighted_model_name() 

1897 if model_name is None: 

1898 self.notify(msg.CATALOG_SELECT_TO_DELETE, severity="warning") 

1899 return 

1900 

1901 if not self._row_is_installed(model_name): 

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

1903 return 

1904 

1905 if self._pending_delete == model_name: 

1906 self._pending_delete = None 

1907 self._run_delete(model_name) 

1908 else: 

1909 self._pending_delete = model_name 

1910 self.notify(msg.CATALOG_CONFIRM_DELETE.format(name=model_name)) 

1911 

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

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

1914 

1915 ``_installed_names`` carries both the full ``<repo>/<file>.gguf`` 

1916 ref and the bare ``hf_repo`` for every installed native model, 

1917 so it answers either ref shape; remote presence is asked of the 

1918 manager directly. 

1919 """ 

1920 if model_name in self._installed_names: 

1921 return True 

1922 return get_services().model_manager.is_installed(model_name, ModelSource.REMOTE) 

1923 

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

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

1926 

1927 Featured / HF browse rows surface a bare hf_repo while the 

1928 registry deletes by ``<hf_repo>/<file>.gguf``. Bare repos 

1929 resolve to the lexicographically-first matching installed 

1930 manifest; full refs and remote names pass through. 

1931 """ 

1932 if "/" in identity and identity.endswith(".gguf"): 

1933 return identity 

1934 prefix = identity + "/" 

1935 matches = sorted(n for n in self._installed_names if n.startswith(prefix)) 

1936 if matches: 

1937 return matches[0] 

1938 return identity 

1939 

1940 def _get_highlighted_model_name(self) -> str | None: 

1941 """Return the registry-compatible model ref for the focused/highlighted row.""" 

1942 if not self._grid_view and self._list_widget.has_focus: 

1943 row = self._list_widget.highlighted_row() 

1944 return row_delete_id(row) if row else None 

1945 focused_grid = self._focused_grid() 

1946 if focused_grid is None or focused_grid.highlighted is None: 

1947 return None 

1948 if isinstance(focused_grid, ModelGrid): 

1949 rows = focused_grid.rows 

1950 index = focused_grid.highlighted 

1951 if 0 <= index < len(rows): 

1952 return row_delete_id(rows[index]) 

1953 return None 

1954 # GridSelect path: cards are direct children indexed positionally. 

1955 child = focused_grid.children[focused_grid.highlighted] 

1956 if isinstance(child, ModelCard): 

1957 return row_delete_id(child.row) 

1958 return None 

1959 

1960 @work(thread=True) 

1961 def _run_delete(self, model_name: str) -> None: 

1962 """Remove a model in a background thread.""" 

1963 delete_ref = self._resolve_delete_ref(model_name) 

1964 try: 

1965 removed = get_services().model_manager.remove(delete_ref) 

1966 if removed: 

1967 call_from_thread(self, self.notify, msg.CATALOG_DELETED.format(name=model_name)) 

1968 call_from_thread(self, self._refresh_after_delete) 

1969 else: 

1970 call_from_thread( 

1971 self, 

1972 self.notify, 

1973 msg.CATALOG_DELETE_FAILED.format(error=model_name), 

1974 severity="error", 

1975 ) 

1976 except Exception as exc: 

1977 log.warning("Delete failed for %s", model_name, exc_info=True) 

1978 call_from_thread( 

1979 self, 

1980 self.notify, 

1981 msg.CATALOG_DELETE_FAILED.format(error=exc), 

1982 severity="error", 

1983 ) 

1984 

1985 def _refresh_after_delete(self) -> None: 

1986 """Re-fetch remote models and refresh after deletion.""" 

1987 self._fetch_installed_names() 

1988 self._refresh_view() 

1989 self._fetch_remote_models() 

1990 

1991 def _focused_grid(self) -> ModelGrid | GridSelect | None: 

1992 """Return the focused grid widget (grid view), else None.""" 

1993 if self._grid_view and isinstance(self.focused, (ModelGrid, GridSelect)): 

1994 return self.focused 

1995 return None 

1996 

1997 def _list_count(self) -> int: 

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

1999 return self._list_widget.row_count 

2000 

2001 def _focus_list_item(self, index: int) -> None: 

2002 """Highlight the row at *index*, clamped to the visible range.""" 

2003 count = self._list_widget.option_count 

2004 if not count: 

2005 return 

2006 clamped = max(0, min(index, count - 1)) 

2007 self._list_widget.highlighted = clamped 

2008 self._list_widget.focus() 

2009 

2010 def _focused_list_index(self) -> int | None: 

2011 """Index of the highlighted list row, or None when nothing is highlighted.""" 

2012 return self._list_widget.highlighted 

2013 

2014 def _nudge_list(self, delta: int) -> None: 

2015 idx = self._focused_list_index() 

2016 if idx is None: 

2017 self._focus_list_item(0) 

2018 return 

2019 self._focus_list_item(idx + delta) 

2020 self._maybe_prefetch_on_nav() 

2021 

2022 def _maybe_prefetch_on_nav(self) -> None: 

2023 if self._grid_view or not self._active_task_has_more() or self._loading_more: 

2024 return 

2025 idx = self._focused_list_index() 

2026 if idx is None: 

2027 return 

2028 if idx >= self._list_widget.option_count - _HF_LOAD_MORE_TRIGGER: 

2029 self._load_more() 

2030 

2031 def _maybe_prefetch_on_grid_nav(self) -> None: 

2032 """Fire ``_load_more`` when the keyboard cursor lands within the last 

2033 rows of the catalog. Mouse wheel triggers via ``_on_grid_scrolled`` at 

2034 the 85 % scroll threshold, but cell-by-cell keyboard nav advances 

2035 scroll_y too gradually to ever cross that threshold; this check 

2036 guarantees keyboard reaches the same prefetch trigger. 

2037 """ 

2038 if not self._grid_view or not self._active_task_has_more() or self._loading_more: 

2039 return 

2040 grids = list(self._grid_container.query(ModelGrid)) 

2041 if not grids: 

2042 return 

2043 focused = self._focused_grid() 

2044 if not isinstance(focused, ModelGrid) or focused.highlighted is None: 

2045 return 

2046 # Absolute cursor position = cards in earlier grids + cursor in this grid. 

2047 try: 

2048 grid_index = grids.index(focused) 

2049 except ValueError: 

2050 return 

2051 cards_before = sum(len(g.rows) for g in grids[:grid_index]) 

2052 absolute = cards_before + focused.highlighted 

2053 total = sum(len(g.rows) for g in grids) 

2054 if total <= 0: 

2055 return 

2056 if absolute >= total - _HF_LOAD_MORE_TRIGGER: 

2057 self._load_more() 

2058 

2059 _SCROLL_PREFETCH_RATIO = 0.85 

2060 _SCROLL_PREFETCH_COOLDOWN = 0.8 

2061 

2062 def _on_list_scrolled(self, _scroll_y: float) -> None: 

2063 """Trigger _load_more when the user scrolls near the bottom of the list.""" 

2064 if not self._scroll_prefetch_due(self._list_widget): 

2065 return 

2066 self._scroll_prefetch_armed_at = time.monotonic() 

2067 self._load_more() 

2068 

2069 def _on_grid_scrolled(self, _scroll_y: float) -> None: 

2070 """Trigger _load_more when the user scrolls near the bottom of the grid.""" 

2071 if not self._grid_view: 

2072 return 

2073 if not self._scroll_prefetch_due(self._grid_container): 

2074 return 

2075 self._scroll_prefetch_armed_at = time.monotonic() 

2076 self._load_more() 

2077 

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

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

2080 

2081 Three collapsed triggers, both views: (1) content already fits the 

2082 viewport so ``max_scroll_y == 0`` and wheel events produce no scroll 

2083 delta, (2) the user has wheeled to ``scroll_y == max_scroll_y`` and 

2084 further wheels produce no delta, (3) list view has the same problem 

2085 as grid view -- the scroll watcher only fires on scroll_y changes, 

2086 so a wheel at max_y is invisible to ``_on_list_scrolled`` / 

2087 ``_on_grid_scrolled``. Re-check here and fetch the next page 

2088 directly. Cooldown prevents a cascade as new rows shift max_scroll_y. 

2089 """ 

2090 if not self._active_task_has_more() or self._loading_more: 

2091 return 

2092 container = self._grid_container if self._grid_view else self._list_widget 

2093 max_y = container.max_scroll_y 

2094 if max_y > 0 and container.scroll_y < max_y: 

2095 return 

2096 if self._scroll_prefetch_armed_at: 

2097 elapsed = time.monotonic() - self._scroll_prefetch_armed_at 

2098 if elapsed < self._SCROLL_PREFETCH_COOLDOWN: 

2099 return 

2100 self._scroll_prefetch_armed_at = time.monotonic() 

2101 self._load_more() 

2102 

2103 def _scroll_prefetch_due(self, widget: VerticalScroll | ModelList) -> bool: 

2104 # Cooldown blocks a runaway cascade where appending rows shifts 

2105 # max_scroll_y, the watcher refires, and load_more kicks off the 

2106 # next fetch before the user notices. 

2107 if not self._active_task_has_more() or self._loading_more: 

2108 return False 

2109 if self._scroll_prefetch_armed_at: 

2110 elapsed = time.monotonic() - self._scroll_prefetch_armed_at 

2111 if elapsed < self._SCROLL_PREFETCH_COOLDOWN: 

2112 return False 

2113 max_y = widget.max_scroll_y 

2114 if max_y <= 0: 

2115 return False 

2116 return widget.scroll_y / max_y >= self._SCROLL_PREFETCH_RATIO 

2117 

2118 def _page_rows(self) -> int: 

2119 """How many cursor steps make up one 'page' in the active view.""" 

2120 return _GRID_PAGE_ROWS if self._grid_view else _LIST_PAGE_ROWS 

2121 

2122 def action_page_down(self) -> None: 

2123 if self._search_focused: 

2124 return 

2125 if self._grid_view: 

2126 if (grid := self._focused_grid()) is not None: 

2127 for _ in range(self._page_rows()): 

2128 grid.action_cursor_down() 

2129 else: 

2130 self._nudge_list(self._page_rows()) 

2131 

2132 def action_page_up(self) -> None: 

2133 if self._search_focused: 

2134 return 

2135 if self._grid_view: 

2136 if (grid := self._focused_grid()) is not None: 

2137 for _ in range(self._page_rows()): 

2138 grid.action_cursor_up() 

2139 else: 

2140 self._nudge_list(-self._page_rows()) 

2141 

2142 def action_cursor_down(self) -> None: 

2143 if self._search_focused: 

2144 return 

2145 if self._grid_view: 

2146 grid = self._focused_grid() or self._first_grid_or_none() 

2147 if grid is not None: 

2148 grid.focus() 

2149 grid.action_cursor_down() 

2150 else: 

2151 self._nudge_list(1) 

2152 

2153 def action_cursor_up(self) -> None: 

2154 if self._search_focused: 

2155 return 

2156 if self._grid_view: 

2157 grid = self._focused_grid() or self._first_grid_or_none() 

2158 if grid is not None: 

2159 grid.focus() 

2160 grid.action_cursor_up() 

2161 else: 

2162 self._nudge_list(-1) 

2163 

2164 def _first_grid_or_none(self) -> ModelGrid | None: 

2165 """Return the first ModelGrid in the active tab's container, or None.""" 

2166 from textual.css.query import NoMatches 

2167 

2168 try: 

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

2170 except NoMatches: 

2171 return None 

2172 

2173 def action_jump_top(self) -> None: 

2174 if self._search_focused: 

2175 return 

2176 if self._grid_view: 

2177 if (grid := self._focused_grid()) is not None: 

2178 grid.highlight_first() 

2179 else: 

2180 self._focus_list_item(0) 

2181 

2182 def action_jump_bottom(self) -> None: 

2183 if self._search_focused: 

2184 return 

2185 if self._grid_view: 

2186 if (grid := self._focused_grid()) is not None: 

2187 grid.highlight_last() 

2188 else: 

2189 count = self._list_widget.option_count 

2190 if count: 

2191 self._focus_list_item(count - 1) 

2192 self._maybe_prefetch_on_nav()