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

1371 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-28 17:20 +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, apply_active_model 

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.status_bar import ViewTabs 

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

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

86from lilbee.core.config import cfg 

87from lilbee.modelhub.model_manager import RemoteModel, classify_all_remote_models 

88from lilbee.providers.sdk_backend import PROVIDER_API_KEY_FIELD 

89from lilbee.runtime.hardware import available_memory_for_fit, chip_for_size 

90 

91log = logging.getLogger(__name__) 

92 

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

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

95# "keep scrolling" hint. 

96_HF_PAGE_SIZE = 24 

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

98# the hub rather than sitting at one offset. 

99_HF_SEARCH_LIMIT = 50 

100_HF_LOAD_MORE_TRIGGER = 4 

101_ALL_TASKS = tuple(ModelTask) 

102 

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

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

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

106_TASK_TO_MODEL_FIELD: dict[ModelTask, str] = { 

107 ModelTask.CHAT: "chat_model", 

108 ModelTask.EMBEDDING: "embedding_model", 

109 ModelTask.VISION: "vision_model", 

110 ModelTask.RERANK: "reranker_model", 

111} 

112 

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

114_KEY_PROBLEM_MESSAGE: dict[KeyStatus, str] = { 

115 KeyStatus.MISSING_KEY: msg.CATALOG_NEEDS_KEY, 

116 KeyStatus.INVALID_KEY: msg.CATALOG_KEY_REJECTED, 

117} 

118 

119 

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

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

122 

123 

124_WORKER_FETCH_HF = "fetch_hf_models" 

125_WORKER_FETCH_MORE_HF = "fetch_more_hf" 

126_WORKER_FETCH_REMOTE = "fetch_remote_models" 

127_WORKER_FETCH_SEARCH = "fetch_hf_search" 

128_WORKER_FETCH_FRONTIER = "fetch_frontier_models" 

129_WORKER_FETCH_FAMILIES = "fetch_families" 

130 

131_GRID_PAGE_ROWS = 3 

132_LIST_PAGE_ROWS = 10 

133 

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

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

136_GRID_ID_PREFIX = "grid-" 

137_LIST_ID_PREFIX = "list-" 

138 

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

140_HIDDEN_CLASS = "-hidden" 

141 

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

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

144# can take several). 

145_TAB_ACTIVATION_RETRY_BUDGET = 20 

146 

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

148 

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

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

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

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

153_SPINNER_INTERVAL_S = 0.1 

154 

155_RowCacheKey = tuple[int, int, int, int, int, int] 

156 

157 

158@dataclass(frozen=True) 

159class _RowCacheEntry: 

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

161 

162 key: _RowCacheKey 

163 rows: list[LocalCatalogRow] 

164 

165 

166class _GridCacheKey(NamedTuple): 

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

168 

169 data_version: int 

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

171 search: str 

172 

173 

174class CatalogScreen(Screen[None]): 

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

176 

177 app: LilbeeApp # type: ignore[assignment] 

178 

179 CSS_PATH = "catalog.tcss" 

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

181 

182 HELP = ( 

183 "# Catalog\n" 

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

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

186 "## Navigation\n" 

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

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

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

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

191 "## Actions\n" 

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

193 "- Space: toggle select.\n" 

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

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

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

197 "## Filters and views\n" 

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

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

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

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

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

203 "## Detail drawer\n" 

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

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

206 "## Fit chip\n" 

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

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

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

210 "## Other\n" 

211 "- q / Esc: back." 

212 ) 

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

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

215 

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

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

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

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

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

221 # preference, not a way around the catalog. 

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

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

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

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

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

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

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

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

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

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

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

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

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

235 # the highlight follows the visible region. Decoupling them 

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

237 # previously-focused card. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

255 # with nothing on screen to say why. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

273 # swallow them. 

274 Binding( 

275 "less_than_sign", 

276 "cycle_tab(-1)", 

277 "Prev tab", 

278 show=True, 

279 priority=True, 

280 group=_TAB_GROUP, 

281 ), 

282 Binding( 

283 "greater_than_sign", 

284 "cycle_tab(1)", 

285 "Next tab", 

286 show=True, 

287 priority=True, 

288 group=_TAB_GROUP, 

289 ), 

290 ] 

291 

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

293 

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

295 super().__init__() 

296 self._focus_task: str | None = focus_task 

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

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

299 # here would block screen construction on the network. 

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

301 self._families_in_flight = True 

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

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

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

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

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

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

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

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

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

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

312 self._sort_column: str = "Name" 

313 self._sort_ascending: bool = True 

314 self._pending_delete: str | None = None 

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

316 self._grid_view: bool = True 

317 self._loading_more: bool = False 

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

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

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

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

322 self._search_in_flight: bool = False 

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

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

325 self._searched_query: str = "" 

326 self._search_offset: int = 0 

327 self._search_has_more: bool = False 

328 self._search_truncated: bool = False 

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

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

331 # invalidate even when collection lengths happen to coincide. 

332 self._data_version: int = 0 

333 self._family_rows_cache: _RowCacheEntry | None = None 

334 self._hf_rows_cache: _RowCacheEntry | None = None 

335 self._remote_rows_cache: _RowCacheEntry | None = None 

336 self._view_switching: bool = False 

337 self._frontier_refresh_timer: Timer | None = None 

338 self._search_filter_timer: Timer | None = None 

339 self._remote_search_timer: Timer | None = None 

340 self._scroll_prefetch_armed_at: float = 0.0 

341 self._spinner_timer: Timer | None = None 

342 self._spinner_frame: int = 0 

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

344 # query_one on every _grid_container / _list_widget access. Default 

345 # matches the tab _activate_initial_tab selects on mount. 

346 self._active_tab_id_cache: str = TAB_CHAT 

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

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

349 # During initial mount Textual fires TabActivated for whichever pane 

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

351 # call_after_refresh setter activates Chat. Suppressing cache writes 

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

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

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

355 self._activation_settled: bool = False 

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

357 # initial activation gives up (see _activate_initial_tab). 

358 self._activation_retries: int = _TAB_ACTIVATION_RETRY_BUDGET 

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

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

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

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

363 tab_id: SourceMode.LOCAL for tab_id in TASK_TAB_IDS 

364 } 

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

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

367 # re-probing on every refresh. 

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

369 

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

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

372 

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

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

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

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

377 transparently. 

378 """ 

379 cached = self._tab_grid_cache.get(tab_id) 

380 if cached is not None and cached.is_running: 

381 return cached 

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

383 container = self.query_one(selector, VerticalScroll) 

384 self._tab_grid_cache[tab_id] = container 

385 return container 

386 

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

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

389 

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

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

392 """ 

393 target = TAB_CHAT if tab_id == TAB_DISCOVER else tab_id 

394 cached = self._tab_list_cache.get(target) 

395 if cached is not None and cached.is_running: 

396 return cached 

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

398 self._tab_list_cache[target] = widget 

399 return widget 

400 

401 def _grid_mounted(self) -> bool: 

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

403 try: 

404 self._grid_for_tab(self._active_tab_id_cache) 

405 except NoMatches: 

406 return False 

407 return True 

408 

409 def _list_mounted(self) -> bool: 

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

411 try: 

412 self._list_for_tab(self._active_tab_id_cache) 

413 except NoMatches: 

414 return False 

415 return True 

416 

417 @property 

418 def _grid_container(self) -> VerticalScroll: 

419 return self._grid_for_tab(self._active_tab_id_cache) 

420 

421 @property 

422 def _list_widget(self) -> ModelList: 

423 return self._list_for_tab(self._active_tab_id_cache) 

424 

425 @property 

426 def _search_focused(self) -> bool: 

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

428 

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

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

431 """ 

432 return isinstance(self.focused, Input) 

433 

434 @property 

435 def _filter_open(self) -> bool: 

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

437 return not self._search_input.has_class(_HIDDEN_CLASS) 

438 

439 def compose(self) -> ComposeResult: 

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

441 

442 with TopBars(): 

443 yield ViewTabs() 

444 yield Input( 

445 placeholder=msg.CATALOG_FILTER_PLACEHOLDER, 

446 id="catalog-search", 

447 classes=_HIDDEN_CLASS, 

448 ) 

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

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

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

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

453 yield GridListToggle() 

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

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

456 # Horizontal split: TabbedContent fills, CatalogDetailDrawer docks 

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

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

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

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

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

462 with ( 

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

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

465 ): 

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

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

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

469 yield VerticalScroll( 

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

471 ) 

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

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

474 yield VerticalScroll( 

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

476 ) 

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

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

479 yield VerticalScroll( 

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

481 ) 

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

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

484 yield VerticalScroll( 

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

486 ) 

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

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

489 yield VerticalScroll( 

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

491 ) 

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

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

494 with BottomBars(): 

495 yield TaskBar() 

496 yield Footer() 

497 

498 def on_mount(self) -> None: 

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

500 self._fetch_installed_names() 

501 self._fetch_families() 

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

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

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

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

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

507 # call_after_refresh also lets the TabActivated cascade settle first. 

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

509 # Discover via keyboard shortcut. 

510 self.call_after_refresh(self._activate_initial_tab) 

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

512 

513 def _activate_initial_tab(self) -> None: 

514 try: 

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

516 except Exception: 

517 self._activation_settled = True 

518 return 

519 target: str | None 

520 if self._focus_task is not None: 

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

522 self._active_tab_id_cache = self._focus_task 

523 target = self._focus_task 

524 else: 

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

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

527 try: 

528 tabs.active = target 

529 except ValueError: 

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

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

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

533 if self._activation_retries > 0: 

534 self._activation_retries -= 1 

535 self.call_after_refresh(self._activate_initial_tab) 

536 return 

537 if not self._activation_settled: 

538 self._activation_settled = True 

539 self.call_after_refresh(self._refresh_grid) 

540 self.call_after_refresh(self._initial_focus_first_grid) 

541 self._fetch_remote_models() 

542 self._fetch_frontier_models() 

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

544 # task tabs fetch lazily on first activation (see 

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

546 # one HF round-trip instead of four. 

547 self._ensure_task_initial_fetch(ModelTask.CHAT) 

548 self.app.provider_availability_changed_signal.subscribe( 

549 self, self._on_provider_availability_changed 

550 ) 

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

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

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

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

555 # on every tab activation. 

556 for tab_id in (*TASK_TAB_IDS, TAB_LIBRARY): 

557 with contextlib.suppress(Exception): 

558 self.watch( 

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

560 ) 

561 self.watch( 

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

563 ) 

564 

565 def on_unmount(self) -> None: 

566 with contextlib.suppress(Exception): 

567 self.app.provider_availability_changed_signal.unsubscribe(self) 

568 self._stop_spinner_timer() 

569 

570 def on_screen_suspend(self) -> None: 

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

572 

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

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

575 of main-thread CPU forever. 

576 """ 

577 self._stop_spinner_timer() 

578 

579 def on_screen_resume(self) -> None: 

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

581 if self._loading_more or self._search_in_flight: 

582 self._sync_loading_spinner() 

583 

584 def _stop_spinner_timer(self) -> None: 

585 if self._spinner_timer is not None: 

586 self._spinner_timer.stop() 

587 self._spinner_timer = None 

588 

589 _FRONTIER_REFRESH_DEBOUNCE = 1.0 

590 

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

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

593 if self._frontier_refresh_timer is not None: 

594 self._frontier_refresh_timer.stop() 

595 self._frontier_refresh_timer = self.set_timer( 

596 self._FRONTIER_REFRESH_DEBOUNCE, self._fetch_frontier_models 

597 ) 

598 

599 def _focus_first_grid(self) -> None: 

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

601 with contextlib.suppress(Exception): 

602 grids = self._pane_grids_with_rows() 

603 if grids: 

604 self.set_focus(grids[0]) 

605 return 

606 with contextlib.suppress(Exception): 

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

608 

609 def _initial_focus_first_grid(self) -> None: 

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

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

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

613 if self.focused is not None: 

614 return 

615 self._focus_first_grid() 

616 

617 def _fetch_installed_names(self) -> None: 

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

619 

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

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

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

623 has a manifest. 

624 

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

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

627 stops rather than reaching the user as a crash. 

628 """ 

629 try: 

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

631 except Exception: 

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

633 return 

634 self._data_version += 1 

635 

636 def _active_tab_id(self) -> str: 

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

638 

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

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

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

642 """ 

643 return self._active_tab_id_cache 

644 

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

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

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

648 

649 def _active_task_has_more(self) -> bool: 

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

651 

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

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

654 result set on screen. 

655 """ 

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

657 

658 def _active_task_truncated(self) -> bool: 

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

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

661 

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

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

664 task = self._active_task() 

665 if task is None: 

666 return False 

667 if self._get_search_text(): 

668 return search_flag 

669 return by_task.get(task, False) 

670 

671 def _hf_fetched_any(self) -> bool: 

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

673 

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

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

676 """ 

677 return bool(self._hf_fetched_tasks) 

678 

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

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

681 if task in self._hf_fetched_tasks: 

682 return 

683 self._hf_fetched_tasks.add(task) 

684 self._fetch_initial_hf_models_for_task(task) 

685 

686 def action_toggle_view(self) -> None: 

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

688 

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

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

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

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

693 """ 

694 if self._active_tab_id() not in TASK_TAB_IDS: 

695 return 

696 if self._view_switching: 

697 return 

698 self._view_switching = True 

699 try: 

700 if self._grid_view: 

701 self._grid_view = False 

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

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

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

705 if active_task is not None: 

706 self._ensure_task_initial_fetch(active_task) 

707 with self.app.batch_update(): 

708 self._refresh_list() 

709 self._focus_list_item(0) 

710 else: 

711 self._grid_view = True 

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

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

714 with self.app.batch_update(): 

715 self._refresh_grid() 

716 with contextlib.suppress(Exception): 

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

718 finally: 

719 self._view_switching = False 

720 self._sync_grid_list_toggle() 

721 

722 def _sync_grid_list_toggle(self) -> None: 

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

724 

725 with contextlib.suppress(Exception): 

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

727 

728 def action_focus_search(self) -> None: 

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

730 

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

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

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

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

735 """ 

736 self._search_input.remove_class(_HIDDEN_CLASS) 

737 self.set_focus(self._search_input) 

738 

739 _SEARCH_FILTER_DEBOUNCE_SECONDS = 0.08 

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

741 # pause in typing. 

742 _REMOTE_SEARCH_DEBOUNCE_SECONDS = 0.45 

743 

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

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

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

747 

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

749 which Textual treats as layout invalidations. Without the debounce 

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

751 to a single pass once the user pauses. 

752 

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

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

755 """ 

756 if self._search_filter_timer is not None: 

757 self._search_filter_timer.stop() 

758 self._search_filter_timer = self.set_timer( 

759 self._SEARCH_FILTER_DEBOUNCE_SECONDS, 

760 self._apply_search_filter, 

761 ) 

762 if self._remote_search_timer is not None: 

763 self._remote_search_timer.stop() 

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

765 if not self._get_search_text(): 

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

767 self._searched_query = "" 

768 self._search_offset = 0 

769 self._search_has_more = False 

770 self._search_truncated = False 

771 return 

772 self._remote_search_timer = self.set_timer( 

773 self._REMOTE_SEARCH_DEBOUNCE_SECONDS, 

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

775 ) 

776 

777 def _apply_search_filter(self) -> None: 

778 if self._active_tab_id() == TAB_LIBRARY: 

779 self._populate_library_list() 

780 return 

781 if self._active_tab_id() == TAB_DISCOVER: 

782 return 

783 if self._grid_view: 

784 self._filter_grid() 

785 else: 

786 self._filter_list() 

787 

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

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

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

791 

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

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

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

795 deliberate Enter on the focused card. 

796 """ 

797 self._focus_first_result() 

798 

799 def _focus_first_result(self) -> None: 

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

801 with contextlib.suppress(Exception): 

802 if not self._grid_view: 

803 self._focus_first_list_row() 

804 return 

805 self._focus_first_grid_card() 

806 

807 def _focus_first_list_row(self) -> None: 

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

809 if not self._list_widget.option_count: 

810 return 

811 self._list_widget.highlighted = 0 

812 self._list_widget.focus() 

813 

814 def _focus_first_grid_card(self) -> None: 

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

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

817 if grid.rows: 

818 grid.focus() 

819 grid.highlighted = 0 

820 return 

821 

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

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

824 

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

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

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

828 the search Input is hidden on them. 

829 """ 

830 if self._search_in_flight or not query: 

831 return 

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

833 if active_task is None: 

834 return 

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

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

837 if query != self._searched_query: 

838 self._searched_query = query 

839 self._search_offset = 0 

840 self._search_has_more = False 

841 self._search_truncated = False 

842 self._search_in_flight = True 

843 self._update_sort_label() 

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

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

846 self._sync_loading_spinner() 

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

848 

849 def _resume_search_if_term_moved_on(self) -> None: 

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

851 

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

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

854 """ 

855 query = self._get_search_text() 

856 if query and query != self._searched_query: 

857 self._trigger_remote_search(query) 

858 

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

860 def _on_search_hf_cta_clicked(self) -> None: 

861 self._trigger_remote_search(self._get_search_text()) 

862 

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

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

865 

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

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

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

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

870 """ 

871 offset = self._hf_offset_by_task[task] 

872 result = get_catalog( 

873 task=task, 

874 featured=False, 

875 limit=_HF_PAGE_SIZE, 

876 offset=offset, 

877 ) 

878 self._hf_has_more_by_task[task] = result.has_more 

879 self._hf_truncated_by_task[task] = result.truncated 

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

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

882 

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

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

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

886 return self._fetch_hf_page_for_task(task) 

887 

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

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

890 return classify_all_remote_models() 

891 

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

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

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

895 

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

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

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

899 """ 

900 try: 

901 return get_families() 

902 except Exception: 

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

904 return [] 

905 

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

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

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

909 

910 ``discover_api_model_groups`` imports litellm (heavy, >50ms) and 

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

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

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

914 from lilbee.modelhub.model_manager import discover_api_model_groups 

915 

916 try: 

917 groups = discover_api_model_groups() 

918 except Exception: 

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

920 return [] 

921 

922 rows = [ 

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

924 for group in groups 

925 for rm in group.models 

926 ] 

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

928 return rows 

929 

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

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

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

933 return self._fetch_hf_page_for_task(task) 

934 

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

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

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

938 

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

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

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

942 """ 

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

944 result = get_catalog( 

945 task=task, 

946 featured=False, 

947 search=query, 

948 limit=_HF_SEARCH_LIMIT, 

949 offset=offset, 

950 ) 

951 self._search_has_more = result.has_more 

952 self._search_truncated = result.truncated 

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

954 

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

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

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

958 self._handle_worker_error_or_cancel(event.worker.name) 

959 return 

960 if event.state != WorkerState.SUCCESS: 

961 return 

962 result = event.worker.result 

963 if not isinstance(result, list): 

964 return 

965 worker_name = event.worker.name 

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

967 return 

968 # A fast worker can complete before TabbedContent finishes mounting 

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

970 # _activate_initial_tab schedules rebuild against the applied state. 

971 with contextlib.suppress(NoMatches): 

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

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

974 if worker_name == _WORKER_FETCH_MORE_HF: 

975 if self._grid_view: 

976 self._refresh_grid() 

977 else: 

978 self._append_more_hf_to_list(result) 

979 return 

980 self._refresh_view() 

981 

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

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

984 

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

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

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

988 sibling tab's list. 

989 """ 

990 active_task = self._active_task() 

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

992 self._refresh_view() 

993 return 

994 new_rows = self._sort_rows( 

995 [ 

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

997 for m in new_models 

998 ] 

999 ) 

1000 if not new_rows: 

1001 self._update_sort_label() 

1002 return 

1003 self._rows.extend(new_rows) 

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

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

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

1007 self._list_cache_keys[self._active_tab_id_cache] = ( 

1008 self._data_version, 

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

1010 self._get_search_text(), 

1011 ) 

1012 self._update_sort_label() 

1013 

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

1015 if name == _WORKER_FETCH_MORE_HF: 

1016 self._loading_more = False 

1017 if name == _WORKER_FETCH_SEARCH: 

1018 self._search_in_flight = False 

1019 self._update_sort_label() 

1020 self._resume_search_if_term_moved_on() 

1021 if name == _WORKER_FETCH_FAMILIES: 

1022 self._families_in_flight = False 

1023 self._sync_loading_spinner() 

1024 

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

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

1027 

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

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

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

1031 if name == _WORKER_FETCH_HF: 

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

1033 # one carries dedup-filtered new rows (see 

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

1035 self._hf_models.extend(result) 

1036 self._loading_more = False 

1037 elif name == _WORKER_FETCH_MORE_HF: 

1038 self._hf_models.extend(result) 

1039 self._loading_more = False 

1040 elif name == _WORKER_FETCH_SEARCH: 

1041 self._hf_models.extend(result) 

1042 self._search_in_flight = False 

1043 self._update_sort_label() 

1044 self._resume_search_if_term_moved_on() 

1045 elif name == _WORKER_FETCH_REMOTE: 

1046 self._remote_models = result 

1047 elif name == _WORKER_FETCH_FRONTIER: 

1048 self._frontier_rows = result 

1049 self._populate_library_list() 

1050 elif name == _WORKER_FETCH_FAMILIES: 

1051 self._families = result 

1052 self._families_in_flight = False 

1053 else: 

1054 return False 

1055 self._data_version += 1 

1056 self._sync_loading_spinner() 

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

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

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

1060 # _populate_discover_rails fires only on tab activation. 

1061 if self._active_tab_id_cache == TAB_DISCOVER: 

1062 self._populate_discover_rails() 

1063 return True 

1064 

1065 def _populate_library_list(self) -> None: 

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

1067 search = self._get_search_text() 

1068 installed_rows: list[LocalCatalogRow] = [] 

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

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

1071 if search: 

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

1073 frontier = self._build_frontier_rows(search) 

1074 self._render_library_list(installed_rows, frontier) 

1075 self._render_library_grid(installed_rows, frontier) 

1076 

1077 def _render_library_list( 

1078 self, 

1079 installed_rows: list[LocalCatalogRow], 

1080 frontier: list[FrontierCatalogRow], 

1081 ) -> None: 

1082 try: 

1083 ml = self._list_for_tab(TAB_LIBRARY) 

1084 except Exception: 

1085 return 

1086 sections: list[ModelListSection] = [] 

1087 if installed_rows: 

1088 sections.append( 

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

1090 ) 

1091 sections.extend(group_frontier_rows(frontier)) 

1092 ml.set_rows(sections) 

1093 

1094 def _render_library_grid( 

1095 self, 

1096 installed_rows: list[LocalCatalogRow], 

1097 frontier: list[FrontierCatalogRow], 

1098 ) -> None: 

1099 try: 

1100 container = self._grid_for_tab(TAB_LIBRARY) 

1101 except Exception: 

1102 return 

1103 sections: list[GridSection] = [] 

1104 if installed_rows: 

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

1106 if frontier: 

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

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

1109 existing_headings = [ 

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

1111 ] 

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

1113 for grid, heading, section in zip( 

1114 existing_grids, existing_headings, sections, strict=False 

1115 ): 

1116 heading.update(section.heading) 

1117 grid.set_rows(section.rows) 

1118 return 

1119 container.remove_children() 

1120 for section in sections: 

1121 container.mount_all( 

1122 [ 

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

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

1125 ] 

1126 ) 

1127 

1128 def _get_search_text(self) -> str: 

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

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

1131 # would otherwise raise NoMatches and crash the callback. 

1132 try: 

1133 return self._search_input.value.strip() 

1134 except Exception: 

1135 return "" 

1136 

1137 def _local_rows_data_key(self) -> _RowCacheKey: 

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

1139 

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

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

1142 """ 

1143 return ( 

1144 len(self._families), 

1145 len(self._hf_models), 

1146 len(self._remote_models), 

1147 len(self._hf_fetched_tasks), 

1148 len(self._installed_names), 

1149 self._data_version, 

1150 ) 

1151 

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

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

1154 

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

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

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

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

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

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

1161 non-primary size without leaving the grid. 

1162 """ 

1163 key = self._local_rows_data_key() 

1164 cached = self._family_rows_cache 

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

1166 return cached.rows 

1167 rows: list[LocalCatalogRow] = [] 

1168 for fam in self._families: 

1169 if not fam.variants: 

1170 continue 

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

1172 family_installed = any( 

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

1174 for v in fam.variants 

1175 ) 

1176 row = variant_to_row(primary, fam, family_installed) 

1177 row.size_variants = family_to_size_variants(fam) 

1178 rows.append(row) 

1179 self._stamp_fit(rows) 

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

1181 return rows 

1182 

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

1184 key = self._local_rows_data_key() 

1185 cached = self._hf_rows_cache 

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

1187 return cached.rows 

1188 rows: list[LocalCatalogRow] = [] 

1189 for m in self._hf_models: 

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

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

1192 self._stamp_fit(rows) 

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

1194 return rows 

1195 

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

1197 key = self._local_rows_data_key() 

1198 cached = self._remote_rows_cache 

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

1200 return cached.rows 

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

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

1203 self._stamp_fit(rows) 

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

1205 return rows 

1206 

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

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

1209 

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

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

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

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

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

1215 """ 

1216 for row in rows: 

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

1218 

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

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

1221 search = self._get_search_text() 

1222 rows: list[LocalCatalogRow] = [] 

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

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

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

1226 return rows 

1227 

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

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

1230 if not search: 

1231 return self._all_family_rows() 

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

1233 

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

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

1236 if not search: 

1237 return self._all_hf_rows() 

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

1239 

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

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

1242 if not search: 

1243 return self._all_remote_rows() 

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

1245 

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

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

1248 

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

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

1251 thread. Renderers call this synchronously to read the 

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

1253 """ 

1254 if not self._frontier_rows: 

1255 return [] 

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

1257 

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

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

1260 if name in self._installed_names: 

1261 return True 

1262 if repo and filename: 

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

1264 return False 

1265 

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

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

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

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

1270 return sorted( 

1271 rows, 

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

1273 reverse=not self._sort_ascending, 

1274 ) 

1275 

1276 def _refresh_view(self) -> None: 

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

1278 

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

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

1281 

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

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

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

1285 DOM.""" 

1286 if self._active_tab_id_cache == TAB_DISCOVER: 

1287 self._populate_discover_rails() 

1288 return 

1289 with self.app.batch_update(): 

1290 if self._grid_view: 

1291 self._refresh_grid() 

1292 else: 

1293 self._refresh_list() 

1294 

1295 def _refresh_grid(self) -> None: 

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

1297 

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

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

1300 update each existing ModelGrid via set_rows rather than tearing 

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

1302 CPU spike on every "Browse more" return. 

1303 

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

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

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

1307 next mounted refresh still repaints. 

1308 """ 

1309 if self._active_tab_id_cache == TAB_DISCOVER: 

1310 # Discover paints rails via _populate_discover_rails, never sections. 

1311 return 

1312 if not self._grid_mounted(): 

1313 return 

1314 prep = self._prepare_grid_refresh() 

1315 if prep is None: 

1316 self._update_sort_label() 

1317 return 

1318 sections, hf_count, filter_changed = prep 

1319 if filter_changed: 

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

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

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

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

1324 if not sections: 

1325 self._grid_container.remove_children() 

1326 self._mount_grid_ctas(hf_count=hf_count) 

1327 self._update_sort_label() 

1328 return 

1329 if self._extend_grid_sections_in_place(sections, hf_count): 

1330 return 

1331 self._remount_grid_sections(sections, hf_count) 

1332 self._update_sort_label() 

1333 

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

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

1336 

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

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

1339 

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

1341 cached path still picks up sort-toggle clicks. 

1342 """ 

1343 search = self._get_search_text() 

1344 family_rows = self._build_family_rows(search) 

1345 remote_rows = self._build_remote_rows(search) 

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

1347 all_rows = family_rows + remote_rows + hf_rows 

1348 active_tab = self._active_tab_id_cache 

1349 tab_rows = self._rows_for_active_tab(all_rows, active_tab) 

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

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

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

1353 # toward the local-row tally. 

1354 local_tab_rows: list[LocalCatalogRow] = [ 

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

1356 ] 

1357 self._rows = local_tab_rows 

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

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

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

1361 # repaint rather than read as cache-hot. 

1362 row_key = _GridCacheKey( 

1363 self._data_version, 

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

1365 search, 

1366 ) 

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

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

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

1370 previous_key = self._grid_cache_keys.get(active_tab) 

1371 if previous_key == row_key: 

1372 return None 

1373 self._grid_cache_keys[active_tab] = row_key 

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

1375 if active_tab in TASK_TAB_IDS: 

1376 active_task = TAB_ID_TO_TASK[active_tab] 

1377 task_label = active_task.value.capitalize() 

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

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

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

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

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

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

1384 if frontier_only: 

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

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

1387 else: 

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

1389 hf_count = len(hf_rows) 

1390 if search: 

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

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

1393 sections = flatten_sections(sections, msg.HEADING_MATCHES) 

1394 return sections, hf_count, filter_changed 

1395 

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

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

1398 

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

1400 through to a teardown + remount on False. 

1401 """ 

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

1403 existing_headings = [ 

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

1405 ] 

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

1407 return False 

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

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

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

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

1412 for grid, heading, section in zip( 

1413 existing_grids, existing_headings, sections, strict=False 

1414 ): 

1415 heading.update(section.heading) 

1416 grid.set_rows(section.rows) 

1417 self._refresh_grid_ctas(hf_count=hf_count) 

1418 self._update_sort_label() 

1419 return True 

1420 

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

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

1423 

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

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

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

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

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

1429 looking. 

1430 """ 

1431 focus_anchor = self._capture_focused_section() 

1432 container = self._grid_container 

1433 prior_scroll_y = container.scroll_y 

1434 container.remove_children() 

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

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

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

1438 self.call_after_refresh( 

1439 self._mount_remaining_grid_sections, 

1440 container, 

1441 sections[1:], 

1442 hf_count=hf_count, 

1443 focus_anchor=focus_anchor, 

1444 prior_scroll_y=prior_scroll_y, 

1445 ) 

1446 

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

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

1449 

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

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

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

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

1454 """ 

1455 focused = self._focused_grid() 

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

1457 return None 

1458 return (focused.name, focused.highlighted) 

1459 

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

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

1462 

1463 Returns True when the previous focus position was successfully 

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

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

1466 """ 

1467 if anchor is None: 

1468 return False 

1469 target_heading, target_highlighted = anchor 

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

1471 if grid.name != target_heading: 

1472 continue 

1473 grid.focus() 

1474 if target_highlighted is not None and grid.rows: 

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

1476 return True 

1477 return False 

1478 

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

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

1481 # ``_capture_focused_section`` / ``_restore_focused_section`` to 

1482 # preserve the cursor across teardown + remount. 

1483 # 

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

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

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

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

1488 return container.mount_all( 

1489 [ 

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

1491 grid, 

1492 ] 

1493 ) 

1494 

1495 def _mount_remaining_grid_sections( 

1496 self, 

1497 container: VerticalScroll, 

1498 remaining: list[GridSection], 

1499 hf_count: int, 

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

1501 prior_scroll_y: float = 0.0, 

1502 ) -> list[AwaitMount]: 

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

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

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

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

1507 # runs from call_after_refresh and ignores it. 

1508 if not container.is_running: 

1509 return [] 

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

1511 self._mount_grid_ctas(hf_count=hf_count) 

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

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

1514 if prior_scroll_y: 

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

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

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

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

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

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

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

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

1523 return mounts 

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

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

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

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

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

1529 if self._search_focused: 

1530 return mounts 

1531 if self._restore_focused_section(focus_anchor): 

1532 return mounts 

1533 self._focus_first_grid() 

1534 return mounts 

1535 

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

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

1538 if self._loading_more: 

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

1540 if self._active_task_has_more(): 

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

1542 if self._active_task_truncated(): 

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

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

1545 

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

1547 try: 

1548 container = self._grid_container 

1549 except Exception: 

1550 return 

1551 ctas: list[Static] = [ 

1552 Static( 

1553 self._grid_scroll_hint_text(hf_count), 

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

1555 ) 

1556 ] 

1557 search = self._get_search_text() 

1558 if search: 

1559 ctas.append( 

1560 Static( 

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

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

1563 markup=False, 

1564 ) 

1565 ) 

1566 container.mount_all(ctas) 

1567 

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

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

1570 try: 

1571 container = self._grid_container 

1572 except Exception: 

1573 return 

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

1575 for w in existing: 

1576 with contextlib.suppress(Exception): 

1577 w.remove() 

1578 self._mount_grid_ctas(hf_count=hf_count) 

1579 

1580 def _rows_for_active_tab( 

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

1582 ) -> list[CatalogRow]: 

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

1584 

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

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

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

1588 also union the matching frontier rows. 

1589 """ 

1590 if active_tab not in TASK_TAB_IDS: 

1591 return list(all_rows) 

1592 active_task = TAB_ID_TO_TASK[active_tab] 

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

1594 local_for_task: list[CatalogRow] = [] 

1595 if mode is not SourceMode.CLOUD: 

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

1597 frontier_for_task: list[CatalogRow] = [] 

1598 if mode is not SourceMode.LOCAL: 

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

1600 return local_for_task + frontier_for_task 

1601 

1602 def _filter_grid(self) -> None: 

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

1604 self._refresh_grid() 

1605 

1606 @on(ModelGrid.Highlighted) 

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

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

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

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

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

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

1613 highlighted row. 

1614 """ 

1615 self._maybe_prefetch_on_grid_nav() 

1616 self._reveal_scroll_hint_at_catalog_end() 

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

1618 

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

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

1621 try: 

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

1623 except Exception: 

1624 return 

1625 rows = grid.rows 

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

1627 drawer.update_for_row(row) 

1628 

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

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

1631 

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

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

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

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

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

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

1638 """ 

1639 if self._search_focused: 

1640 return 

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

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

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

1644 

1645 if not event.key.isdigit(): 

1646 return 

1647 index = int(event.key) - 1 

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

1649 return 

1650 event.stop() 

1651 event.prevent_default() 

1652 self.action_select_tab(index) 

1653 

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

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

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

1657 

1658 if self._search_focused: 

1659 return 

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

1661 return 

1662 target = ALL_TAB_IDS[index] 

1663 try: 

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

1665 except Exception: 

1666 return 

1667 self.set_focus(None) 

1668 if tabs.active != target: 

1669 tabs.active = target 

1670 self._active_tab_id_cache = target 

1671 

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

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

1674 

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

1676 either direction without hitting an end stop. 

1677 """ 

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

1679 

1680 if self._search_focused: 

1681 return 

1682 try: 

1683 current = ALL_TAB_IDS.index(self._active_tab_id_cache) 

1684 except ValueError: 

1685 current = 0 

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

1687 self.action_select_tab(next_index) 

1688 

1689 def action_cycle_source(self) -> None: 

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

1691 

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

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

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

1695 """ 

1696 if self._search_focused: 

1697 return 

1698 active = self._active_tab_id_cache 

1699 if active not in TASK_TAB_IDS: 

1700 return 

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

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

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

1704 self._grid_cache_keys.pop(active, None) 

1705 self._list_cache_keys.pop(active, None) 

1706 self._refresh_view() 

1707 

1708 def action_toggle_drawer(self) -> None: 

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

1710 

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

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

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

1714 """ 

1715 try: 

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

1717 except Exception: 

1718 return 

1719 drawer.toggle_class("-collapsed") 

1720 

1721 def _reveal_scroll_hint_at_catalog_end(self) -> None: 

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

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

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

1725 

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

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

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

1729 

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

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

1732 """ 

1733 if self._active_task() is None: 

1734 return 

1735 focused = self._focused_grid() 

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

1737 return 

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

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

1740 return 

1741 cols = max(1, focused.columns_per_row) 

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

1743 if focused.highlighted // cols < last_row: 

1744 return 

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

1746 

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

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

1749 try: 

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

1751 except NoMatches: 

1752 return [] 

1753 

1754 @staticmethod 

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

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

1757 

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

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

1760 """ 

1761 if from_above: 

1762 grid.highlight_first() 

1763 else: 

1764 grid.highlight_last() 

1765 grid.focus() 

1766 

1767 @on(GridSelect.LeaveDown) 

1768 @on(ModelGrid.LeaveDown) 

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

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

1771 

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

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

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

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

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

1777 """ 

1778 if isinstance(event, ModelGrid.LeaveDown): 

1779 grids = self._pane_grids_with_rows() 

1780 try: 

1781 index = grids.index(event.grid) 

1782 except ValueError: 

1783 return 

1784 if index + 1 < len(grids): 

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

1786 return 

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

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

1789 self._load_more() 

1790 return 

1791 self.focus_next() 

1792 

1793 @on(GridSelect.LeaveUp) 

1794 @on(ModelGrid.LeaveUp) 

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

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

1797 

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

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

1800 """ 

1801 if isinstance(event, ModelGrid.LeaveUp): 

1802 grids = self._pane_grids_with_rows() 

1803 try: 

1804 index = grids.index(event.grid) 

1805 except ValueError: 

1806 return 

1807 if index > 0: 

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

1809 return 

1810 self.focus_previous() 

1811 

1812 @on(GridSelect.Selected) 

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

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

1815 widget = event.widget 

1816 if isinstance(widget, ModelCard): 

1817 self._select_row(widget.row) 

1818 

1819 @on(ModelGrid.Selected) 

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

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

1822 self._select_row(event.row) 

1823 

1824 @on(ModelList.Selected) 

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

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

1827 self._select_row(event.row) 

1828 

1829 def _refresh_list(self) -> None: 

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

1831 if not self._list_mounted(): 

1832 return 

1833 active_tab = self._active_tab_id_cache 

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

1835 if active_tab in TASK_TAB_IDS: 

1836 active_task = TAB_ID_TO_TASK[active_tab] 

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

1838 else: 

1839 self._rows = list(all_rows) 

1840 search = self._get_search_text() 

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

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

1843 list_key = ( 

1844 self._data_version, 

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

1846 search, 

1847 ) 

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

1849 self._update_sort_label() 

1850 return 

1851 self._list_cache_keys[active_tab] = list_key 

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

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

1854 self._update_sort_label() 

1855 

1856 def _filter_list(self) -> None: 

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

1858 search = self._get_search_text() 

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

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

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

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

1863 self._list_cache_keys[self._active_tab_id_cache] = ( 

1864 self._data_version, 

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

1866 search, 

1867 ) 

1868 self._update_sort_label() 

1869 

1870 def _sync_loading_spinner(self) -> None: 

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

1872 

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

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

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

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

1877 """ 

1878 try: 

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

1880 except Exception: 

1881 return 

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

1883 if active: 

1884 spinner.styles.display = "block" 

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

1886 if self._spinner_timer is None: 

1887 self._spinner_timer = self.set_interval( 

1888 _SPINNER_INTERVAL_S, self._tick_loading_spinner 

1889 ) 

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

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

1892 # same place mouse scroll surfaces it. 

1893 if self._loading_more: 

1894 with contextlib.suppress(Exception): 

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

1896 hint.update( 

1897 msg.CATALOG_GRID_LOADING_MORE.format( 

1898 frame=SPINNER_FRAMES[self._spinner_frame] 

1899 ) 

1900 ) 

1901 else: 

1902 spinner.update("") 

1903 spinner.styles.display = "none" 

1904 if self._spinner_timer is not None: 

1905 self._spinner_timer.stop() 

1906 self._spinner_timer = None 

1907 self._spinner_frame = 0 

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

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

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

1911 active_task = self._active_task() 

1912 hf_count = ( 

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

1914 if active_task is not None 

1915 else len(hf_rows) 

1916 ) 

1917 self._refresh_grid_ctas(hf_count=hf_count) 

1918 

1919 def _tick_loading_spinner(self) -> None: 

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

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

1922 with contextlib.suppress(Exception): 

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

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

1925 if self._loading_more: 

1926 with contextlib.suppress(Exception): 

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

1928 hint.update( 

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

1930 ) 

1931 

1932 def _update_sort_label(self) -> None: 

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

1934 

1935 Wrapped in NoMatches suppression because the worker callbacks that 

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

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

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

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

1940 """ 

1941 try: 

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

1943 except NoMatches: 

1944 return 

1945 if self._active_tab_id() == TAB_LIBRARY: 

1946 label.update(self._frontier_label_text()) 

1947 return 

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

1949 n_total = len(self._rows) 

1950 if self._loading_more: 

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

1952 elif self._active_task_has_more(): 

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

1954 elif self._active_task_truncated(): 

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

1956 else: 

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

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

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

1960 

1961 def _frontier_label_text(self) -> str: 

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

1963 return msg.CATALOG_FRONTIER_SUMMARY.format( 

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

1965 ) 

1966 

1967 def action_cycle_sort(self) -> None: 

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

1969 if self._search_focused: 

1970 return 

1971 if self._active_tab_id() not in TASK_TAB_IDS: 

1972 return 

1973 if self._grid_view: 

1974 self.notify(msg.CATALOG_SORT_LIST_ONLY) 

1975 return 

1976 try: 

1977 idx = _SORT_CYCLE.index(self._sort_column) 

1978 except ValueError: 

1979 idx = -1 

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

1981 self._sort_ascending = True 

1982 self._refresh_list() 

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

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

1985 self.call_after_refresh(self._focus_list_item, 0) 

1986 

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

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

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

1990 self._select_frontier_row(row) 

1991 return 

1992 if row.variant and row.family: 

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

1994 elif row.catalog_model: 

1995 self._install_model(row.catalog_model) 

1996 elif row.remote_model: 

1997 apply_active_model(self.app, _model_field_for_task(row.remote_model.task), row.ref) 

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

1999 

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

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

2002 if row.key_status == KeyStatus.READY: 

2003 apply_active_model(self.app, _model_field_for_task(row.task), row.ref) 

2004 self.notify( 

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

2006 ) 

2007 return 

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

2009 self.notify( 

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

2011 severity="warning", 

2012 timeout=10, 

2013 ) 

2014 self.app.switch_view("Settings") 

2015 

2016 def _load_more(self) -> None: 

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

2018 

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

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

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

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

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

2024 """ 

2025 if self._loading_more: 

2026 return 

2027 if self._get_search_text(): 

2028 self._load_more_search_results() 

2029 return 

2030 task = self._active_task() 

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

2032 return 

2033 self._loading_more = True 

2034 self._sync_loading_spinner() 

2035 self._hf_offset_by_task[task] += _HF_PAGE_SIZE 

2036 self._fetch_more_hf_for_task(task) 

2037 

2038 def _load_more_search_results(self) -> None: 

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

2040 

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

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

2043 """ 

2044 query = self._get_search_text() 

2045 if self._search_in_flight or not self._search_has_more: 

2046 return 

2047 if query != self._searched_query: 

2048 return 

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

2050 if active_task is None: 

2051 return 

2052 self._search_offset += _HF_SEARCH_LIMIT 

2053 self._search_in_flight = True 

2054 self._sync_loading_spinner() 

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

2056 

2057 def action_load_more(self) -> None: 

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

2059 if self._active_tab_id() not in TASK_TAB_IDS: 

2060 return 

2061 self._load_more() 

2062 

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

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

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

2066 

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

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

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

2070 """ 

2071 new_tab = event.pane.id or TAB_CHAT 

2072 if not self._activation_settled: 

2073 return 

2074 self._active_tab_id_cache = new_tab 

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

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

2077 # may be detached. _grid_for_tab/_list_for_tab validate via 

2078 # is_running and refetch as needed. 

2079 self._update_sort_label() 

2080 if new_tab == TAB_LIBRARY: 

2081 self._populate_library_list() 

2082 elif new_tab == TAB_DISCOVER: 

2083 self._populate_discover_rails() 

2084 elif new_tab in TASK_TAB_IDS: 

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

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

2087 # after, so re-activations stay free. 

2088 self._ensure_task_initial_fetch(TAB_ID_TO_TASK[new_tab]) 

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

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

2091 self._refresh_view() 

2092 

2093 def _populate_discover_rails(self) -> None: 

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

2095 

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

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

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

2099 coin flip. 

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

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

2102 single rail-friendly slice. 

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

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

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

2106 skipping rows For You already took. 

2107 """ 

2108 try: 

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

2110 except Exception: 

2111 return 

2112 family_rows = self._all_family_rows() 

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

2114 remote_rows = self._all_remote_rows() 

2115 for_you = for_you_by_role(family_rows + hf_rows) 

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

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

2118 fresh = sorted( 

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

2120 key=lambda r: -r.sort_downloads, 

2121 )[:6] 

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

2123 

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

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

2126 entry = CatalogModel( 

2127 hf_repo=variant.hf_repo, 

2128 gguf_filename=variant.filename, 

2129 size_gb=variant.size_mb / 1024, 

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

2131 description=family.description, 

2132 featured=True, 

2133 downloads=0, 

2134 task=family.task, 

2135 ) 

2136 self._install_model(entry) 

2137 

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

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

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

2141 return 

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

2143 # file is named here, so the check asks HuggingFace what it really costs. 

2144 needed = int(model.size_gb * _BYTES_PER_GB) 

2145 try: 

2146 filename = resolve_filename(model) 

2147 dest = cfg.models_dir / filename 

2148 if dest.exists(): 

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

2150 return 

2151 needed = download_bytes(model.hf_repo, filename) 

2152 except Exception: 

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

2154 

2155 # After the already-installed check, which needs no space, and before 

2156 # the enqueue: a task that fails instantly is terminal, so dedupe would 

2157 # not stop a second row. 

2158 shortfall = disk_shortfall(cfg.models_dir, model.hf_repo, needed) 

2159 if shortfall is not None: 

2160 self.notify(shortfall, severity="warning") 

2161 return 

2162 

2163 self._enqueue_download(model) 

2164 

2165 def _on_chat_ready_changed(self, ready: bool) -> None: 

2166 """Show the first-run welcome only while no chat model resolves.""" 

2167 with contextlib.suppress(NoMatches): 

2168 self.query_one("#catalog-welcome", Static).display = not ready 

2169 

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

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

2172 

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

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

2175 survives navigation. When the row's architecture is known-unsupported, 

2176 confirm with a modal before enqueuing; the modal returns True to 

2177 proceed with ``allow_unsupported=True`` or False to cancel. 

2178 """ 

2179 

2180 def _adopt() -> None: 

2181 # Runs on the download worker thread; the safe wrapper drops the 

2182 # hop on a detached screen instead of crashing the worker. 

2183 call_from_thread(self, self._adopt_first_download, model) 

2184 

2185 if model.compat is ModelCompat.UNSUPPORTED: 

2186 

2187 def _after_confirm(verdict: bool | None) -> None: 

2188 if not verdict: 

2189 return 

2190 self.app.task_bar.start_download(model, allow_unsupported=True, on_success=_adopt) 

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

2192 

2193 self.app.push_screen( 

2194 ConfirmDialog( 

2195 msg.COMPAT_MODAL_TITLE, 

2196 msg.COMPAT_MODAL_BODY.format(arch=model.architecture or "unknown"), 

2197 ), 

2198 _after_confirm, 

2199 ) 

2200 return 

2201 

2202 self.app.task_bar.start_download(model, on_success=_adopt) 

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

2204 

2205 def _adopt_first_download(self, model: CatalogModel) -> None: 

2206 """Make the first model of an unconfigured role the active one. 

2207 

2208 A role that already has a model keeps it: a later download never 

2209 steals the assignment. Chat announces itself, since the user's next 

2210 step (start chatting) is on another screen. 

2211 """ 

2212 field = _model_field_for_task(model.task) 

2213 if getattr(cfg, field): 

2214 return 

2215 apply_active_model(self.app, field, model.ref) 

2216 if field == "chat_model": 

2217 self.app.notify(msg.CHAT_READY_TOAST) 

2218 

2219 def action_go_back(self) -> None: 

2220 # An open filter collapses to hidden (restoring grid/list focus); 

2221 # otherwise q / Esc returns to the view the user came from. 

2222 if self._filter_open: 

2223 self._search_input.value = "" 

2224 self._search_input.add_class(_HIDDEN_CLASS) 

2225 self._focus_list_or_grid() 

2226 return 

2227 self.app.go_back() 

2228 

2229 def _focus_list_or_grid(self) -> None: 

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

2231 if self._grid_view: 

2232 self._focus_first_grid() 

2233 else: 

2234 self._focus_list_item(0) 

2235 

2236 def action_show_info(self) -> None: 

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

2238 if self._search_focused: 

2239 return 

2240 row = self._highlighted_row() 

2241 if row is None: 

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

2243 return 

2244 if row.kind != CatalogRowKind.LOCAL: 

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

2246 return 

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

2248 

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

2250 

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

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

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

2254 return self._list_widget.highlighted_row() 

2255 focused_grid = self._focused_grid() 

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

2257 return None 

2258 if isinstance(focused_grid, ModelGrid): 

2259 rows = focused_grid.rows 

2260 index = focused_grid.highlighted 

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

2262 child = focused_grid.children[focused_grid.highlighted] 

2263 if isinstance(child, ModelCard): 

2264 return child.row 

2265 return None 

2266 

2267 def action_delete_model(self) -> None: 

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

2269 if self._search_focused: 

2270 return 

2271 model_name = self._get_highlighted_model_name() 

2272 if model_name is None: 

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

2274 return 

2275 

2276 if not self._row_is_installed(model_name): 

2277 self.notify( 

2278 msg.CATALOG_NOT_INSTALLED.format(name=model_name), 

2279 severity="warning", 

2280 ) 

2281 return 

2282 

2283 if self._pending_delete == model_name: 

2284 self._pending_delete = None 

2285 self._run_delete(model_name) 

2286 else: 

2287 self._pending_delete = model_name 

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

2289 

2290 def _row_is_installed(self, model_name: str) -> bool: 

2291 """True if *model_name* names an installed native or remote model. 

2292 

2293 ``_installed_names`` carries both the full ``<repo>/<file>.gguf`` 

2294 ref and the bare ``hf_repo`` for every installed native model, 

2295 so it answers either ref shape; remote presence is asked of the 

2296 manager directly. 

2297 """ 

2298 if model_name in self._installed_names: 

2299 return True 

2300 return get_services().model_manager.is_installed(model_name, ModelSource.REMOTE) 

2301 

2302 def _resolve_delete_ref(self, identity: str) -> str: 

2303 """Pick the single registry ref that deleting *identity* maps to. 

2304 

2305 Featured / HF browse rows surface a bare hf_repo while the 

2306 registry deletes by ``<hf_repo>/<file>.gguf``. Bare repos 

2307 resolve to the lexicographically-first matching installed 

2308 manifest; full refs and remote names pass through. 

2309 """ 

2310 if "/" in identity and identity.endswith(".gguf"): 

2311 return identity 

2312 prefix = identity + "/" 

2313 matches = sorted(n for n in self._installed_names if n.startswith(prefix)) 

2314 if matches: 

2315 return matches[0] 

2316 return identity 

2317 

2318 def _get_highlighted_model_name(self) -> str | None: 

2319 """Return the registry-compatible model ref for the focused/highlighted row.""" 

2320 if not self._grid_view and self._list_widget.has_focus: 

2321 row = self._list_widget.highlighted_row() 

2322 return row_delete_id(row) if row else None 

2323 focused_grid = self._focused_grid() 

2324 if focused_grid is None or focused_grid.highlighted is None: 

2325 return None 

2326 if isinstance(focused_grid, ModelGrid): 

2327 rows = focused_grid.rows 

2328 index = focused_grid.highlighted 

2329 if 0 <= index < len(rows): 

2330 return row_delete_id(rows[index]) 

2331 return None 

2332 # GridSelect path: cards are direct children indexed positionally. 

2333 child = focused_grid.children[focused_grid.highlighted] 

2334 if isinstance(child, ModelCard): 

2335 return row_delete_id(child.row) 

2336 return None 

2337 

2338 @work(thread=True) 

2339 def _run_delete(self, model_name: str) -> None: 

2340 """Remove a model in a background thread.""" 

2341 delete_ref = self._resolve_delete_ref(model_name) 

2342 try: 

2343 removed = get_services().model_manager.remove(delete_ref) 

2344 if removed: 

2345 call_from_thread(self, self.notify, msg.CATALOG_DELETED.format(name=model_name)) 

2346 call_from_thread(self, self._refresh_after_delete) 

2347 else: 

2348 call_from_thread( 

2349 self, 

2350 self.notify, 

2351 msg.CATALOG_DELETE_FAILED.format(error=model_name), 

2352 severity="error", 

2353 ) 

2354 except Exception as exc: 

2355 log.warning("Delete failed for %s", model_name, exc_info=True) 

2356 call_from_thread( 

2357 self, 

2358 self.notify, 

2359 msg.CATALOG_DELETE_FAILED.format(error=exc), 

2360 severity="error", 

2361 ) 

2362 

2363 def _refresh_after_delete(self) -> None: 

2364 """Re-fetch remote models and refresh after deletion.""" 

2365 self._fetch_installed_names() 

2366 self._refresh_view() 

2367 self._fetch_remote_models() 

2368 

2369 def _focused_grid(self) -> ModelGrid | GridSelect | None: 

2370 """Return the focused grid widget (grid view), else None.""" 

2371 if self._grid_view and isinstance(self.focused, (ModelGrid, GridSelect)): 

2372 return self.focused 

2373 return None 

2374 

2375 def _list_count(self) -> int: 

2376 """Total options currently shown in the list view (excluding headings).""" 

2377 return self._list_widget.row_count 

2378 

2379 def _focus_list_item(self, index: int) -> None: 

2380 """Highlight the row at *index*, clamped to the visible range.""" 

2381 count = self._list_widget.option_count 

2382 if not count: 

2383 return 

2384 clamped = max(0, min(index, count - 1)) 

2385 self._list_widget.highlighted = clamped 

2386 # Same invariant as _mount_remaining_grid_sections: this is reached from 

2387 # call_after_refresh, so it can run after the user has opened the filter 

2388 # with `/`, and taking the cursor back would strand them mid-keystroke. 

2389 if self._search_focused: 

2390 return 

2391 self.set_focus(self._list_widget) 

2392 

2393 def _focused_list_index(self) -> int | None: 

2394 """Index of the highlighted list row, or None when nothing is highlighted.""" 

2395 return self._list_widget.highlighted 

2396 

2397 def _nudge_list(self, delta: int) -> None: 

2398 idx = self._focused_list_index() 

2399 if idx is None: 

2400 self._focus_list_item(0) 

2401 return 

2402 self._focus_list_item(idx + delta) 

2403 self._maybe_prefetch_on_nav() 

2404 

2405 def _maybe_prefetch_on_nav(self) -> None: 

2406 if self._grid_view or not self._active_task_has_more() or self._loading_more: 

2407 return 

2408 idx = self._focused_list_index() 

2409 if idx is None: 

2410 return 

2411 if idx >= self._list_widget.option_count - _HF_LOAD_MORE_TRIGGER: 

2412 self._load_more() 

2413 

2414 def _maybe_prefetch_on_grid_nav(self) -> None: 

2415 """Fire ``_load_more`` when the keyboard cursor lands within the last 

2416 rows of the catalog. Mouse wheel triggers via ``_on_grid_scrolled`` at 

2417 the 85 % scroll threshold, but cell-by-cell keyboard nav advances 

2418 scroll_y too gradually to ever cross that threshold; this check 

2419 guarantees keyboard reaches the same prefetch trigger. 

2420 """ 

2421 if not self._grid_view or not self._active_task_has_more() or self._loading_more: 

2422 return 

2423 grids = list(self._grid_container.query(ModelGrid)) 

2424 if not grids: 

2425 return 

2426 focused = self._focused_grid() 

2427 if not isinstance(focused, ModelGrid) or focused.highlighted is None: 

2428 return 

2429 # Absolute cursor position = cards in earlier grids + cursor in this grid. 

2430 try: 

2431 grid_index = grids.index(focused) 

2432 except ValueError: 

2433 return 

2434 cards_before = sum(len(g.rows) for g in grids[:grid_index]) 

2435 absolute = cards_before + focused.highlighted 

2436 total = sum(len(g.rows) for g in grids) 

2437 if total <= 0: 

2438 return 

2439 if absolute >= total - _HF_LOAD_MORE_TRIGGER: 

2440 self._load_more() 

2441 

2442 _SCROLL_PREFETCH_RATIO = 0.85 

2443 _SCROLL_PREFETCH_COOLDOWN = 0.8 

2444 

2445 def _on_list_scrolled(self, _scroll_y: float) -> None: 

2446 """Trigger _load_more when the user scrolls near the bottom of the list.""" 

2447 if not self._scroll_prefetch_due(self._list_widget): 

2448 return 

2449 self._scroll_prefetch_armed_at = time.monotonic() 

2450 self._load_more() 

2451 

2452 def _on_grid_scrolled(self, _scroll_y: float) -> None: 

2453 """Trigger _load_more when the user scrolls near the bottom of the grid.""" 

2454 if not self._grid_view: 

2455 return 

2456 if not self._scroll_prefetch_due(self._grid_container): 

2457 return 

2458 self._scroll_prefetch_armed_at = time.monotonic() 

2459 self._load_more() 

2460 

2461 def on_mouse_scroll_down(self, event: MouseScrollDown) -> None: 

2462 """Force pagination when wheeling beyond what the active scroll can scroll. 

2463 

2464 Three collapsed triggers, both views: (1) content already fits the 

2465 viewport so ``max_scroll_y == 0`` and wheel events produce no scroll 

2466 delta, (2) the user has wheeled to ``scroll_y == max_scroll_y`` and 

2467 further wheels produce no delta, (3) list view has the same problem 

2468 as grid view -- the scroll watcher only fires on scroll_y changes, 

2469 so a wheel at max_y is invisible to ``_on_list_scrolled`` / 

2470 ``_on_grid_scrolled``. Re-check here and fetch the next page 

2471 directly. Cooldown prevents a cascade as new rows shift max_scroll_y. 

2472 """ 

2473 if not self._active_task_has_more() or self._loading_more: 

2474 return 

2475 container = self._grid_container if self._grid_view else self._list_widget 

2476 max_y = container.max_scroll_y 

2477 if max_y > 0 and container.scroll_y < max_y: 

2478 return 

2479 if self._scroll_prefetch_armed_at: 

2480 elapsed = time.monotonic() - self._scroll_prefetch_armed_at 

2481 if elapsed < self._SCROLL_PREFETCH_COOLDOWN: 

2482 return 

2483 self._scroll_prefetch_armed_at = time.monotonic() 

2484 self._load_more() 

2485 

2486 def _scroll_prefetch_due(self, widget: VerticalScroll | ModelList) -> bool: 

2487 # Cooldown blocks a runaway cascade where appending rows shifts 

2488 # max_scroll_y, the watcher refires, and load_more kicks off the 

2489 # next fetch before the user notices. 

2490 if not self._active_task_has_more() or self._loading_more: 

2491 return False 

2492 if self._scroll_prefetch_armed_at: 

2493 elapsed = time.monotonic() - self._scroll_prefetch_armed_at 

2494 if elapsed < self._SCROLL_PREFETCH_COOLDOWN: 

2495 return False 

2496 max_y = widget.max_scroll_y 

2497 if max_y <= 0: 

2498 return False 

2499 return widget.scroll_y / max_y >= self._SCROLL_PREFETCH_RATIO 

2500 

2501 def _page_rows(self) -> int: 

2502 """How many cursor steps make up one 'page' in the active view.""" 

2503 return _GRID_PAGE_ROWS if self._grid_view else _LIST_PAGE_ROWS 

2504 

2505 def action_page_down(self) -> None: 

2506 if self._search_focused: 

2507 return 

2508 if self._grid_view: 

2509 if (grid := self._focused_grid()) is not None: 

2510 for _ in range(self._page_rows()): 

2511 grid.action_cursor_down() 

2512 else: 

2513 self._nudge_list(self._page_rows()) 

2514 

2515 def action_page_up(self) -> None: 

2516 if self._search_focused: 

2517 return 

2518 if self._grid_view: 

2519 if (grid := self._focused_grid()) is not None: 

2520 for _ in range(self._page_rows()): 

2521 grid.action_cursor_up() 

2522 else: 

2523 self._nudge_list(-self._page_rows()) 

2524 

2525 def action_cursor_down(self) -> None: 

2526 if self._search_focused: 

2527 return 

2528 if self._grid_view: 

2529 grid = self._focused_grid() or self._first_grid_or_none() 

2530 if grid is not None: 

2531 grid.focus() 

2532 grid.action_cursor_down() 

2533 else: 

2534 self._nudge_list(1) 

2535 

2536 def action_cursor_up(self) -> None: 

2537 if self._search_focused: 

2538 return 

2539 if self._grid_view: 

2540 grid = self._focused_grid() or self._first_grid_or_none() 

2541 if grid is not None: 

2542 grid.focus() 

2543 grid.action_cursor_up() 

2544 else: 

2545 self._nudge_list(-1) 

2546 

2547 def _first_grid_or_none(self) -> ModelGrid | None: 

2548 """Return the first non-empty ModelGrid in the active tab's container.""" 

2549 grids = self._pane_grids_with_rows() 

2550 return grids[0] if grids else None 

2551 

2552 def action_jump_top(self) -> None: 

2553 if self._search_focused: 

2554 return 

2555 if self._grid_view: 

2556 if (grid := self._focused_grid()) is not None: 

2557 grid.highlight_first() 

2558 else: 

2559 self._focus_list_item(0) 

2560 

2561 def action_jump_bottom(self) -> None: 

2562 if self._search_focused: 

2563 return 

2564 if self._grid_view: 

2565 if (grid := self._focused_grid()) is not None: 

2566 grid.highlight_last() 

2567 else: 

2568 count = self._list_widget.option_count 

2569 if count: 

2570 self._focus_list_item(count - 1) 

2571 self._maybe_prefetch_on_nav()