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

1376 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +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, NamedTuple 

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.css.query import NoMatches 

16from textual.events import Click, Key, MouseScrollDown 

17from textual.message import Message 

18from textual.screen import Screen 

19from textual.timer import Timer 

20from textual.widget import AwaitMount 

21from textual.widgets import Footer, Input, Static, TabbedContent, TabPane 

22from textual.worker import Worker, WorkerState 

23 

24from lilbee.app.services import get_services 

25from lilbee.catalog import ( 

26 CatalogModel, 

27 ModelFamily, 

28 ModelVariant, 

29 disk_shortfall, 

30 get_catalog, 

31 get_families, 

32 resolve_filename, 

33) 

34from lilbee.catalog.download import _BYTES_PER_GB, download_bytes 

35from lilbee.catalog.models import estimate_min_ram_gb 

36from lilbee.catalog.types import ModelCompat, ModelSource, ModelTask 

37from lilbee.cli.tui import messages as msg 

38from lilbee.cli.tui.app import LilbeeApp 

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

40 GridSection, 

41 flatten_sections, 

42 for_you_by_role, 

43 group_frontier_rows, 

44 group_rows_for_grid, 

45 group_task_rows_with_picks, 

46 row_cache_signature, 

47) 

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

49 SORT_KEYS, 

50 TAB_CHAT, 

51 TAB_DISCOVER, 

52 TAB_EMBED, 

53 TAB_ID_TO_TASK, 

54 TAB_LIBRARY, 

55 TAB_RERANK, 

56 TAB_VISION, 

57 TASK_TAB_IDS, 

58 CatalogRow, 

59 CatalogRowKind, 

60 FrontierCatalogRow, 

61 KeyStatus, 

62 LocalCatalogRow, 

63 SourceMode, 

64 catalog_to_row, 

65 family_to_size_variants, 

66 frontier_row_from_remote, 

67 matches_search, 

68 next_source_mode, 

69 remote_to_row, 

70 row_delete_id, 

71 variant_to_row, 

72) 

73from lilbee.cli.tui.spinner import SPINNER_FRAMES 

74from lilbee.cli.tui.thread_safe import call_from_thread 

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

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

77from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog 

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

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

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

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

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

83from lilbee.cli.tui.widgets.model_pick import apply_model_pick 

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

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

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

87from lilbee.core.config import cfg 

88from lilbee.modelhub.model_manager import RemoteModel, classify_all_remote_models 

89from lilbee.providers.sdk_backend import PROVIDER_API_KEY_FIELD 

90from lilbee.runtime.hardware import available_memory_for_fit, chip_for_size 

91 

92log = logging.getLogger(__name__) 

93 

94# Rows per browse page for the active task. Must exceed one viewport (about 

95# five cards a row, four rows) or the grid paints part-empty under the 

96# "keep scrolling" hint. 

97_HF_PAGE_SIZE = 24 

98# Rows per search page. Wider than a browse page: matches are spread across 

99# the hub rather than sitting at one offset. 

100_HF_SEARCH_LIMIT = 50 

101_HF_LOAD_MORE_TRIGGER = 4 

102_ALL_TASKS = tuple(ModelTask) 

103 

104# Which config model-role field a selected model is assigned to, keyed by its task. 

105# Remote/frontier rows surface into their matching task tab, so selecting one must 

106# persist to that role, not always to chat_model. 

107_TASK_TO_MODEL_FIELD: dict[ModelTask, str] = { 

108 ModelTask.CHAT: "chat_model", 

109 ModelTask.EMBEDDING: "embedding_model", 

110 ModelTask.VISION: "vision_model", 

111 ModelTask.RERANK: "reranker_model", 

112} 

113 

114# Warning shown when a frontier row whose key is not usable is selected. 

115_KEY_PROBLEM_MESSAGE: dict[KeyStatus, str] = { 

116 KeyStatus.MISSING_KEY: msg.CATALOG_NEEDS_KEY, 

117 KeyStatus.INVALID_KEY: msg.CATALOG_KEY_REJECTED, 

118} 

119 

120 

121def _model_field_for_task(task: ModelTask | str) -> str: 

122 return _TASK_TO_MODEL_FIELD.get(ModelTask(task), "chat_model") 

123 

124 

125_WORKER_FETCH_HF = "fetch_hf_models" 

126_WORKER_FETCH_MORE_HF = "fetch_more_hf" 

127_WORKER_FETCH_REMOTE = "fetch_remote_models" 

128_WORKER_FETCH_SEARCH = "fetch_hf_search" 

129_WORKER_FETCH_FRONTIER = "fetch_frontier_models" 

130_WORKER_FETCH_FAMILIES = "fetch_families" 

131 

132_GRID_PAGE_ROWS = 3 

133_LIST_PAGE_ROWS = 10 

134 

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

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

137_GRID_ID_PREFIX = "grid-" 

138_LIST_ID_PREFIX = "list-" 

139 

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

141_HIDDEN_CLASS = "-hidden" 

142 

143# Refresh cycles the initial tab activation waits for the tab strip's children 

144# to mount before giving up (they mount a frame after construction; a slow host 

145# can take several). 

146_TAB_ACTIVATION_RETRY_BUDGET = 20 

147 

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

149 

150# Spinner cadence for the catalog pagination/search loading indicator: cycled on 

151# a 100 ms timer while the catalog fetches more HF rows or a remote search is in 

152# flight, so the wait always shows a moving signal instead of an empty pane. The 

153# frames come from the shared, Rich-sourced ``SPINNER_FRAMES``. 

154_SPINNER_INTERVAL_S = 0.1 

155 

156_RowCacheKey = tuple[int, int, int, int, int, int] 

157 

158 

159@dataclass(frozen=True) 

160class _RowCacheEntry: 

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

162 

163 key: _RowCacheKey 

164 rows: list[LocalCatalogRow] 

165 

166 

167class _GridCacheKey(NamedTuple): 

168 """Per-tab identity of a painted grid; equal keys mean nothing to repaint.""" 

169 

170 data_version: int 

171 rows: tuple[tuple[str, bool], ...] 

172 search: str 

173 

174 

175class CatalogScreen(Screen[None]): 

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

177 

178 app: LilbeeApp # type: ignore[assignment] 

179 

180 CSS_PATH = "catalog.tcss" 

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

182 

183 HELP = ( 

184 "# Catalog\n" 

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

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

187 "## Navigation\n" 

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

189 "- 1-6: jump to tab N (the numerals are shown on the tab strip).\n" 

190 "- < / >: step to the previous / next tab.\n" 

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

192 "## Actions\n" 

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

194 "- Space: toggle select.\n" 

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

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

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

198 "## Filters and views\n" 

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

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

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

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

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

204 "## Detail drawer\n" 

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

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

207 "## Fit chip\n" 

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

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

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

211 "## Other\n" 

212 "- q / Esc: back." 

213 ) 

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

215 _TAB_GROUP = Binding.Group("Tabs", compact=True) 

216 

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

218 Binding("q", "go_back", "Back", show=True), 

219 Binding("escape", "go_back", "", show=False), 

220 # Help-panel only: the footer keeps the keys that move between views plus the 

221 # one this screen is for, which is search. Switching grid for list is a 

222 # preference, not a way around the catalog. 

223 Binding("v", "toggle_view", "Grid/List", show=False), 

224 Binding("slash", "focus_search", "Search", show=True), 

225 # `d` / `i` are bound but help-panel only, like everything here that is not 

226 # search: the footer carries the keys that move between views plus the 

227 # one verb this screen is for. Both stay discoverable via F2 (command 

228 # palette) and `?` (help overlay). 

229 Binding("d", "delete_model", "Delete", show=False), 

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

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

232 Binding("i", "show_info", "Info", show=False), 

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

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

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

236 # the highlight follows the visible region. Decoupling them 

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

238 # previously-focused card. 

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

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

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

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

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

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

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

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

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

248 # Help-panel only; the sort-label surfaces "press n for more" and "press s to 

249 # sort" on screen instead, which is better than a footer cell. 

250 Binding("n", "load_more", "More", show=False), 

251 Binding("s", "cycle_sort", "Sort", show=False), 

252 Binding("ctrl+b", "toggle_drawer", "Detail", show=False), 

253 # `o` for origin, not `c`: `c` is the app-wide jump to Chat. An app 

254 # binding may only be shadowed by a screen key the footer explains, 

255 # and this one is hidden, so it would have flipped the source filter 

256 # with nothing on screen to say why. 

257 Binding("o", "cycle_source", "Source", show=False), 

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

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

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

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

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

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

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

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

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

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

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

269 # Tab cycling, shown as a compact pair: stepping between Chat / Embed 

270 # / Vision / Rerank is the catalog's most-used move and the numbered 

271 # strip alone did not advertise it. ctrl+arrow conflicts with macOS 

272 # desktop-space shortcuts, hence vim-style angle brackets. 

273 # priority=True so the active ModelGrid's own focus cycling doesn't 

274 # swallow them. 

275 Binding( 

276 "less_than_sign", 

277 "cycle_tab(-1)", 

278 "Prev tab", 

279 show=True, 

280 priority=True, 

281 group=_TAB_GROUP, 

282 ), 

283 Binding( 

284 "greater_than_sign", 

285 "cycle_tab(1)", 

286 "Next tab", 

287 show=True, 

288 priority=True, 

289 group=_TAB_GROUP, 

290 ), 

291 ] 

292 

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

294 

295 def __init__(self, *, focus_task: str | None = None) -> None: 

296 super().__init__() 

297 self._focus_task: str | None = focus_task 

298 # Empty until the worker lands: get_families() resolves the picks, 

299 # which hits HuggingFace on the first call of a session. Building it 

300 # here would block screen construction on the network. 

301 self._families: list[ModelFamily] = [] 

302 self._families_in_flight = True 

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

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

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

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

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

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

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

310 self._hf_truncated_by_task: dict[ModelTask, bool] = dict.fromkeys(_ALL_TASKS, False) 

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

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

313 self._sort_column: str = "Name" 

314 self._sort_ascending: bool = True 

315 self._pending_delete: str | None = None 

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

317 self._grid_view: bool = True 

318 self._loading_more: bool = False 

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

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

321 self._grid_cache_keys: dict[str, _GridCacheKey] = {} 

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

323 self._search_in_flight: bool = False 

324 # Remote-search pagination, keyed on the query the offset belongs to. 

325 # Distinct from the per-task browse offsets, which page unfiltered rows. 

326 self._searched_query: str = "" 

327 self._search_offset: int = 0 

328 self._search_has_more: bool = False 

329 self._search_truncated: bool = False 

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

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

332 # invalidate even when collection lengths happen to coincide. 

333 self._data_version: int = 0 

334 self._family_rows_cache: _RowCacheEntry | None = None 

335 self._hf_rows_cache: _RowCacheEntry | None = None 

336 self._remote_rows_cache: _RowCacheEntry | None = None 

337 self._view_switching: bool = False 

338 self._frontier_refresh_timer: Timer | None = None 

339 self._search_filter_timer: Timer | None = None 

340 self._remote_search_timer: Timer | None = None 

341 self._scroll_prefetch_armed_at: float = 0.0 

342 self._spinner_timer: Timer | None = None 

343 self._spinner_frame: int = 0 

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

345 # query_one on every _grid_container / _list_widget access. Default 

346 # matches the tab _activate_initial_tab selects on mount. 

347 self._active_tab_id_cache: str = TAB_CHAT 

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

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

350 # During initial mount Textual fires TabActivated for whichever pane 

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

352 # call_after_refresh setter activates Chat. Suppressing cache writes 

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

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

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

356 self._activation_settled: bool = False 

357 # Refresh cycles left to wait for the tab strip to mount before the 

358 # initial activation gives up (see _activate_initial_tab). 

359 self._activation_retries: int = _TAB_ACTIVATION_RETRY_BUDGET 

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

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

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

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

364 tab_id: SourceMode.LOCAL for tab_id in TASK_TAB_IDS 

365 } 

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

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

368 # re-probing on every refresh. 

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

370 

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

372 """Return (and memoize) the scroll container hosting *tab_id*'s grids. 

373 

374 Discover's container is the DiscoverRails scroll itself, so cursor 

375 actions and leave handlers always operate on the visible tab's grids 

376 (never a hidden sibling pane's). Cached references are validated via 

377 ``is_running`` so a stale post-remount handle gets refreshed 

378 transparently. 

379 """ 

380 cached = self._tab_grid_cache.get(tab_id) 

381 if cached is not None and cached.is_running: 

382 return cached 

383 selector = "#discover-rails" if tab_id == TAB_DISCOVER else f"#{_GRID_ID_PREFIX}{tab_id}" 

384 container = self.query_one(selector, VerticalScroll) 

385 self._tab_grid_cache[tab_id] = container 

386 return container 

387 

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

389 """Return (and memoize) the ModelList for *tab_id*. 

390 

391 Discover has no list view; falls through to TAB_CHAT so callers that 

392 touch ``_list_widget`` while Discover is active never crash. 

393 """ 

394 target = TAB_CHAT if tab_id == TAB_DISCOVER else tab_id 

395 cached = self._tab_list_cache.get(target) 

396 if cached is not None and cached.is_running: 

397 return cached 

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

399 self._tab_list_cache[target] = widget 

400 return widget 

401 

402 def _grid_mounted(self) -> bool: 

403 """Whether the active tab's grid container exists to paint into.""" 

404 try: 

405 self._grid_for_tab(self._active_tab_id_cache) 

406 except NoMatches: 

407 return False 

408 return True 

409 

410 def _list_mounted(self) -> bool: 

411 """Whether the active tab's list widget exists to paint into.""" 

412 try: 

413 self._list_for_tab(self._active_tab_id_cache) 

414 except NoMatches: 

415 return False 

416 return True 

417 

418 @property 

419 def _grid_container(self) -> VerticalScroll: 

420 return self._grid_for_tab(self._active_tab_id_cache) 

421 

422 @property 

423 def _list_widget(self) -> ModelList: 

424 return self._list_for_tab(self._active_tab_id_cache) 

425 

426 @property 

427 def _search_focused(self) -> bool: 

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

429 

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

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

432 """ 

433 return isinstance(self.focused, Input) 

434 

435 @property 

436 def _filter_open(self) -> bool: 

437 """True while the filter Input is revealed, independent of focus.""" 

438 return not self._search_input.has_class(_HIDDEN_CLASS) 

439 

440 def compose(self) -> ComposeResult: 

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

442 

443 with TopBars(): 

444 yield ViewTabs() 

445 yield Input( 

446 placeholder=msg.CATALOG_FILTER_PLACEHOLDER, 

447 id="catalog-search", 

448 classes=_HIDDEN_CLASS, 

449 ) 

450 # First-run guidance: shown while no chat model resolves, hidden the 

451 # moment one does. The fit chips on the cards carry the hardware signal. 

452 yield Static(msg.CATALOG_WELCOME, id="catalog-welcome") 

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

454 yield GridListToggle() 

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

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

457 # Horizontal split: TabbedContent fills, CatalogDetailDrawer docks 

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

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

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

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

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

463 with ( 

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

465 TabbedContent(id="catalog-tabs"), 

466 ): 

467 with TabPane(f"1 {msg.CATALOG_TAB_DISCOVER}", id=TAB_DISCOVER): 

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

469 with TabPane(f"2 {msg.CATALOG_TAB_CHAT}", id=TAB_CHAT): 

470 yield VerticalScroll( 

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

472 ) 

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

474 with TabPane(f"3 {msg.CATALOG_TAB_EMBED}", id=TAB_EMBED): 

475 yield VerticalScroll( 

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

477 ) 

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

479 with TabPane(f"4 {msg.CATALOG_TAB_VISION}", id=TAB_VISION): 

480 yield VerticalScroll( 

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

482 ) 

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

484 with TabPane(f"5 {msg.CATALOG_TAB_RERANK}", id=TAB_RERANK): 

485 yield VerticalScroll( 

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

487 ) 

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

489 with TabPane(f"6 {msg.CATALOG_TAB_LIBRARY}", id=TAB_LIBRARY): 

490 yield VerticalScroll( 

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

492 ) 

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

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

495 with BottomBars(): 

496 yield TaskBar() 

497 yield Footer() 

498 

499 def on_mount(self) -> None: 

500 self.watch(self.app, "chat_is_ready", self._on_chat_ready_changed, init=True) 

501 self._fetch_installed_names() 

502 self._fetch_families() 

503 # Force Chat as the initial active tab. Deliberately not 

504 # `TabbedContent(initial=...)`: that arms `Tabs._on_mount` to assign the 

505 # active tab unconditionally, and when the app tears down mid-mount 

506 # Textual's `mount_all` no-ops, so the strip has no Tab children yet and 

507 # the assignment raises `ValueError: No Tab with id`. Setting it here via 

508 # call_after_refresh also lets the TabActivated cascade settle first. 

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

510 # Discover via keyboard shortcut. 

511 self.call_after_refresh(self._activate_initial_tab) 

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

513 

514 def _activate_initial_tab(self) -> None: 

515 try: 

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

517 except Exception: 

518 self._activation_settled = True 

519 return 

520 target: str | None 

521 if self._focus_task is not None: 

522 # On-ramp: land directly on the requested task tab. 

523 self._active_tab_id_cache = self._focus_task 

524 target = self._focus_task 

525 else: 

526 target = TAB_CHAT if self._active_tab_id_cache == TAB_CHAT else None 

527 if target is not None and tabs.active != target: 

528 try: 

529 tabs.active = target 

530 except ValueError: 

531 # The strip's Tab children mount a frame after construction; on 

532 # a slow host this refresh callback can still beat them and the 

533 # setter raises "No Tab with id". Wait out the next refresh. 

534 if self._activation_retries > 0: 

535 self._activation_retries -= 1 

536 self.call_after_refresh(self._activate_initial_tab) 

537 return 

538 if not self._activation_settled: 

539 self._activation_settled = True 

540 self.call_after_refresh(self._refresh_grid) 

541 self.call_after_refresh(self._initial_focus_first_grid) 

542 self._fetch_remote_models() 

543 self._fetch_frontier_models() 

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

545 # task tabs fetch lazily on first activation (see 

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

547 # one HF round-trip instead of four. 

548 self._ensure_task_initial_fetch(ModelTask.CHAT) 

549 self.app.provider_availability_changed_signal.subscribe( 

550 self, self._on_provider_availability_changed 

551 ) 

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

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

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

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

556 # on every tab activation. 

557 for tab_id in (*TASK_TAB_IDS, TAB_LIBRARY): 

558 with contextlib.suppress(Exception): 

559 self.watch( 

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

561 ) 

562 self.watch( 

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

564 ) 

565 

566 def on_unmount(self) -> None: 

567 with contextlib.suppress(Exception): 

568 self.app.provider_availability_changed_signal.unsubscribe(self) 

569 self._stop_spinner_timer() 

570 

571 def on_screen_suspend(self) -> None: 

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

573 

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

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

576 of main-thread CPU forever. 

577 """ 

578 self._stop_spinner_timer() 

579 

580 def on_screen_resume(self) -> None: 

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

582 if self._loading_more or self._search_in_flight: 

583 self._sync_loading_spinner() 

584 

585 def _stop_spinner_timer(self) -> None: 

586 if self._spinner_timer is not None: 

587 self._spinner_timer.stop() 

588 self._spinner_timer = None 

589 

590 _FRONTIER_REFRESH_DEBOUNCE = 1.0 

591 

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

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

594 if self._frontier_refresh_timer is not None: 

595 self._frontier_refresh_timer.stop() 

596 self._frontier_refresh_timer = self.set_timer( 

597 self._FRONTIER_REFRESH_DEBOUNCE, self._fetch_frontier_models 

598 ) 

599 

600 def _focus_first_grid(self) -> None: 

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

602 with contextlib.suppress(Exception): 

603 grids = self._pane_grids_with_rows() 

604 if grids: 

605 self.set_focus(grids[0]) 

606 return 

607 with contextlib.suppress(Exception): 

608 self.set_focus(self._grid_container.query(GridSelect).first()) 

609 

610 def _initial_focus_first_grid(self) -> None: 

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

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

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

614 if self.focused is not None: 

615 return 

616 self._focus_first_grid() 

617 

618 def _fetch_installed_names(self) -> None: 

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

620 

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

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

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

624 has a manifest. 

625 

626 An unreadable registry leaves the previous set in place and logs. 

627 The screen must still open, so this is the one place the fault 

628 stops rather than reaching the user as a crash. 

629 """ 

630 try: 

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

632 except Exception: 

633 log.warning("Could not read the model registry; catalog install marks may be stale") 

634 return 

635 self._data_version += 1 

636 

637 def _active_tab_id(self) -> str: 

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

639 

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

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

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

643 """ 

644 return self._active_tab_id_cache 

645 

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

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

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

649 

650 def _active_task_has_more(self) -> bool: 

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

652 

653 Discover and Library tabs return False; neither paginates. Under an 

654 active search this is the search's own flag, so the hint describes the 

655 result set on screen. 

656 """ 

657 return self._active_listing_flag(self._search_has_more, self._hf_has_more_by_task) 

658 

659 def _active_task_truncated(self) -> bool: 

660 """True iff the active task tab's listing stopped at the scan bound with rows unread.""" 

661 return self._active_listing_flag(self._search_truncated, self._hf_truncated_by_task) 

662 

663 def _active_listing_flag(self, search_flag: bool, by_task: dict[ModelTask, bool]) -> bool: 

664 """The search's flag under a search, else the active task's; False off a task tab.""" 

665 task = self._active_task() 

666 if task is None: 

667 return False 

668 if self._get_search_text(): 

669 return search_flag 

670 return by_task.get(task, False) 

671 

672 def _hf_fetched_any(self) -> bool: 

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

674 

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

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

677 """ 

678 return bool(self._hf_fetched_tasks) 

679 

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

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

682 if task in self._hf_fetched_tasks: 

683 return 

684 self._hf_fetched_tasks.add(task) 

685 self._fetch_initial_hf_models_for_task(task) 

686 

687 def action_toggle_view(self) -> None: 

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

689 

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

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

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

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

694 """ 

695 if self._active_tab_id() not in TASK_TAB_IDS: 

696 return 

697 if self._view_switching: 

698 return 

699 self._view_switching = True 

700 try: 

701 if self._grid_view: 

702 self._grid_view = False 

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

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

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

706 if active_task is not None: 

707 self._ensure_task_initial_fetch(active_task) 

708 with self.app.batch_update(): 

709 self._refresh_list() 

710 self._focus_list_item(0) 

711 else: 

712 self._grid_view = True 

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

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

715 with self.app.batch_update(): 

716 self._refresh_grid() 

717 with contextlib.suppress(Exception): 

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

719 finally: 

720 self._view_switching = False 

721 self._sync_grid_list_toggle() 

722 

723 def _sync_grid_list_toggle(self) -> None: 

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

725 

726 with contextlib.suppress(Exception): 

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

728 

729 def action_focus_search(self) -> None: 

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

731 

732 ``set_focus`` rather than ``Input.focus()``: Widget.focus() only queues 

733 the move via ``call_later``, so two pending focus callbacks resolve in 

734 queue order and whichever lands last wins. Setting it on the screen 

735 lands now, which keeps `/` from losing to a focus queued beside it. 

736 """ 

737 self._search_input.remove_class(_HIDDEN_CLASS) 

738 self.set_focus(self._search_input) 

739 

740 _SEARCH_FILTER_DEBOUNCE_SECONDS = 0.08 

741 # The remote leg is a round trip, not a repaint, so it waits for a real 

742 # pause in typing. 

743 _REMOTE_SEARCH_DEBOUNCE_SECONDS = 0.45 

744 

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

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

747 """Schedule a filter pass, and a hub search behind a longer debounce. 

748 

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

750 which Textual treats as layout invalidations. Without the debounce 

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

752 to a single pass once the user pauses. 

753 

754 The filter only narrows models already fetched, so a term the catalog 

755 has not paged to would otherwise read as "no such model". 

756 """ 

757 if self._search_filter_timer is not None: 

758 self._search_filter_timer.stop() 

759 self._search_filter_timer = self.set_timer( 

760 self._SEARCH_FILTER_DEBOUNCE_SECONDS, 

761 self._apply_search_filter, 

762 ) 

763 if self._remote_search_timer is not None: 

764 self._remote_search_timer.stop() 

765 # The Input already holds the new value when this fires. 

766 if not self._get_search_text(): 

767 # Cleared: the next search starts its own result set. 

768 self._searched_query = "" 

769 self._search_offset = 0 

770 self._search_has_more = False 

771 self._search_truncated = False 

772 return 

773 self._remote_search_timer = self.set_timer( 

774 self._REMOTE_SEARCH_DEBOUNCE_SECONDS, 

775 lambda: self._trigger_remote_search(self._get_search_text()), 

776 ) 

777 

778 def _apply_search_filter(self) -> None: 

779 if self._active_tab_id() == TAB_LIBRARY: 

780 self._populate_library_list() 

781 return 

782 if self._active_tab_id() == TAB_DISCOVER: 

783 return 

784 if self._grid_view: 

785 self._filter_grid() 

786 else: 

787 self._filter_list() 

788 

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

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

791 """Enter dismisses the search box and puts the cursor on the results. 

792 

793 Typing already runs the filter and the hub search, so there is no query 

794 left to submit. It must not install: every row is a multi-gigabyte 

795 download and the top row is whichever one sorted first. Installing is a 

796 deliberate Enter on the focused card. 

797 """ 

798 self._focus_first_result() 

799 

800 def _focus_first_result(self) -> None: 

801 """Move the cursor to the first match. Focus only, never selection.""" 

802 with contextlib.suppress(Exception): 

803 if not self._grid_view: 

804 self._focus_first_list_row() 

805 return 

806 self._focus_first_grid_card() 

807 

808 def _focus_first_list_row(self) -> None: 

809 """Put the list cursor on the first row, if there is one.""" 

810 if not self._list_widget.option_count: 

811 return 

812 self._list_widget.highlighted = 0 

813 self._list_widget.focus() 

814 

815 def _focus_first_grid_card(self) -> None: 

816 """Put the grid cursor on the first card of the first populated grid.""" 

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

818 if grid.rows: 

819 grid.focus() 

820 grid.highlighted = 0 

821 return 

822 

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

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

825 

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

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

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

829 the search Input is hidden on them. 

830 """ 

831 if self._search_in_flight or not query: 

832 return 

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

834 if active_task is None: 

835 return 

836 # A new term starts its own result set; only _load_more advances the 

837 # offset, and only for the query it was fetched under. 

838 if query != self._searched_query: 

839 self._searched_query = query 

840 self._search_offset = 0 

841 self._search_has_more = False 

842 self._search_truncated = False 

843 self._search_in_flight = True 

844 self._update_sort_label() 

845 # The toolbar spinner carries this in both views. No toast: typing 

846 # fires this, so one per pause would stack up over a single term. 

847 self._sync_loading_spinner() 

848 self._fetch_hf_search(query, active_task, self._search_offset) 

849 

850 def _resume_search_if_term_moved_on(self) -> None: 

851 """Re-run the hub search when the box moved on during the last one. 

852 

853 ``_trigger_remote_search`` drops a request that arrives mid-flight, so 

854 the term typed during a round trip would otherwise never reach the hub. 

855 """ 

856 query = self._get_search_text() 

857 if query and query != self._searched_query: 

858 self._trigger_remote_search(query) 

859 

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

861 def _on_search_hf_cta_clicked(self) -> None: 

862 self._trigger_remote_search(self._get_search_text()) 

863 

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

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

866 

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

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

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

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

871 """ 

872 offset = self._hf_offset_by_task[task] 

873 result = get_catalog( 

874 task=task, 

875 featured=False, 

876 limit=_HF_PAGE_SIZE, 

877 offset=offset, 

878 ) 

879 self._hf_has_more_by_task[task] = result.has_more 

880 self._hf_truncated_by_task[task] = result.truncated 

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

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

883 

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

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

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

887 return self._fetch_hf_page_for_task(task) 

888 

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

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

891 return classify_all_remote_models() 

892 

893 @work(thread=True, name=_WORKER_FETCH_FAMILIES) 

894 def _fetch_families(self) -> list[ModelFamily]: 

895 """Group the picks into families off the UI thread. 

896 

897 ``get_families`` resolves the picks, which is an HTTP round trip on the 

898 first call of a session. Doing it during screen construction froze the 

899 catalog on open for as long as HuggingFace took to answer. 

900 """ 

901 try: 

902 return get_families() 

903 except Exception: 

904 log.debug("get_families failed in worker", exc_info=True) 

905 return [] 

906 

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

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

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

910 

911 ``discover_api_model_groups`` imports the SDK (heavy, >50ms) and 

912 checks every configured provider key over the network. Running it on 

913 the main thread froze the catalog on mount and on every 

914 signal-driven refresh; the worker keeps the screen responsive.""" 

915 from lilbee.modelhub.model_manager import discover_api_model_groups 

916 

917 try: 

918 groups = discover_api_model_groups() 

919 except Exception: 

920 log.debug("discover_api_model_groups failed in worker", exc_info=True) 

921 return [] 

922 

923 rows = [ 

924 frontier_row_from_remote(rm, provider_id=group.provider, key_status=group.key_status) 

925 for group in groups 

926 for rm in group.models 

927 ] 

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

929 return rows 

930 

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

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

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

934 return self._fetch_hf_page_for_task(task) 

935 

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

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

938 """Fetch one page of HF models matching *query* for *task* (worker thread). 

939 

940 Writes ``_search_has_more`` from the worker thread the same way 

941 ``_fetch_hf_page_for_task`` writes the per-task flag: the assignment is 

942 GIL-atomic and the main thread only reads it. 

943 """ 

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

945 result = get_catalog( 

946 task=task, 

947 featured=False, 

948 search=query, 

949 limit=_HF_SEARCH_LIMIT, 

950 offset=offset, 

951 ) 

952 self._search_has_more = result.has_more 

953 self._search_truncated = result.truncated 

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

955 

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

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

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

959 self._handle_worker_error_or_cancel(event.worker.name) 

960 return 

961 if event.state != WorkerState.SUCCESS: 

962 return 

963 result = event.worker.result 

964 if not isinstance(result, list): 

965 return 

966 worker_name = event.worker.name 

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

968 return 

969 # A fast worker can complete before TabbedContent finishes mounting 

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

971 # _activate_initial_tab schedules rebuild against the applied state. 

972 with contextlib.suppress(NoMatches): 

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

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

975 if worker_name == _WORKER_FETCH_MORE_HF: 

976 if self._grid_view: 

977 self._refresh_grid() 

978 else: 

979 self._append_more_hf_to_list(result) 

980 return 

981 self._refresh_view() 

982 

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

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

985 

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

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

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

989 sibling tab's list. 

990 """ 

991 active_task = self._active_task() 

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

993 self._refresh_view() 

994 return 

995 new_rows = self._sort_rows( 

996 [ 

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

998 for m in new_models 

999 ] 

1000 ) 

1001 if not new_rows: 

1002 self._update_sort_label() 

1003 return 

1004 self._rows.extend(new_rows) 

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

1006 # Update the per-tab cache key (not a stray singular attribute) so a 

1007 # subsequent _refresh_list for this tab sees the appended rows as cached. 

1008 self._list_cache_keys[self._active_tab_id_cache] = ( 

1009 self._data_version, 

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

1011 self._get_search_text(), 

1012 ) 

1013 self._update_sort_label() 

1014 

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

1016 if name == _WORKER_FETCH_MORE_HF: 

1017 self._loading_more = False 

1018 if name == _WORKER_FETCH_SEARCH: 

1019 self._search_in_flight = False 

1020 self._update_sort_label() 

1021 self._resume_search_if_term_moved_on() 

1022 if name == _WORKER_FETCH_FAMILIES: 

1023 self._families_in_flight = False 

1024 self._sync_loading_spinner() 

1025 

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

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

1028 

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

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

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

1032 if name == _WORKER_FETCH_HF: 

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

1034 # one carries dedup-filtered new rows (see 

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

1036 self._hf_models.extend(result) 

1037 self._loading_more = False 

1038 elif name == _WORKER_FETCH_MORE_HF: 

1039 self._hf_models.extend(result) 

1040 self._loading_more = False 

1041 elif name == _WORKER_FETCH_SEARCH: 

1042 self._hf_models.extend(result) 

1043 self._search_in_flight = False 

1044 self._update_sort_label() 

1045 self._resume_search_if_term_moved_on() 

1046 elif name == _WORKER_FETCH_REMOTE: 

1047 self._remote_models = result 

1048 elif name == _WORKER_FETCH_FRONTIER: 

1049 self._frontier_rows = result 

1050 self._populate_library_list() 

1051 elif name == _WORKER_FETCH_FAMILIES: 

1052 self._families = result 

1053 self._families_in_flight = False 

1054 else: 

1055 return False 

1056 self._data_version += 1 

1057 self._sync_loading_spinner() 

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

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

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

1061 # _populate_discover_rails fires only on tab activation. 

1062 if self._active_tab_id_cache == TAB_DISCOVER: 

1063 self._populate_discover_rails() 

1064 return True 

1065 

1066 def _populate_library_list(self) -> None: 

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

1068 search = self._get_search_text() 

1069 installed_rows: list[LocalCatalogRow] = [] 

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

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

1072 if search: 

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

1074 frontier = self._build_frontier_rows(search) 

1075 self._render_library_list(installed_rows, frontier) 

1076 self._render_library_grid(installed_rows, frontier) 

1077 

1078 def _render_library_list( 

1079 self, 

1080 installed_rows: list[LocalCatalogRow], 

1081 frontier: list[FrontierCatalogRow], 

1082 ) -> None: 

1083 try: 

1084 ml = self._list_for_tab(TAB_LIBRARY) 

1085 except Exception: 

1086 return 

1087 sections: list[ModelListSection] = [] 

1088 if installed_rows: 

1089 sections.append( 

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

1091 ) 

1092 sections.extend(group_frontier_rows(frontier)) 

1093 ml.set_rows(sections) 

1094 

1095 def _render_library_grid( 

1096 self, 

1097 installed_rows: list[LocalCatalogRow], 

1098 frontier: list[FrontierCatalogRow], 

1099 ) -> None: 

1100 try: 

1101 container = self._grid_for_tab(TAB_LIBRARY) 

1102 except Exception: 

1103 return 

1104 sections: list[GridSection] = [] 

1105 if installed_rows: 

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

1107 if frontier: 

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

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

1110 existing_headings = [ 

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

1112 ] 

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

1114 for grid, heading, section in zip( 

1115 existing_grids, existing_headings, sections, strict=False 

1116 ): 

1117 heading.update(section.heading) 

1118 grid.set_rows(section.rows) 

1119 return 

1120 container.remove_children() 

1121 for section in sections: 

1122 container.mount_all( 

1123 [ 

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

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

1126 ] 

1127 ) 

1128 

1129 def _get_search_text(self) -> str: 

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

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

1132 # would otherwise raise NoMatches and crash the callback. 

1133 try: 

1134 return self._search_input.value.strip() 

1135 except Exception: 

1136 return "" 

1137 

1138 def _local_rows_data_key(self) -> _RowCacheKey: 

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

1140 

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

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

1143 """ 

1144 return ( 

1145 len(self._families), 

1146 len(self._hf_models), 

1147 len(self._remote_models), 

1148 len(self._hf_fetched_tasks), 

1149 len(self._installed_names), 

1150 self._data_version, 

1151 ) 

1152 

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

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

1155 

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

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

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

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

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

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

1162 non-primary size without leaving the grid. 

1163 """ 

1164 key = self._local_rows_data_key() 

1165 cached = self._family_rows_cache 

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

1167 return cached.rows 

1168 rows: list[LocalCatalogRow] = [] 

1169 for fam in self._families: 

1170 if not fam.variants: 

1171 continue 

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

1173 family_installed = any( 

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

1175 for v in fam.variants 

1176 ) 

1177 row = variant_to_row(primary, fam, family_installed) 

1178 row.size_variants = family_to_size_variants(fam) 

1179 rows.append(row) 

1180 self._stamp_fit(rows) 

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

1182 return rows 

1183 

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

1185 key = self._local_rows_data_key() 

1186 cached = self._hf_rows_cache 

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

1188 return cached.rows 

1189 rows: list[LocalCatalogRow] = [] 

1190 for m in self._hf_models: 

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

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

1193 self._stamp_fit(rows) 

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

1195 return rows 

1196 

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

1198 key = self._local_rows_data_key() 

1199 cached = self._remote_rows_cache 

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

1201 return cached.rows 

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

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

1204 self._stamp_fit(rows) 

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

1206 return rows 

1207 

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

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

1210 

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

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

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

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

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

1216 """ 

1217 for row in rows: 

1218 row.fit = chip_for_size(row.sort_size, self._available_memory_bytes) 

1219 

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

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

1222 search = self._get_search_text() 

1223 rows: list[LocalCatalogRow] = [] 

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

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

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

1227 return rows 

1228 

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

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

1231 if not search: 

1232 return self._all_family_rows() 

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

1234 

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

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

1237 if not search: 

1238 return self._all_hf_rows() 

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

1240 

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

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

1243 if not search: 

1244 return self._all_remote_rows() 

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

1246 

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

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

1249 

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

1251 worker) because the SDK import + key probing blocks the UI 

1252 thread. Renderers call this synchronously to read the 

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

1254 """ 

1255 if not self._frontier_rows: 

1256 return [] 

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

1258 

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

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

1261 if name in self._installed_names: 

1262 return True 

1263 if repo and filename: 

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

1265 return False 

1266 

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

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

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

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

1271 return sorted( 

1272 rows, 

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

1274 reverse=not self._sort_ascending, 

1275 ) 

1276 

1277 def _refresh_view(self) -> None: 

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

1279 

1280 Discover renders rails, not sections; worker landings while it is 

1281 active repopulate the rails instead of painting a hidden task pane. 

1282 

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

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

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

1286 DOM.""" 

1287 if self._active_tab_id_cache == TAB_DISCOVER: 

1288 self._populate_discover_rails() 

1289 return 

1290 with self.app.batch_update(): 

1291 if self._grid_view: 

1292 self._refresh_grid() 

1293 else: 

1294 self._refresh_list() 

1295 

1296 def _refresh_grid(self) -> None: 

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

1298 

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

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

1301 update each existing ModelGrid via set_rows rather than tearing 

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

1303 CPU spike on every "Browse more" return. 

1304 

1305 A scheduled refresh can fire while the screen is composing or being 

1306 dismissed, when the tab containers aren't mounted; there is nothing 

1307 to paint then, and the guard runs before the row-cache update so the 

1308 next mounted refresh still repaints. 

1309 """ 

1310 if self._active_tab_id_cache == TAB_DISCOVER: 

1311 # Discover paints rails via _populate_discover_rails, never sections. 

1312 return 

1313 if not self._grid_mounted(): 

1314 return 

1315 prep = self._prepare_grid_refresh() 

1316 if prep is None: 

1317 self._update_sort_label() 

1318 return 

1319 sections, hf_count, filter_changed = prep 

1320 if filter_changed: 

1321 # The offset belongs to the previous result set. Keeping it parks 

1322 # the viewport past the end of a shorter one (Textual clamps to the 

1323 # new max), so the matches render above the visible area. 

1324 self._grid_container.scroll_to(y=0, animate=False) 

1325 if not sections: 

1326 self._grid_container.remove_children() 

1327 self._mount_grid_ctas(hf_count=hf_count) 

1328 self._update_sort_label() 

1329 return 

1330 if self._extend_grid_sections_in_place(sections, hf_count): 

1331 return 

1332 self._remount_grid_sections(sections, hf_count) 

1333 self._update_sort_label() 

1334 

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

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

1337 

1338 Third element is True when the search text changed since the last 

1339 paint, which the caller uses to reset the scroll offset. 

1340 

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

1342 cached path still picks up sort-toggle clicks. 

1343 """ 

1344 search = self._get_search_text() 

1345 family_rows = self._build_family_rows(search) 

1346 remote_rows = self._build_remote_rows(search) 

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

1348 all_rows = family_rows + remote_rows + hf_rows 

1349 active_tab = self._active_tab_id_cache 

1350 tab_rows = self._rows_for_active_tab(all_rows, active_tab) 

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

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

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

1354 # toward the local-row tally. 

1355 local_tab_rows: list[LocalCatalogRow] = [ 

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

1357 ] 

1358 self._rows = local_tab_rows 

1359 # _data_version is part of the key: row signatures cover only 

1360 # (name, installed), so a worker landing that changes rendered state 

1361 # the signature misses (frontier key_status, fit, compat) must still 

1362 # repaint rather than read as cache-hot. 

1363 row_key = _GridCacheKey( 

1364 self._data_version, 

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

1366 search, 

1367 ) 

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

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

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

1371 previous_key = self._grid_cache_keys.get(active_tab) 

1372 if previous_key == row_key: 

1373 return None 

1374 self._grid_cache_keys[active_tab] = row_key 

1375 filter_changed = previous_key is not None and previous_key.search != search 

1376 if active_tab in TASK_TAB_IDS: 

1377 active_task = TAB_ID_TO_TASK[active_tab] 

1378 task_label = active_task.value.capitalize() 

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

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

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

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

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

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

1385 if frontier_only: 

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

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

1388 else: 

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

1390 hf_count = len(hf_rows) 

1391 if search: 

1392 # A filtered catalog is one result set, not a taxonomy: matches 

1393 # render flat so the viewport holds cards instead of headings. 

1394 sections = flatten_sections(sections, msg.HEADING_MATCHES) 

1395 return sections, hf_count, filter_changed 

1396 

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

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

1399 

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

1401 through to a teardown + remount on False. 

1402 """ 

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

1404 existing_headings = [ 

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

1406 ] 

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

1408 return False 

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

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

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

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

1413 for grid, heading, section in zip( 

1414 existing_grids, existing_headings, sections, strict=False 

1415 ): 

1416 heading.update(section.heading) 

1417 grid.set_rows(section.rows) 

1418 self._refresh_grid_ctas(hf_count=hf_count) 

1419 self._update_sort_label() 

1420 return True 

1421 

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

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

1424 

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

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

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

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

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

1430 looking. 

1431 """ 

1432 focus_anchor = self._capture_focused_section() 

1433 container = self._grid_container 

1434 prior_scroll_y = container.scroll_y 

1435 container.remove_children() 

1436 self._mount_grid_section(sections[0], container) 

1437 # Pass the container itself; the deferred callback must not re-query 

1438 # by id because the pane can be unmounted before it runs. 

1439 self.call_after_refresh( 

1440 self._mount_remaining_grid_sections, 

1441 container, 

1442 sections[1:], 

1443 hf_count=hf_count, 

1444 focus_anchor=focus_anchor, 

1445 prior_scroll_y=prior_scroll_y, 

1446 ) 

1447 

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

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

1450 

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

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

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

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

1455 """ 

1456 focused = self._focused_grid() 

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

1458 return None 

1459 return (focused.name, focused.highlighted) 

1460 

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

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

1463 

1464 Returns True when the previous focus position was successfully 

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

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

1467 """ 

1468 if anchor is None: 

1469 return False 

1470 target_heading, target_highlighted = anchor 

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

1472 if grid.name != target_heading: 

1473 continue 

1474 grid.focus() 

1475 if target_highlighted is not None and grid.rows: 

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

1477 return True 

1478 return False 

1479 

1480 def _mount_grid_section(self, section: GridSection, container: VerticalScroll) -> AwaitMount: 

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

1482 # ``_capture_focused_section`` / ``_restore_focused_section`` to 

1483 # preserve the cursor across teardown + remount. 

1484 # 

1485 # Returns mount_all's awaitable so a caller that needs the widgets to 

1486 # exist can wait for them. Pilot.pause only waits on the widgets that 

1487 # were present when it was called, so it never covers these. 

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

1489 return container.mount_all( 

1490 [ 

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

1492 grid, 

1493 ] 

1494 ) 

1495 

1496 def _mount_remaining_grid_sections( 

1497 self, 

1498 container: VerticalScroll, 

1499 remaining: list[GridSection], 

1500 hf_count: int, 

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

1502 prior_scroll_y: float = 0.0, 

1503 ) -> list[AwaitMount]: 

1504 # Runs one refresh after _remount_grid_sections; the pane can be 

1505 # unmounted by then (screen teardown, tab remount). Returns one 

1506 # awaitable per mounted section (empty when the pane is gone) so a 

1507 # caller can wait for the sections to exist; the production caller 

1508 # runs from call_after_refresh and ignores it. 

1509 if not container.is_running: 

1510 return [] 

1511 mounts = [self._mount_grid_section(section, container) for section in remaining] 

1512 self._mount_grid_ctas(hf_count=hf_count) 

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

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

1515 if prior_scroll_y: 

1516 container.scroll_to(y=prior_scroll_y, animate=False) 

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

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

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

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

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

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

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

1524 return mounts 

1525 # ...but "no grid has focus" is not "nothing has focus". This runs from 

1526 # call_after_refresh, so the user may have pressed `/` since the mount 

1527 # was scheduled; _focused_grid() is None while the filter Input owns the 

1528 # cursor, so the check above would let a repaint yank it out of the 

1529 # field they just opened. Same invariant _initial_focus_first_grid keeps. 

1530 if self._search_focused: 

1531 return mounts 

1532 if self._restore_focused_section(focus_anchor): 

1533 return mounts 

1534 self._focus_first_grid() 

1535 return mounts 

1536 

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

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

1539 if self._loading_more: 

1540 return msg.CATALOG_GRID_LOADING_MORE.format(frame=SPINNER_FRAMES[self._spinner_frame]) 

1541 if self._active_task_has_more(): 

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

1543 if self._active_task_truncated(): 

1544 return msg.CATALOG_GRID_CUT_SHORT.format(count=hf_count) 

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

1546 

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

1548 try: 

1549 container = self._grid_container 

1550 except Exception: 

1551 return 

1552 ctas: list[Static] = [ 

1553 Static( 

1554 self._grid_scroll_hint_text(hf_count), 

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

1556 ) 

1557 ] 

1558 search = self._get_search_text() 

1559 if search: 

1560 ctas.append( 

1561 Static( 

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

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

1564 markup=False, 

1565 ) 

1566 ) 

1567 container.mount_all(ctas) 

1568 

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

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

1571 try: 

1572 container = self._grid_container 

1573 except Exception: 

1574 return 

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

1576 for w in existing: 

1577 with contextlib.suppress(Exception): 

1578 w.remove() 

1579 self._mount_grid_ctas(hf_count=hf_count) 

1580 

1581 def _rows_for_active_tab( 

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

1583 ) -> list[CatalogRow]: 

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

1585 

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

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

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

1589 also union the matching frontier rows. 

1590 """ 

1591 if active_tab not in TASK_TAB_IDS: 

1592 return list(all_rows) 

1593 active_task = TAB_ID_TO_TASK[active_tab] 

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

1595 local_for_task: list[CatalogRow] = [] 

1596 if mode is not SourceMode.CLOUD: 

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

1598 frontier_for_task: list[CatalogRow] = [] 

1599 if mode is not SourceMode.LOCAL: 

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

1601 return local_for_task + frontier_for_task 

1602 

1603 def _filter_grid(self) -> None: 

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

1605 self._refresh_grid() 

1606 

1607 @on(ModelGrid.Highlighted) 

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

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

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

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

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

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

1614 highlighted row. 

1615 """ 

1616 self._maybe_prefetch_on_grid_nav() 

1617 self._reveal_scroll_hint_at_catalog_end() 

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

1619 

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

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

1622 try: 

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

1624 except Exception: 

1625 return 

1626 rows = grid.rows 

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

1628 drawer.update_for_row(row) 

1629 

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

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

1632 

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

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

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

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

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

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

1639 """ 

1640 if self._search_focused: 

1641 return 

1642 # 1-based digit -> 0-based tab index; bounded by the tab count so the 

1643 # 1..6 contract derives from ALL_TAB_IDS rather than a parallel map. 

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

1645 

1646 if not event.key.isdigit(): 

1647 return 

1648 index = int(event.key) - 1 

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

1650 return 

1651 event.stop() 

1652 event.prevent_default() 

1653 self.action_select_tab(index) 

1654 

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

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

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

1658 

1659 if self._search_focused: 

1660 return 

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

1662 return 

1663 target = ALL_TAB_IDS[index] 

1664 try: 

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

1666 except Exception: 

1667 return 

1668 self.set_focus(None) 

1669 if tabs.active != target: 

1670 tabs.active = target 

1671 self._active_tab_id_cache = target 

1672 

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

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

1675 

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

1677 either direction without hitting an end stop. 

1678 """ 

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

1680 

1681 if self._search_focused: 

1682 return 

1683 try: 

1684 current = ALL_TAB_IDS.index(self._active_tab_id_cache) 

1685 except ValueError: 

1686 current = 0 

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

1688 self.action_select_tab(next_index) 

1689 

1690 def action_cycle_source(self) -> None: 

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

1692 

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

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

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

1696 """ 

1697 if self._search_focused: 

1698 return 

1699 active = self._active_tab_id_cache 

1700 if active not in TASK_TAB_IDS: 

1701 return 

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

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

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

1705 self._grid_cache_keys.pop(active, None) 

1706 self._list_cache_keys.pop(active, None) 

1707 self._refresh_view() 

1708 

1709 def action_toggle_drawer(self) -> None: 

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

1711 

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

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

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

1715 """ 

1716 try: 

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

1718 except Exception: 

1719 return 

1720 drawer.toggle_class("-collapsed") 

1721 

1722 def _reveal_scroll_hint_at_catalog_end(self) -> None: 

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

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

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

1726 

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

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

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

1730 

1731 Task tabs only: Discover/Library have no inline scroll hint, and the 

1732 overshoot would yank the rails viewport away from the cursor. 

1733 """ 

1734 if self._active_task() is None: 

1735 return 

1736 focused = self._focused_grid() 

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

1738 return 

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

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

1741 return 

1742 cols = max(1, focused.columns_per_row) 

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

1744 if focused.highlighted // cols < last_row: 

1745 return 

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

1747 

1748 def _pane_grids_with_rows(self) -> list[ModelGrid]: 

1749 """The active pane's ModelGrids that have cards (empty rails skipped).""" 

1750 try: 

1751 return [g for g in self._grid_container.query(ModelGrid) if g.rows] 

1752 except NoMatches: 

1753 return [] 

1754 

1755 @staticmethod 

1756 def _enter_grid(grid: ModelGrid, *, from_above: bool) -> None: 

1757 """Deliberate cursor entry into *grid* (toad's move_focus pattern). 

1758 

1759 Highlight before focus: on_focus only assigns a cursor when none is 

1760 set, so the entry cell wins and the arrival is always visible. 

1761 """ 

1762 if from_above: 

1763 grid.highlight_first() 

1764 else: 

1765 grid.highlight_last() 

1766 grid.focus() 

1767 

1768 @on(GridSelect.LeaveDown) 

1769 @on(ModelGrid.LeaveDown) 

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

1771 """Enter the next grid in the active pane, or fetch more at the end. 

1772 

1773 Only grids are arrow targets (never the generic focus chain, which 

1774 can land on arrow-dead widgets and lose the cursor). On the 

1775 bottom-most grid we expose the inline scroll-hint Static (mounted 

1776 below the last grid via ``_mount_grid_ctas``) by scrolling the 

1777 container to its end; cursor stays parked on the last cell. 

1778 """ 

1779 if isinstance(event, ModelGrid.LeaveDown): 

1780 grids = self._pane_grids_with_rows() 

1781 try: 

1782 index = grids.index(event.grid) 

1783 except ValueError: 

1784 return 

1785 if index + 1 < len(grids): 

1786 self._enter_grid(grids[index + 1], from_above=True) 

1787 return 

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

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

1790 self._load_more() 

1791 return 

1792 self.focus_next() 

1793 

1794 @on(GridSelect.LeaveUp) 

1795 @on(ModelGrid.LeaveUp) 

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

1797 """Enter the previous grid in the active pane. 

1798 

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

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

1801 """ 

1802 if isinstance(event, ModelGrid.LeaveUp): 

1803 grids = self._pane_grids_with_rows() 

1804 try: 

1805 index = grids.index(event.grid) 

1806 except ValueError: 

1807 return 

1808 if index > 0: 

1809 self._enter_grid(grids[index - 1], from_above=False) 

1810 return 

1811 self.focus_previous() 

1812 

1813 @on(GridSelect.Selected) 

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

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

1816 widget = event.widget 

1817 if isinstance(widget, ModelCard): 

1818 self._select_row(widget.row) 

1819 

1820 @on(ModelGrid.Selected) 

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

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

1823 self._select_row(event.row) 

1824 

1825 @on(ModelList.Selected) 

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

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

1828 self._select_row(event.row) 

1829 

1830 def _refresh_list(self) -> None: 

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

1832 if not self._list_mounted(): 

1833 return 

1834 active_tab = self._active_tab_id_cache 

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

1836 if active_tab in TASK_TAB_IDS: 

1837 active_task = TAB_ID_TO_TASK[active_tab] 

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

1839 else: 

1840 self._rows = list(all_rows) 

1841 search = self._get_search_text() 

1842 # _data_version mirrors the grid key: worker landings must repaint 

1843 # even when (name, installed) shapes coincide. 

1844 list_key = ( 

1845 self._data_version, 

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

1847 search, 

1848 ) 

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

1850 self._update_sort_label() 

1851 return 

1852 self._list_cache_keys[active_tab] = list_key 

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

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

1855 self._update_sort_label() 

1856 

1857 def _filter_list(self) -> None: 

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

1859 search = self._get_search_text() 

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

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

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

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

1864 self._list_cache_keys[self._active_tab_id_cache] = ( 

1865 self._data_version, 

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

1867 search, 

1868 ) 

1869 self._update_sort_label() 

1870 

1871 def _sync_loading_spinner(self) -> None: 

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

1873 

1874 Visible when a paginated HF fetch, a remote search, or the initial 

1875 families resolution is in flight (both grid and list views share the 

1876 same toolbar widget). Cycles braille frames on a 100 ms timer so the 

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

1878 """ 

1879 try: 

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

1881 except Exception: 

1882 return 

1883 active = self._loading_more or self._search_in_flight or self._families_in_flight 

1884 if active: 

1885 spinner.styles.display = "block" 

1886 spinner.update(f"{SPINNER_FRAMES[self._spinner_frame]} loading…") 

1887 if self._spinner_timer is None: 

1888 self._spinner_timer = self.set_interval( 

1889 _SPINNER_INTERVAL_S, self._tick_loading_spinner 

1890 ) 

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

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

1893 # same place mouse scroll surfaces it. 

1894 if self._loading_more: 

1895 with contextlib.suppress(Exception): 

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

1897 hint.update( 

1898 msg.CATALOG_GRID_LOADING_MORE.format( 

1899 frame=SPINNER_FRAMES[self._spinner_frame] 

1900 ) 

1901 ) 

1902 else: 

1903 spinner.update("") 

1904 spinner.styles.display = "none" 

1905 if self._spinner_timer is not None: 

1906 self._spinner_timer.stop() 

1907 self._spinner_timer = None 

1908 self._spinner_frame = 0 

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

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

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

1912 active_task = self._active_task() 

1913 hf_count = ( 

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

1915 if active_task is not None 

1916 else len(hf_rows) 

1917 ) 

1918 self._refresh_grid_ctas(hf_count=hf_count) 

1919 

1920 def _tick_loading_spinner(self) -> None: 

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

1922 self._spinner_frame = (self._spinner_frame + 1) % len(SPINNER_FRAMES) 

1923 with contextlib.suppress(Exception): 

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

1925 spinner.update(f"{SPINNER_FRAMES[self._spinner_frame]} loading…") 

1926 if self._loading_more: 

1927 with contextlib.suppress(Exception): 

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

1929 hint.update( 

1930 msg.CATALOG_GRID_LOADING_MORE.format(frame=SPINNER_FRAMES[self._spinner_frame]) 

1931 ) 

1932 

1933 def _update_sort_label(self) -> None: 

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

1935 

1936 Wrapped in NoMatches suppression because the worker callbacks that 

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

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

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

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

1941 """ 

1942 try: 

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

1944 except NoMatches: 

1945 return 

1946 if self._active_tab_id() == TAB_LIBRARY: 

1947 label.update(self._frontier_label_text()) 

1948 return 

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

1950 n_total = len(self._rows) 

1951 if self._loading_more: 

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

1953 elif self._active_task_has_more(): 

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

1955 elif self._active_task_truncated(): 

1956 count = msg.CATALOG_LIST_CUT_SHORT.format(count=n_total) 

1957 else: 

1958 count = msg.CATALOG_LIST_COUNT.format(count=n_total) 

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

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

1961 

1962 def _frontier_label_text(self) -> str: 

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

1964 return msg.CATALOG_FRONTIER_SUMMARY.format( 

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

1966 ) 

1967 

1968 def action_cycle_sort(self) -> None: 

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

1970 if self._search_focused: 

1971 return 

1972 if self._active_tab_id() not in TASK_TAB_IDS: 

1973 return 

1974 if self._grid_view: 

1975 self.notify(msg.CATALOG_SORT_LIST_ONLY) 

1976 return 

1977 try: 

1978 idx = _SORT_CYCLE.index(self._sort_column) 

1979 except ValueError: 

1980 idx = -1 

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

1982 self._sort_ascending = True 

1983 self._refresh_list() 

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

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

1986 self.call_after_refresh(self._focus_list_item, 0) 

1987 

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

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

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

1991 self._select_frontier_row(row) 

1992 return 

1993 if row.variant and row.family: 

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

1995 elif row.catalog_model: 

1996 self._install_model(row.catalog_model) 

1997 elif row.remote_model: 

1998 self._activate( 

1999 _model_field_for_task(row.remote_model.task), 

2000 row.ref, 

2001 msg.CATALOG_USING_REMOTE.format(name=row.remote_model.name), 

2002 ) 

2003 

2004 def _activate(self, field: str, ref: str, notice: str) -> None: 

2005 """Make *ref* the active model of *field*; *notice* shows once the swap has taken.""" 

2006 apply_model_pick( 

2007 self, 

2008 key=field, 

2009 ref=ref, 

2010 on_done=lambda: self.notify(notice), 

2011 reload_worker=field == "embedding_model", 

2012 ) 

2013 

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

2015 """Activate a cloud model, or jump to settings when its key is missing or rejected.""" 

2016 if row.key_status == KeyStatus.READY: 

2017 self._activate( 

2018 _model_field_for_task(row.task), 

2019 row.ref, 

2020 msg.CATALOG_USING_FRONTIER.format(name=row.name, provider=row.provider), 

2021 ) 

2022 return 

2023 key_field = PROVIDER_API_KEY_FIELD.get(row.provider_id, f"{row.provider_id}_api_key") 

2024 self.notify( 

2025 _KEY_PROBLEM_MESSAGE[row.key_status].format(provider=row.provider, key_field=key_field), 

2026 severity="warning", 

2027 timeout=10, 

2028 ) 

2029 self.app.switch_view("Settings") 

2030 

2031 def _load_more(self) -> None: 

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

2033 

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

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

2036 because they have no associated task and can't paginate. While a 

2037 search is active the search's own offset advances instead; paging the 

2038 browse offset there would fetch models the filter then discards. 

2039 """ 

2040 if self._loading_more: 

2041 return 

2042 if self._get_search_text(): 

2043 self._load_more_search_results() 

2044 return 

2045 task = self._active_task() 

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

2047 return 

2048 self._loading_more = True 

2049 self._sync_loading_spinner() 

2050 self._hf_offset_by_task[task] += _HF_PAGE_SIZE 

2051 self._fetch_more_hf_for_task(task) 

2052 

2053 def _load_more_search_results(self) -> None: 

2054 """Advance the active search by one page. 

2055 

2056 Guarded on the query the offset was fetched under, so a term edited 

2057 mid-flight cannot append matches for a term nobody typed. 

2058 """ 

2059 query = self._get_search_text() 

2060 if self._search_in_flight or not self._search_has_more: 

2061 return 

2062 if query != self._searched_query: 

2063 return 

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

2065 if active_task is None: 

2066 return 

2067 self._search_offset += _HF_SEARCH_LIMIT 

2068 self._search_in_flight = True 

2069 self._sync_loading_spinner() 

2070 self._fetch_hf_search(query, active_task, self._search_offset) 

2071 

2072 def action_load_more(self) -> None: 

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

2074 if self._active_tab_id() not in TASK_TAB_IDS: 

2075 return 

2076 self._load_more() 

2077 

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

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

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

2081 

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

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

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

2085 """ 

2086 new_tab = event.pane.id or TAB_CHAT 

2087 if not self._activation_settled: 

2088 return 

2089 self._active_tab_id_cache = new_tab 

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

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

2092 # may be detached. _grid_for_tab/_list_for_tab validate via 

2093 # is_running and refetch as needed. 

2094 self._update_sort_label() 

2095 if new_tab == TAB_LIBRARY: 

2096 self._populate_library_list() 

2097 elif new_tab == TAB_DISCOVER: 

2098 self._populate_discover_rails() 

2099 elif new_tab in TASK_TAB_IDS: 

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

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

2102 # after, so re-activations stay free. 

2103 self._ensure_task_initial_fetch(TAB_ID_TO_TASK[new_tab]) 

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

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

2106 self._refresh_view() 

2107 

2108 def _populate_discover_rails(self) -> None: 

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

2110 

2111 - For You: one runnable pick per role, in role order. Only rows the 

2112 engine can load and the host cannot prove will not run, measured 

2113 rows first, so every card is a one-click install rather than a 

2114 coin flip. 

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

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

2117 single rail-friendly slice. 

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

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

2120 a sort key today; downloads-desc surfaces buzzy recent uploads), 

2121 skipping rows For You already took. 

2122 """ 

2123 try: 

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

2125 except Exception: 

2126 return 

2127 family_rows = self._all_family_rows() 

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

2129 remote_rows = self._all_remote_rows() 

2130 for_you = for_you_by_role(family_rows + hf_rows) 

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

2132 taken_refs = {r.ref for r in for_you} 

2133 fresh = sorted( 

2134 (r for r in hf_rows if not r.featured and r.ref not in taken_refs), 

2135 key=lambda r: -r.sort_downloads, 

2136 )[:6] 

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

2138 

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

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

2141 entry = CatalogModel( 

2142 hf_repo=variant.hf_repo, 

2143 gguf_filename=variant.filename, 

2144 size_gb=variant.size_mb / 1024, 

2145 min_ram_gb=estimate_min_ram_gb(variant.size_mb / 1024), 

2146 description=family.description, 

2147 featured=True, 

2148 downloads=0, 

2149 task=family.task, 

2150 ) 

2151 self._install_model(entry) 

2152 

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

2154 if self.app.task_bar.pending_download(model) is not None: 

2155 self.notify(msg.CATALOG_ALREADY_DOWNLOADING.format(name=model.display_name)) 

2156 return 

2157 # The row's own size is an approximation off the parameter count; one 

2158 # file is named here, so the check asks HuggingFace what it really costs. 

2159 needed = int(model.size_gb * _BYTES_PER_GB) 

2160 try: 

2161 filename = resolve_filename(model) 

2162 dest = cfg.models_dir / filename 

2163 if dest.exists(): 

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

2165 return 

2166 needed = download_bytes(model.hf_repo, filename) 

2167 except Exception: 

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

2169 

2170 # After the already-installed check, which needs no space, and before 

2171 # the enqueue: a task that fails instantly is terminal, so dedupe would 

2172 # not stop a second row. 

2173 shortfall = disk_shortfall(cfg.models_dir, model.hf_repo, needed) 

2174 if shortfall is not None: 

2175 self.notify(shortfall, severity="warning") 

2176 return 

2177 

2178 self._enqueue_download(model) 

2179 

2180 def _on_chat_ready_changed(self, ready: bool) -> None: 

2181 """Show the first-run welcome only while no chat model resolves.""" 

2182 with contextlib.suppress(NoMatches): 

2183 self.query_one("#catalog-welcome", Static).display = not ready 

2184 

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

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

2187 

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

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

2190 survives navigation. When the row's architecture is known-unsupported, 

2191 confirm with a modal before enqueuing; the modal returns True to 

2192 proceed with ``allow_unsupported=True`` or False to cancel. 

2193 """ 

2194 

2195 def _adopt() -> None: 

2196 # Runs on the download worker thread; the safe wrapper drops the 

2197 # hop on a detached screen instead of crashing the worker. 

2198 call_from_thread(self, self._adopt_first_download, model) 

2199 

2200 if model.compat is ModelCompat.UNSUPPORTED: 

2201 

2202 def _after_confirm(verdict: bool | None) -> None: 

2203 if not verdict: 

2204 return 

2205 self.app.task_bar.start_download(model, allow_unsupported=True, on_success=_adopt) 

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

2207 

2208 self.app.push_screen( 

2209 ConfirmDialog( 

2210 msg.COMPAT_MODAL_TITLE, 

2211 msg.COMPAT_MODAL_BODY.format(arch=model.architecture or "unknown"), 

2212 ), 

2213 _after_confirm, 

2214 ) 

2215 return 

2216 

2217 self.app.task_bar.start_download(model, on_success=_adopt) 

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

2219 

2220 def _adopt_first_download(self, model: CatalogModel) -> None: 

2221 """Make the first model of an unconfigured role the active one. 

2222 

2223 A role that already has a model keeps it: a later download never 

2224 steals the assignment. Chat announces itself, since the user's next 

2225 step (start chatting) is on another screen. 

2226 """ 

2227 field = _model_field_for_task(model.task) 

2228 if getattr(cfg, field): 

2229 return 

2230 

2231 def _announce() -> None: 

2232 if field == "chat_model": 

2233 self.app.notify(msg.CHAT_READY_TOAST) 

2234 

2235 apply_model_pick(self, key=field, ref=model.ref, on_done=_announce, reload_worker=False) 

2236 

2237 def action_go_back(self) -> None: 

2238 # An open filter collapses to hidden (restoring grid/list focus); 

2239 # otherwise q / Esc returns to the view the user came from. 

2240 if self._filter_open: 

2241 self._search_input.value = "" 

2242 self._search_input.add_class(_HIDDEN_CLASS) 

2243 self._focus_list_or_grid() 

2244 return 

2245 # A catalog a model field pushed returns to the screen that opened it. 

2246 if not self.app.is_screen_installed(self): 

2247 self.dismiss() 

2248 return 

2249 self.app.go_back() 

2250 

2251 def _focus_list_or_grid(self) -> None: 

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

2253 if self._grid_view: 

2254 self._focus_first_grid() 

2255 else: 

2256 self._focus_list_item(0) 

2257 

2258 def action_show_info(self) -> None: 

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

2260 if self._search_focused: 

2261 return 

2262 row = self._highlighted_row() 

2263 if row is None: 

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

2265 return 

2266 if row.kind != CatalogRowKind.LOCAL: 

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

2268 return 

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

2270 

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

2272 

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

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

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

2276 return self._list_widget.highlighted_row() 

2277 focused_grid = self._focused_grid() 

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

2279 return None 

2280 if isinstance(focused_grid, ModelGrid): 

2281 rows = focused_grid.rows 

2282 index = focused_grid.highlighted 

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

2284 child = focused_grid.children[focused_grid.highlighted] 

2285 if isinstance(child, ModelCard): 

2286 return child.row 

2287 return None 

2288 

2289 def action_delete_model(self) -> None: 

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

2291 if self._search_focused: 

2292 return 

2293 model_name = self._get_highlighted_model_name() 

2294 if model_name is None: 

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

2296 return 

2297 

2298 if not self._row_is_installed(model_name): 

2299 self.notify( 

2300 msg.CATALOG_NOT_INSTALLED.format(name=model_name), 

2301 severity="warning", 

2302 ) 

2303 return 

2304 

2305 if self._pending_delete == model_name: 

2306 self._pending_delete = None 

2307 self._run_delete(model_name) 

2308 else: 

2309 self._pending_delete = model_name 

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

2311 

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

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

2314 

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

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

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

2318 manager directly. 

2319 """ 

2320 if model_name in self._installed_names: 

2321 return True 

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

2323 

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

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

2326 

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

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

2329 resolve to the lexicographically-first matching installed 

2330 manifest; full refs and remote names pass through. 

2331 """ 

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

2333 return identity 

2334 prefix = identity + "/" 

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

2336 if matches: 

2337 return matches[0] 

2338 return identity 

2339 

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

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

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

2343 row = self._list_widget.highlighted_row() 

2344 return row_delete_id(row) if row else None 

2345 focused_grid = self._focused_grid() 

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

2347 return None 

2348 if isinstance(focused_grid, ModelGrid): 

2349 rows = focused_grid.rows 

2350 index = focused_grid.highlighted 

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

2352 return row_delete_id(rows[index]) 

2353 return None 

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

2355 child = focused_grid.children[focused_grid.highlighted] 

2356 if isinstance(child, ModelCard): 

2357 return row_delete_id(child.row) 

2358 return None 

2359 

2360 @work(thread=True) 

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

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

2363 delete_ref = self._resolve_delete_ref(model_name) 

2364 try: 

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

2366 if removed: 

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

2368 call_from_thread(self, self._refresh_after_delete) 

2369 else: 

2370 call_from_thread( 

2371 self, 

2372 self.notify, 

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

2374 severity="error", 

2375 ) 

2376 except Exception as exc: 

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

2378 call_from_thread( 

2379 self, 

2380 self.notify, 

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

2382 severity="error", 

2383 ) 

2384 

2385 def _refresh_after_delete(self) -> None: 

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

2387 self._fetch_installed_names() 

2388 self._refresh_view() 

2389 self._fetch_remote_models() 

2390 

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

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

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

2394 return self.focused 

2395 return None 

2396 

2397 def _list_count(self) -> int: 

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

2399 return self._list_widget.row_count 

2400 

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

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

2403 count = self._list_widget.option_count 

2404 if not count: 

2405 return 

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

2407 self._list_widget.highlighted = clamped 

2408 # Same invariant as _mount_remaining_grid_sections: this is reached from 

2409 # call_after_refresh, so it can run after the user has opened the filter 

2410 # with `/`, and taking the cursor back would strand them mid-keystroke. 

2411 if self._search_focused: 

2412 return 

2413 self.set_focus(self._list_widget) 

2414 

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

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

2417 return self._list_widget.highlighted 

2418 

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

2420 idx = self._focused_list_index() 

2421 if idx is None: 

2422 self._focus_list_item(0) 

2423 return 

2424 self._focus_list_item(idx + delta) 

2425 self._maybe_prefetch_on_nav() 

2426 

2427 def _maybe_prefetch_on_nav(self) -> None: 

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

2429 return 

2430 idx = self._focused_list_index() 

2431 if idx is None: 

2432 return 

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

2434 self._load_more() 

2435 

2436 def _maybe_prefetch_on_grid_nav(self) -> None: 

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

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

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

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

2441 guarantees keyboard reaches the same prefetch trigger. 

2442 """ 

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

2444 return 

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

2446 if not grids: 

2447 return 

2448 focused = self._focused_grid() 

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

2450 return 

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

2452 try: 

2453 grid_index = grids.index(focused) 

2454 except ValueError: 

2455 return 

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

2457 absolute = cards_before + focused.highlighted 

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

2459 if total <= 0: 

2460 return 

2461 if absolute >= total - _HF_LOAD_MORE_TRIGGER: 

2462 self._load_more() 

2463 

2464 _SCROLL_PREFETCH_RATIO = 0.85 

2465 _SCROLL_PREFETCH_COOLDOWN = 0.8 

2466 

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

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

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

2470 return 

2471 self._scroll_prefetch_armed_at = time.monotonic() 

2472 self._load_more() 

2473 

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

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

2476 if not self._grid_view: 

2477 return 

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

2479 return 

2480 self._scroll_prefetch_armed_at = time.monotonic() 

2481 self._load_more() 

2482 

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

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

2485 

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

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

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

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

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

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

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

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

2494 """ 

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

2496 return 

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

2498 max_y = container.max_scroll_y 

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

2500 return 

2501 if self._scroll_prefetch_armed_at: 

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

2503 if elapsed < self._SCROLL_PREFETCH_COOLDOWN: 

2504 return 

2505 self._scroll_prefetch_armed_at = time.monotonic() 

2506 self._load_more() 

2507 

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

2509 # Cooldown blocks a runaway cascade where appending rows shifts 

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

2511 # next fetch before the user notices. 

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

2513 return False 

2514 if self._scroll_prefetch_armed_at: 

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

2516 if elapsed < self._SCROLL_PREFETCH_COOLDOWN: 

2517 return False 

2518 max_y = widget.max_scroll_y 

2519 if max_y <= 0: 

2520 return False 

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

2522 

2523 def _page_rows(self) -> int: 

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

2525 return _GRID_PAGE_ROWS if self._grid_view else _LIST_PAGE_ROWS 

2526 

2527 def action_page_down(self) -> None: 

2528 if self._search_focused: 

2529 return 

2530 if self._grid_view: 

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

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

2533 grid.action_cursor_down() 

2534 else: 

2535 self._nudge_list(self._page_rows()) 

2536 

2537 def action_page_up(self) -> None: 

2538 if self._search_focused: 

2539 return 

2540 if self._grid_view: 

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

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

2543 grid.action_cursor_up() 

2544 else: 

2545 self._nudge_list(-self._page_rows()) 

2546 

2547 def action_cursor_down(self) -> None: 

2548 if self._search_focused: 

2549 return 

2550 if self._grid_view: 

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

2552 if grid is not None: 

2553 grid.focus() 

2554 grid.action_cursor_down() 

2555 else: 

2556 self._nudge_list(1) 

2557 

2558 def action_cursor_up(self) -> None: 

2559 if self._search_focused: 

2560 return 

2561 if self._grid_view: 

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

2563 if grid is not None: 

2564 grid.focus() 

2565 grid.action_cursor_up() 

2566 else: 

2567 self._nudge_list(-1) 

2568 

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

2570 """Return the first non-empty ModelGrid in the active tab's container.""" 

2571 grids = self._pane_grids_with_rows() 

2572 return grids[0] if grids else None 

2573 

2574 def action_jump_top(self) -> None: 

2575 if self._search_focused: 

2576 return 

2577 if self._grid_view: 

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

2579 grid.highlight_first() 

2580 else: 

2581 self._focus_list_item(0) 

2582 

2583 def action_jump_bottom(self) -> None: 

2584 if self._search_focused: 

2585 return 

2586 if self._grid_view: 

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

2588 grid.highlight_last() 

2589 else: 

2590 count = self._list_widget.option_count 

2591 if count: 

2592 self._focus_list_item(count - 1) 

2593 self._maybe_prefetch_on_nav()