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

1361 statements  

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

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, get_provider_api_key 

89from lilbee.runtime.hardware import available_memory_for_fit, compute_fit 

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 

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

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

116 

117 

118_WORKER_FETCH_HF = "fetch_hf_models" 

119_WORKER_FETCH_MORE_HF = "fetch_more_hf" 

120_WORKER_FETCH_REMOTE = "fetch_remote_models" 

121_WORKER_FETCH_SEARCH = "fetch_hf_search" 

122_WORKER_FETCH_FRONTIER = "fetch_frontier_models" 

123_WORKER_FETCH_FAMILIES = "fetch_families" 

124 

125_GRID_PAGE_ROWS = 3 

126_LIST_PAGE_ROWS = 10 

127 

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

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

130_GRID_ID_PREFIX = "grid-" 

131_LIST_ID_PREFIX = "list-" 

132 

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

134_HIDDEN_CLASS = "-hidden" 

135 

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

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

138# can take several). 

139_TAB_ACTIVATION_RETRY_BUDGET = 20 

140 

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

142 

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

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

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

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

147_SPINNER_INTERVAL_S = 0.1 

148 

149_RowCacheKey = tuple[int, int, int, int, int, int] 

150 

151 

152@dataclass(frozen=True) 

153class _RowCacheEntry: 

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

155 

156 key: _RowCacheKey 

157 rows: list[LocalCatalogRow] 

158 

159 

160class _GridCacheKey(NamedTuple): 

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

162 

163 data_version: int 

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

165 search: str 

166 

167 

168class CatalogScreen(Screen[None]): 

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

170 

171 app: LilbeeApp # type: ignore[assignment] 

172 

173 CSS_PATH = "catalog.tcss" 

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

175 

176 HELP = ( 

177 "# Catalog\n" 

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

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

180 "## Navigation\n" 

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

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

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

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

185 "## Actions\n" 

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

187 "- Space: toggle select.\n" 

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

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

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

191 "## Filters and views\n" 

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

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

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

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

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

197 "## Detail drawer\n" 

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

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

200 "## Fit chip\n" 

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

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

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

204 "## Other\n" 

205 "- q / Esc: back." 

206 ) 

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

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

209 

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

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

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

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

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

215 # preference, not a way around the catalog. 

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

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

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

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

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

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

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

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

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

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

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

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

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

229 # the highlight follows the visible region. Decoupling them 

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

231 # previously-focused card. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

249 # with nothing on screen to say why. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

267 # swallow them. 

268 Binding( 

269 "less_than_sign", 

270 "cycle_tab(-1)", 

271 "Prev tab", 

272 show=True, 

273 priority=True, 

274 group=_TAB_GROUP, 

275 ), 

276 Binding( 

277 "greater_than_sign", 

278 "cycle_tab(1)", 

279 "Next tab", 

280 show=True, 

281 priority=True, 

282 group=_TAB_GROUP, 

283 ), 

284 ] 

285 

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

287 

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

289 super().__init__() 

290 self._focus_task: str | None = focus_task 

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

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

293 # here would block screen construction on the network. 

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

295 self._families_in_flight = True 

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

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

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

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

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

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

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

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

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

305 self._sort_column: str = "Name" 

306 self._sort_ascending: bool = True 

307 self._pending_delete: str | None = None 

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

309 self._grid_view: bool = True 

310 self._loading_more: bool = False 

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

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

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

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

315 self._search_in_flight: bool = False 

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

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

318 self._searched_query: str = "" 

319 self._search_offset: int = 0 

320 self._search_has_more: bool = False 

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

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

323 # invalidate even when collection lengths happen to coincide. 

324 self._data_version: int = 0 

325 self._family_rows_cache: _RowCacheEntry | None = None 

326 self._hf_rows_cache: _RowCacheEntry | None = None 

327 self._remote_rows_cache: _RowCacheEntry | None = None 

328 self._view_switching: bool = False 

329 self._frontier_refresh_timer: Timer | None = None 

330 self._search_filter_timer: Timer | None = None 

331 self._remote_search_timer: Timer | None = None 

332 self._scroll_prefetch_armed_at: float = 0.0 

333 self._spinner_timer: Timer | None = None 

334 self._spinner_frame: int = 0 

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

336 # query_one on every _grid_container / _list_widget access. Default 

337 # matches the tab _activate_initial_tab selects on mount. 

338 self._active_tab_id_cache: str = TAB_CHAT 

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

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

341 # During initial mount Textual fires TabActivated for whichever pane 

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

343 # call_after_refresh setter activates Chat. Suppressing cache writes 

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

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

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

347 self._activation_settled: bool = False 

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

349 # initial activation gives up (see _activate_initial_tab). 

350 self._activation_retries: int = _TAB_ACTIVATION_RETRY_BUDGET 

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

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

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

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

355 tab_id: SourceMode.LOCAL for tab_id in TASK_TAB_IDS 

356 } 

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

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

359 # re-probing on every refresh. 

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

361 

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

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

364 

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

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

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

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

369 transparently. 

370 """ 

371 cached = self._tab_grid_cache.get(tab_id) 

372 if cached is not None and cached.is_running: 

373 return cached 

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

375 container = self.query_one(selector, VerticalScroll) 

376 self._tab_grid_cache[tab_id] = container 

377 return container 

378 

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

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

381 

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

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

384 """ 

385 target = TAB_CHAT if tab_id == TAB_DISCOVER else tab_id 

386 cached = self._tab_list_cache.get(target) 

387 if cached is not None and cached.is_running: 

388 return cached 

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

390 self._tab_list_cache[target] = widget 

391 return widget 

392 

393 def _grid_mounted(self) -> bool: 

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

395 try: 

396 self._grid_for_tab(self._active_tab_id_cache) 

397 except NoMatches: 

398 return False 

399 return True 

400 

401 def _list_mounted(self) -> bool: 

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

403 try: 

404 self._list_for_tab(self._active_tab_id_cache) 

405 except NoMatches: 

406 return False 

407 return True 

408 

409 @property 

410 def _grid_container(self) -> VerticalScroll: 

411 return self._grid_for_tab(self._active_tab_id_cache) 

412 

413 @property 

414 def _list_widget(self) -> ModelList: 

415 return self._list_for_tab(self._active_tab_id_cache) 

416 

417 @property 

418 def _search_focused(self) -> bool: 

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

420 

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

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

423 """ 

424 return isinstance(self.focused, Input) 

425 

426 @property 

427 def _filter_open(self) -> bool: 

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

429 return not self._search_input.has_class(_HIDDEN_CLASS) 

430 

431 def compose(self) -> ComposeResult: 

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

433 

434 with TopBars(): 

435 yield ViewTabs() 

436 yield Input( 

437 placeholder=msg.CATALOG_FILTER_PLACEHOLDER, 

438 id="catalog-search", 

439 classes=_HIDDEN_CLASS, 

440 ) 

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

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

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

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

445 yield GridListToggle() 

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

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

448 # Horizontal split: TabbedContent fills, CatalogDetailDrawer docks 

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

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

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

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

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

454 with ( 

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

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

457 ): 

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

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

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

461 yield VerticalScroll( 

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

463 ) 

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

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

466 yield VerticalScroll( 

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

468 ) 

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

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

471 yield VerticalScroll( 

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

473 ) 

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

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

476 yield VerticalScroll( 

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

478 ) 

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

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

481 yield VerticalScroll( 

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

483 ) 

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

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

486 with BottomBars(): 

487 yield TaskBar() 

488 yield Footer() 

489 

490 def on_mount(self) -> None: 

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

492 self._fetch_installed_names() 

493 self._fetch_families() 

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

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

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

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

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

499 # call_after_refresh also lets the TabActivated cascade settle first. 

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

501 # Discover via keyboard shortcut. 

502 self.call_after_refresh(self._activate_initial_tab) 

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

504 

505 def _activate_initial_tab(self) -> None: 

506 try: 

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

508 except Exception: 

509 self._activation_settled = True 

510 return 

511 target: str | None 

512 if self._focus_task is not None: 

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

514 self._active_tab_id_cache = self._focus_task 

515 target = self._focus_task 

516 else: 

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

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

519 try: 

520 tabs.active = target 

521 except ValueError: 

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

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

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

525 if self._activation_retries > 0: 

526 self._activation_retries -= 1 

527 self.call_after_refresh(self._activate_initial_tab) 

528 return 

529 if not self._activation_settled: 

530 self._activation_settled = True 

531 self.call_after_refresh(self._refresh_grid) 

532 self.call_after_refresh(self._initial_focus_first_grid) 

533 self._fetch_remote_models() 

534 self._fetch_frontier_models() 

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

536 # task tabs fetch lazily on first activation (see 

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

538 # one HF round-trip instead of four. 

539 self._ensure_task_initial_fetch(ModelTask.CHAT) 

540 self.app.provider_availability_changed_signal.subscribe( 

541 self, self._on_provider_availability_changed 

542 ) 

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

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

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

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

547 # on every tab activation. 

548 for tab_id in (*TASK_TAB_IDS, TAB_LIBRARY): 

549 with contextlib.suppress(Exception): 

550 self.watch( 

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

552 ) 

553 self.watch( 

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

555 ) 

556 

557 def on_unmount(self) -> None: 

558 with contextlib.suppress(Exception): 

559 self.app.provider_availability_changed_signal.unsubscribe(self) 

560 self._stop_spinner_timer() 

561 

562 def on_screen_suspend(self) -> None: 

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

564 

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

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

567 of main-thread CPU forever. 

568 """ 

569 self._stop_spinner_timer() 

570 

571 def on_screen_resume(self) -> None: 

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

573 if self._loading_more or self._search_in_flight: 

574 self._sync_loading_spinner() 

575 

576 def _stop_spinner_timer(self) -> None: 

577 if self._spinner_timer is not None: 

578 self._spinner_timer.stop() 

579 self._spinner_timer = None 

580 

581 _FRONTIER_REFRESH_DEBOUNCE = 1.0 

582 

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

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

585 if self._frontier_refresh_timer is not None: 

586 self._frontier_refresh_timer.stop() 

587 self._frontier_refresh_timer = self.set_timer( 

588 self._FRONTIER_REFRESH_DEBOUNCE, self._fetch_frontier_models 

589 ) 

590 

591 def _focus_first_grid(self) -> None: 

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

593 with contextlib.suppress(Exception): 

594 grids = self._pane_grids_with_rows() 

595 if grids: 

596 self.set_focus(grids[0]) 

597 return 

598 with contextlib.suppress(Exception): 

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

600 

601 def _initial_focus_first_grid(self) -> None: 

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

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

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

605 if self.focused is not None: 

606 return 

607 self._focus_first_grid() 

608 

609 def _fetch_installed_names(self) -> None: 

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

611 

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

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

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

615 has a manifest. 

616 """ 

617 with contextlib.suppress(Exception): 

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

619 self._data_version += 1 

620 

621 def _active_tab_id(self) -> str: 

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

623 

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

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

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

627 """ 

628 return self._active_tab_id_cache 

629 

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

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

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

633 

634 def _active_task_has_more(self) -> bool: 

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

636 

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

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

639 result set on screen. 

640 """ 

641 task = self._active_task() 

642 if task is None: 

643 return False 

644 if self._get_search_text(): 

645 return self._search_has_more 

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

647 

648 def _hf_fetched_any(self) -> bool: 

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

650 

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

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

653 """ 

654 return bool(self._hf_fetched_tasks) 

655 

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

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

658 if task in self._hf_fetched_tasks: 

659 return 

660 self._hf_fetched_tasks.add(task) 

661 self._fetch_initial_hf_models_for_task(task) 

662 

663 def action_toggle_view(self) -> None: 

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

665 

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

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

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

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

670 """ 

671 if self._active_tab_id() not in TASK_TAB_IDS: 

672 return 

673 if self._view_switching: 

674 return 

675 self._view_switching = True 

676 try: 

677 if self._grid_view: 

678 self._grid_view = False 

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

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

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

682 if active_task is not None: 

683 self._ensure_task_initial_fetch(active_task) 

684 with self.app.batch_update(): 

685 self._refresh_list() 

686 self._focus_list_item(0) 

687 else: 

688 self._grid_view = True 

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

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

691 with self.app.batch_update(): 

692 self._refresh_grid() 

693 with contextlib.suppress(Exception): 

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

695 finally: 

696 self._view_switching = False 

697 self._sync_grid_list_toggle() 

698 

699 def _sync_grid_list_toggle(self) -> None: 

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

701 

702 with contextlib.suppress(Exception): 

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

704 

705 def action_focus_search(self) -> None: 

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

707 

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

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

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

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

712 """ 

713 self._search_input.remove_class(_HIDDEN_CLASS) 

714 self.set_focus(self._search_input) 

715 

716 _SEARCH_FILTER_DEBOUNCE_SECONDS = 0.08 

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

718 # pause in typing. 

719 _REMOTE_SEARCH_DEBOUNCE_SECONDS = 0.45 

720 

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

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

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

724 

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

726 which Textual treats as layout invalidations. Without the debounce 

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

728 to a single pass once the user pauses. 

729 

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

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

732 """ 

733 if self._search_filter_timer is not None: 

734 self._search_filter_timer.stop() 

735 self._search_filter_timer = self.set_timer( 

736 self._SEARCH_FILTER_DEBOUNCE_SECONDS, 

737 self._apply_search_filter, 

738 ) 

739 if self._remote_search_timer is not None: 

740 self._remote_search_timer.stop() 

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

742 if not self._get_search_text(): 

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

744 self._searched_query = "" 

745 self._search_offset = 0 

746 self._search_has_more = False 

747 return 

748 self._remote_search_timer = self.set_timer( 

749 self._REMOTE_SEARCH_DEBOUNCE_SECONDS, 

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

751 ) 

752 

753 def _apply_search_filter(self) -> None: 

754 if self._active_tab_id() == TAB_LIBRARY: 

755 self._populate_library_list() 

756 return 

757 if self._active_tab_id() == TAB_DISCOVER: 

758 return 

759 if self._grid_view: 

760 self._filter_grid() 

761 else: 

762 self._filter_list() 

763 

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

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

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

767 

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

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

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

771 deliberate Enter on the focused card. 

772 """ 

773 self._focus_first_result() 

774 

775 def _focus_first_result(self) -> None: 

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

777 with contextlib.suppress(Exception): 

778 if not self._grid_view: 

779 self._focus_first_list_row() 

780 return 

781 self._focus_first_grid_card() 

782 

783 def _focus_first_list_row(self) -> None: 

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

785 if not self._list_widget.option_count: 

786 return 

787 self._list_widget.highlighted = 0 

788 self._list_widget.focus() 

789 

790 def _focus_first_grid_card(self) -> None: 

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

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

793 if grid.rows: 

794 grid.focus() 

795 grid.highlighted = 0 

796 return 

797 

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

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

800 

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

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

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

804 the search Input is hidden on them. 

805 """ 

806 if self._search_in_flight or not query: 

807 return 

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

809 if active_task is None: 

810 return 

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

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

813 if query != self._searched_query: 

814 self._searched_query = query 

815 self._search_offset = 0 

816 self._search_has_more = False 

817 self._search_in_flight = True 

818 self._update_sort_label() 

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

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

821 self._sync_loading_spinner() 

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

823 

824 def _resume_search_if_term_moved_on(self) -> None: 

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

826 

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

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

829 """ 

830 query = self._get_search_text() 

831 if query and query != self._searched_query: 

832 self._trigger_remote_search(query) 

833 

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

835 def _on_search_hf_cta_clicked(self) -> None: 

836 self._trigger_remote_search(self._get_search_text()) 

837 

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

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

840 

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

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

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

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

845 """ 

846 offset = self._hf_offset_by_task[task] 

847 result = get_catalog( 

848 task=task, 

849 featured=False, 

850 limit=_HF_PAGE_SIZE, 

851 offset=offset, 

852 ) 

853 self._hf_has_more_by_task[task] = result.has_more 

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

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

856 

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

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

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

860 return self._fetch_hf_page_for_task(task) 

861 

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

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

864 return classify_all_remote_models() 

865 

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

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

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

869 

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

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

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

873 """ 

874 try: 

875 return get_families() 

876 except Exception: 

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

878 return [] 

879 

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

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

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

883 

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

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

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

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

888 the screen responsive.""" 

889 from lilbee.modelhub.model_manager import discover_api_models 

890 

891 try: 

892 groups = discover_api_models() 

893 except Exception: 

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

895 return [] 

896 

897 rows: list[FrontierCatalogRow] = [] 

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

899 provider_id = display_name.lower() 

900 has_key = get_provider_api_key(provider_id) is not None 

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

902 for rm in models: 

903 rows.append( 

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

905 ) 

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

907 return rows 

908 

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

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

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

912 return self._fetch_hf_page_for_task(task) 

913 

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

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

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

917 

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

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

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

921 """ 

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

923 result = get_catalog( 

924 task=task, 

925 featured=False, 

926 search=query, 

927 limit=_HF_SEARCH_LIMIT, 

928 offset=offset, 

929 ) 

930 self._search_has_more = result.has_more 

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

932 

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

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

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

936 self._handle_worker_error_or_cancel(event.worker.name) 

937 return 

938 if event.state != WorkerState.SUCCESS: 

939 return 

940 result = event.worker.result 

941 if not isinstance(result, list): 

942 return 

943 worker_name = event.worker.name 

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

945 return 

946 # A fast worker can complete before TabbedContent finishes mounting 

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

948 # _activate_initial_tab schedules rebuild against the applied state. 

949 with contextlib.suppress(NoMatches): 

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

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

952 if worker_name == _WORKER_FETCH_MORE_HF: 

953 if self._grid_view: 

954 self._refresh_grid() 

955 else: 

956 self._append_more_hf_to_list(result) 

957 return 

958 self._refresh_view() 

959 

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

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

962 

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

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

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

966 sibling tab's list. 

967 """ 

968 active_task = self._active_task() 

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

970 self._refresh_view() 

971 return 

972 new_rows = self._sort_rows( 

973 [ 

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

975 for m in new_models 

976 ] 

977 ) 

978 if not new_rows: 

979 self._update_sort_label() 

980 return 

981 self._rows.extend(new_rows) 

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

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

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

985 self._list_cache_keys[self._active_tab_id_cache] = ( 

986 self._data_version, 

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

988 self._get_search_text(), 

989 ) 

990 self._update_sort_label() 

991 

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

993 if name == _WORKER_FETCH_MORE_HF: 

994 self._loading_more = False 

995 if name == _WORKER_FETCH_SEARCH: 

996 self._search_in_flight = False 

997 self._update_sort_label() 

998 self._resume_search_if_term_moved_on() 

999 if name == _WORKER_FETCH_FAMILIES: 

1000 self._families_in_flight = False 

1001 self._sync_loading_spinner() 

1002 

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

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

1005 

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

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

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

1009 if name == _WORKER_FETCH_HF: 

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

1011 # one carries dedup-filtered new rows (see 

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

1013 self._hf_models.extend(result) 

1014 self._loading_more = False 

1015 elif name == _WORKER_FETCH_MORE_HF: 

1016 self._hf_models.extend(result) 

1017 self._loading_more = False 

1018 elif name == _WORKER_FETCH_SEARCH: 

1019 self._hf_models.extend(result) 

1020 self._search_in_flight = False 

1021 self._update_sort_label() 

1022 self._resume_search_if_term_moved_on() 

1023 elif name == _WORKER_FETCH_REMOTE: 

1024 self._remote_models = result 

1025 elif name == _WORKER_FETCH_FRONTIER: 

1026 self._frontier_rows = result 

1027 self._populate_library_list() 

1028 elif name == _WORKER_FETCH_FAMILIES: 

1029 self._families = result 

1030 self._families_in_flight = False 

1031 else: 

1032 return False 

1033 self._data_version += 1 

1034 self._sync_loading_spinner() 

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

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

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

1038 # _populate_discover_rails fires only on tab activation. 

1039 if self._active_tab_id_cache == TAB_DISCOVER: 

1040 self._populate_discover_rails() 

1041 return True 

1042 

1043 def _populate_library_list(self) -> None: 

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

1045 search = self._get_search_text() 

1046 installed_rows: list[LocalCatalogRow] = [] 

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

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

1049 if search: 

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

1051 frontier = self._build_frontier_rows(search) 

1052 self._render_library_list(installed_rows, frontier) 

1053 self._render_library_grid(installed_rows, frontier) 

1054 

1055 def _render_library_list( 

1056 self, 

1057 installed_rows: list[LocalCatalogRow], 

1058 frontier: list[FrontierCatalogRow], 

1059 ) -> None: 

1060 try: 

1061 ml = self._list_for_tab(TAB_LIBRARY) 

1062 except Exception: 

1063 return 

1064 sections: list[ModelListSection] = [] 

1065 if installed_rows: 

1066 sections.append( 

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

1068 ) 

1069 sections.extend(group_frontier_rows(frontier)) 

1070 ml.set_rows(sections) 

1071 

1072 def _render_library_grid( 

1073 self, 

1074 installed_rows: list[LocalCatalogRow], 

1075 frontier: list[FrontierCatalogRow], 

1076 ) -> None: 

1077 try: 

1078 container = self._grid_for_tab(TAB_LIBRARY) 

1079 except Exception: 

1080 return 

1081 sections: list[GridSection] = [] 

1082 if installed_rows: 

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

1084 if frontier: 

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

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

1087 existing_headings = [ 

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

1089 ] 

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

1091 for grid, heading, section in zip( 

1092 existing_grids, existing_headings, sections, strict=False 

1093 ): 

1094 heading.update(section.heading) 

1095 grid.set_rows(section.rows) 

1096 return 

1097 container.remove_children() 

1098 for section in sections: 

1099 container.mount_all( 

1100 [ 

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

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

1103 ] 

1104 ) 

1105 

1106 def _get_search_text(self) -> str: 

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

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

1109 # would otherwise raise NoMatches and crash the callback. 

1110 try: 

1111 return self._search_input.value.strip() 

1112 except Exception: 

1113 return "" 

1114 

1115 def _local_rows_data_key(self) -> _RowCacheKey: 

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

1117 

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

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

1120 """ 

1121 return ( 

1122 len(self._families), 

1123 len(self._hf_models), 

1124 len(self._remote_models), 

1125 len(self._hf_fetched_tasks), 

1126 len(self._installed_names), 

1127 self._data_version, 

1128 ) 

1129 

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

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

1132 

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

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

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

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

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

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

1139 non-primary size without leaving the grid. 

1140 """ 

1141 key = self._local_rows_data_key() 

1142 cached = self._family_rows_cache 

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

1144 return cached.rows 

1145 rows: list[LocalCatalogRow] = [] 

1146 for fam in self._families: 

1147 if not fam.variants: 

1148 continue 

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

1150 family_installed = any( 

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

1152 for v in fam.variants 

1153 ) 

1154 row = variant_to_row(primary, fam, family_installed) 

1155 row.size_variants = family_to_size_variants(fam) 

1156 rows.append(row) 

1157 self._stamp_fit(rows) 

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

1159 return rows 

1160 

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

1162 key = self._local_rows_data_key() 

1163 cached = self._hf_rows_cache 

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

1165 return cached.rows 

1166 rows: list[LocalCatalogRow] = [] 

1167 for m in self._hf_models: 

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

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

1170 self._stamp_fit(rows) 

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

1172 return rows 

1173 

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

1175 key = self._local_rows_data_key() 

1176 cached = self._remote_rows_cache 

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

1178 return cached.rows 

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

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

1181 self._stamp_fit(rows) 

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

1183 return rows 

1184 

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

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

1187 

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

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

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

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

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

1193 """ 

1194 if self._available_memory_bytes is None: 

1195 return 

1196 bytes_per_gb = 1024**3 

1197 for row in rows: 

1198 if row.sort_size <= 0: 

1199 continue 

1200 row.fit = compute_fit( 

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

1202 available_bytes=self._available_memory_bytes, 

1203 ) 

1204 

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

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

1207 search = self._get_search_text() 

1208 rows: list[LocalCatalogRow] = [] 

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

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

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

1212 return rows 

1213 

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

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

1216 if not search: 

1217 return self._all_family_rows() 

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

1219 

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

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

1222 if not search: 

1223 return self._all_hf_rows() 

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

1225 

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

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

1228 if not search: 

1229 return self._all_remote_rows() 

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

1231 

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

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

1234 

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

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

1237 thread. Renderers call this synchronously to read the 

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

1239 """ 

1240 if not self._frontier_rows: 

1241 return [] 

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

1243 

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

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

1246 if name in self._installed_names: 

1247 return True 

1248 if repo and filename: 

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

1250 return False 

1251 

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

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

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

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

1256 return sorted( 

1257 rows, 

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

1259 reverse=not self._sort_ascending, 

1260 ) 

1261 

1262 def _refresh_view(self) -> None: 

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

1264 

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

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

1267 

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

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

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

1271 DOM.""" 

1272 if self._active_tab_id_cache == TAB_DISCOVER: 

1273 self._populate_discover_rails() 

1274 return 

1275 with self.app.batch_update(): 

1276 if self._grid_view: 

1277 self._refresh_grid() 

1278 else: 

1279 self._refresh_list() 

1280 

1281 def _refresh_grid(self) -> None: 

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

1283 

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

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

1286 update each existing ModelGrid via set_rows rather than tearing 

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

1288 CPU spike on every "Browse more" return. 

1289 

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

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

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

1293 next mounted refresh still repaints. 

1294 """ 

1295 if self._active_tab_id_cache == TAB_DISCOVER: 

1296 # Discover paints rails via _populate_discover_rails, never sections. 

1297 return 

1298 if not self._grid_mounted(): 

1299 return 

1300 prep = self._prepare_grid_refresh() 

1301 if prep is None: 

1302 self._update_sort_label() 

1303 return 

1304 sections, hf_count, filter_changed = prep 

1305 if filter_changed: 

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

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

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

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

1310 if not sections: 

1311 self._grid_container.remove_children() 

1312 self._mount_grid_ctas(hf_count=hf_count) 

1313 self._update_sort_label() 

1314 return 

1315 if self._extend_grid_sections_in_place(sections, hf_count): 

1316 return 

1317 self._remount_grid_sections(sections, hf_count) 

1318 self._update_sort_label() 

1319 

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

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

1322 

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

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

1325 

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

1327 cached path still picks up sort-toggle clicks. 

1328 """ 

1329 search = self._get_search_text() 

1330 family_rows = self._build_family_rows(search) 

1331 remote_rows = self._build_remote_rows(search) 

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

1333 all_rows = family_rows + remote_rows + hf_rows 

1334 active_tab = self._active_tab_id_cache 

1335 tab_rows = self._rows_for_active_tab(all_rows, active_tab) 

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

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

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

1339 # toward the local-row tally. 

1340 local_tab_rows: list[LocalCatalogRow] = [ 

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

1342 ] 

1343 self._rows = local_tab_rows 

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

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

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

1347 # repaint rather than read as cache-hot. 

1348 row_key = _GridCacheKey( 

1349 self._data_version, 

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

1351 search, 

1352 ) 

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

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

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

1356 previous_key = self._grid_cache_keys.get(active_tab) 

1357 if previous_key == row_key: 

1358 return None 

1359 self._grid_cache_keys[active_tab] = row_key 

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

1361 if active_tab in TASK_TAB_IDS: 

1362 active_task = TAB_ID_TO_TASK[active_tab] 

1363 task_label = active_task.value.capitalize() 

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

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

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

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

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

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

1370 if frontier_only: 

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

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

1373 else: 

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

1375 hf_count = len(hf_rows) 

1376 if search: 

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

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

1379 sections = flatten_sections(sections, msg.HEADING_MATCHES) 

1380 return sections, hf_count, filter_changed 

1381 

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

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

1384 

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

1386 through to a teardown + remount on False. 

1387 """ 

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

1389 existing_headings = [ 

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

1391 ] 

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

1393 return False 

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

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

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

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

1398 for grid, heading, section in zip( 

1399 existing_grids, existing_headings, sections, strict=False 

1400 ): 

1401 heading.update(section.heading) 

1402 grid.set_rows(section.rows) 

1403 self._refresh_grid_ctas(hf_count=hf_count) 

1404 self._update_sort_label() 

1405 return True 

1406 

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

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

1409 

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

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

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

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

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

1415 looking. 

1416 """ 

1417 focus_anchor = self._capture_focused_section() 

1418 container = self._grid_container 

1419 prior_scroll_y = container.scroll_y 

1420 container.remove_children() 

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

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

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

1424 self.call_after_refresh( 

1425 self._mount_remaining_grid_sections, 

1426 container, 

1427 sections[1:], 

1428 hf_count=hf_count, 

1429 focus_anchor=focus_anchor, 

1430 prior_scroll_y=prior_scroll_y, 

1431 ) 

1432 

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

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

1435 

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

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

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

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

1440 """ 

1441 focused = self._focused_grid() 

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

1443 return None 

1444 return (focused.name, focused.highlighted) 

1445 

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

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

1448 

1449 Returns True when the previous focus position was successfully 

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

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

1452 """ 

1453 if anchor is None: 

1454 return False 

1455 target_heading, target_highlighted = anchor 

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

1457 if grid.name != target_heading: 

1458 continue 

1459 grid.focus() 

1460 if target_highlighted is not None and grid.rows: 

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

1462 return True 

1463 return False 

1464 

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

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

1467 # ``_capture_focused_section`` / ``_restore_focused_section`` to 

1468 # preserve the cursor across teardown + remount. 

1469 # 

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

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

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

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

1474 return container.mount_all( 

1475 [ 

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

1477 grid, 

1478 ] 

1479 ) 

1480 

1481 def _mount_remaining_grid_sections( 

1482 self, 

1483 container: VerticalScroll, 

1484 remaining: list[GridSection], 

1485 hf_count: int, 

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

1487 prior_scroll_y: float = 0.0, 

1488 ) -> list[AwaitMount]: 

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

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

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

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

1493 # runs from call_after_refresh and ignores it. 

1494 if not container.is_running: 

1495 return [] 

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

1497 self._mount_grid_ctas(hf_count=hf_count) 

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

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

1500 if prior_scroll_y: 

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

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

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

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

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

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

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

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

1509 return mounts 

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

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

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

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

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

1515 if self._search_focused: 

1516 return mounts 

1517 if self._restore_focused_section(focus_anchor): 

1518 return mounts 

1519 self._focus_first_grid() 

1520 return mounts 

1521 

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

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

1524 if self._loading_more: 

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

1526 if self._active_task_has_more(): 

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

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

1529 

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

1531 try: 

1532 container = self._grid_container 

1533 except Exception: 

1534 return 

1535 ctas: list[Static] = [ 

1536 Static( 

1537 self._grid_scroll_hint_text(hf_count), 

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

1539 ) 

1540 ] 

1541 search = self._get_search_text() 

1542 if search: 

1543 ctas.append( 

1544 Static( 

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

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

1547 ) 

1548 ) 

1549 container.mount_all(ctas) 

1550 

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

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

1553 try: 

1554 container = self._grid_container 

1555 except Exception: 

1556 return 

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

1558 for w in existing: 

1559 with contextlib.suppress(Exception): 

1560 w.remove() 

1561 self._mount_grid_ctas(hf_count=hf_count) 

1562 

1563 def _rows_for_active_tab( 

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

1565 ) -> list[CatalogRow]: 

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

1567 

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

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

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

1571 also union the matching frontier rows. 

1572 """ 

1573 if active_tab not in TASK_TAB_IDS: 

1574 return list(all_rows) 

1575 active_task = TAB_ID_TO_TASK[active_tab] 

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

1577 local_for_task: list[CatalogRow] = [] 

1578 if mode is not SourceMode.CLOUD: 

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

1580 frontier_for_task: list[CatalogRow] = [] 

1581 if mode is not SourceMode.LOCAL: 

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

1583 return local_for_task + frontier_for_task 

1584 

1585 def _filter_grid(self) -> None: 

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

1587 self._refresh_grid() 

1588 

1589 @on(ModelGrid.Highlighted) 

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

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

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

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

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

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

1596 highlighted row. 

1597 """ 

1598 self._maybe_prefetch_on_grid_nav() 

1599 self._reveal_scroll_hint_at_catalog_end() 

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

1601 

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

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

1604 try: 

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

1606 except Exception: 

1607 return 

1608 rows = grid.rows 

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

1610 drawer.update_for_row(row) 

1611 

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

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

1614 

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

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

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

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

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

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

1621 """ 

1622 if self._search_focused: 

1623 return 

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

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

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

1627 

1628 if not event.key.isdigit(): 

1629 return 

1630 index = int(event.key) - 1 

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

1632 return 

1633 event.stop() 

1634 event.prevent_default() 

1635 self.action_select_tab(index) 

1636 

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

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

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

1640 

1641 if self._search_focused: 

1642 return 

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

1644 return 

1645 target = ALL_TAB_IDS[index] 

1646 try: 

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

1648 except Exception: 

1649 return 

1650 self.set_focus(None) 

1651 if tabs.active != target: 

1652 tabs.active = target 

1653 self._active_tab_id_cache = target 

1654 

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

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

1657 

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

1659 either direction without hitting an end stop. 

1660 """ 

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

1662 

1663 if self._search_focused: 

1664 return 

1665 try: 

1666 current = ALL_TAB_IDS.index(self._active_tab_id_cache) 

1667 except ValueError: 

1668 current = 0 

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

1670 self.action_select_tab(next_index) 

1671 

1672 def action_cycle_source(self) -> None: 

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

1674 

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

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

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

1678 """ 

1679 if self._search_focused: 

1680 return 

1681 active = self._active_tab_id_cache 

1682 if active not in TASK_TAB_IDS: 

1683 return 

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

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

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

1687 self._grid_cache_keys.pop(active, None) 

1688 self._list_cache_keys.pop(active, None) 

1689 self._refresh_view() 

1690 

1691 def action_toggle_drawer(self) -> None: 

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

1693 

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

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

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

1697 """ 

1698 try: 

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

1700 except Exception: 

1701 return 

1702 drawer.toggle_class("-collapsed") 

1703 

1704 def _reveal_scroll_hint_at_catalog_end(self) -> None: 

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

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

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

1708 

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

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

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

1712 

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

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

1715 """ 

1716 if self._active_task() is None: 

1717 return 

1718 focused = self._focused_grid() 

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

1720 return 

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

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

1723 return 

1724 cols = max(1, focused.columns_per_row) 

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

1726 if focused.highlighted // cols < last_row: 

1727 return 

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

1729 

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

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

1732 try: 

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

1734 except NoMatches: 

1735 return [] 

1736 

1737 @staticmethod 

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

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

1740 

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

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

1743 """ 

1744 if from_above: 

1745 grid.highlight_first() 

1746 else: 

1747 grid.highlight_last() 

1748 grid.focus() 

1749 

1750 @on(GridSelect.LeaveDown) 

1751 @on(ModelGrid.LeaveDown) 

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

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

1754 

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

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

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

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

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

1760 """ 

1761 if isinstance(event, ModelGrid.LeaveDown): 

1762 grids = self._pane_grids_with_rows() 

1763 try: 

1764 index = grids.index(event.grid) 

1765 except ValueError: 

1766 return 

1767 if index + 1 < len(grids): 

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

1769 return 

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

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

1772 self._load_more() 

1773 return 

1774 self.focus_next() 

1775 

1776 @on(GridSelect.LeaveUp) 

1777 @on(ModelGrid.LeaveUp) 

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

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

1780 

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

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

1783 """ 

1784 if isinstance(event, ModelGrid.LeaveUp): 

1785 grids = self._pane_grids_with_rows() 

1786 try: 

1787 index = grids.index(event.grid) 

1788 except ValueError: 

1789 return 

1790 if index > 0: 

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

1792 return 

1793 self.focus_previous() 

1794 

1795 @on(GridSelect.Selected) 

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

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

1798 widget = event.widget 

1799 if isinstance(widget, ModelCard): 

1800 self._select_row(widget.row) 

1801 

1802 @on(ModelGrid.Selected) 

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

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

1805 self._select_row(event.row) 

1806 

1807 @on(ModelList.Selected) 

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

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

1810 self._select_row(event.row) 

1811 

1812 def _refresh_list(self) -> None: 

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

1814 if not self._list_mounted(): 

1815 return 

1816 active_tab = self._active_tab_id_cache 

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

1818 if active_tab in TASK_TAB_IDS: 

1819 active_task = TAB_ID_TO_TASK[active_tab] 

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

1821 else: 

1822 self._rows = list(all_rows) 

1823 search = self._get_search_text() 

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

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

1826 list_key = ( 

1827 self._data_version, 

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

1829 search, 

1830 ) 

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

1832 self._update_sort_label() 

1833 return 

1834 self._list_cache_keys[active_tab] = list_key 

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

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

1837 self._update_sort_label() 

1838 

1839 def _filter_list(self) -> None: 

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

1841 search = self._get_search_text() 

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

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

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

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

1846 self._list_cache_keys[self._active_tab_id_cache] = ( 

1847 self._data_version, 

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

1849 search, 

1850 ) 

1851 self._update_sort_label() 

1852 

1853 def _sync_loading_spinner(self) -> None: 

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

1855 

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

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

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

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

1860 """ 

1861 try: 

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

1863 except Exception: 

1864 return 

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

1866 if active: 

1867 spinner.styles.display = "block" 

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

1869 if self._spinner_timer is None: 

1870 self._spinner_timer = self.set_interval( 

1871 _SPINNER_INTERVAL_S, self._tick_loading_spinner 

1872 ) 

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

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

1875 # same place mouse scroll surfaces it. 

1876 if self._loading_more: 

1877 with contextlib.suppress(Exception): 

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

1879 hint.update( 

1880 msg.CATALOG_GRID_LOADING_MORE.format( 

1881 frame=SPINNER_FRAMES[self._spinner_frame] 

1882 ) 

1883 ) 

1884 else: 

1885 spinner.update("") 

1886 spinner.styles.display = "none" 

1887 if self._spinner_timer is not None: 

1888 self._spinner_timer.stop() 

1889 self._spinner_timer = None 

1890 self._spinner_frame = 0 

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

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

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

1894 active_task = self._active_task() 

1895 hf_count = ( 

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

1897 if active_task is not None 

1898 else len(hf_rows) 

1899 ) 

1900 self._refresh_grid_ctas(hf_count=hf_count) 

1901 

1902 def _tick_loading_spinner(self) -> None: 

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

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

1905 with contextlib.suppress(Exception): 

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

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

1908 if self._loading_more: 

1909 with contextlib.suppress(Exception): 

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

1911 hint.update( 

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

1913 ) 

1914 

1915 def _update_sort_label(self) -> None: 

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

1917 

1918 Wrapped in NoMatches suppression because the worker callbacks that 

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

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

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

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

1923 """ 

1924 try: 

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

1926 except NoMatches: 

1927 return 

1928 if self._active_tab_id() == TAB_LIBRARY: 

1929 label.update(self._frontier_label_text()) 

1930 return 

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

1932 n_total = len(self._rows) 

1933 if self._loading_more: 

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

1935 elif self._active_task_has_more(): 

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

1937 else: 

1938 count = f"{n_total} models" 

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

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

1941 

1942 def _frontier_label_text(self) -> str: 

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

1944 return msg.CATALOG_FRONTIER_SUMMARY.format( 

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

1946 ) 

1947 

1948 def action_cycle_sort(self) -> None: 

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

1950 if self._search_focused: 

1951 return 

1952 if self._active_tab_id() not in TASK_TAB_IDS: 

1953 return 

1954 if self._grid_view: 

1955 self.notify(msg.CATALOG_SORT_LIST_ONLY) 

1956 return 

1957 try: 

1958 idx = _SORT_CYCLE.index(self._sort_column) 

1959 except ValueError: 

1960 idx = -1 

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

1962 self._sort_ascending = True 

1963 self._refresh_list() 

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

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

1966 self.call_after_refresh(self._focus_list_item, 0) 

1967 

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

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

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

1971 self._select_frontier_row(row) 

1972 return 

1973 if row.variant and row.family: 

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

1975 elif row.catalog_model: 

1976 self._install_model(row.catalog_model) 

1977 elif row.remote_model: 

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

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

1980 

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

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

1983 if row.key_status == KeyStatus.READY: 

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

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

1986 return 

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

1988 self.notify( 

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

1990 severity="warning", 

1991 timeout=10, 

1992 ) 

1993 self.app.switch_view("Settings") 

1994 

1995 def _load_more(self) -> None: 

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

1997 

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

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

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

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

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

2003 """ 

2004 if self._loading_more: 

2005 return 

2006 if self._get_search_text(): 

2007 self._load_more_search_results() 

2008 return 

2009 task = self._active_task() 

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

2011 return 

2012 self._loading_more = True 

2013 self._sync_loading_spinner() 

2014 self._hf_offset_by_task[task] += _HF_PAGE_SIZE 

2015 self._fetch_more_hf_for_task(task) 

2016 

2017 def _load_more_search_results(self) -> None: 

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

2019 

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

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

2022 """ 

2023 query = self._get_search_text() 

2024 if self._search_in_flight or not self._search_has_more: 

2025 return 

2026 if query != self._searched_query: 

2027 return 

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

2029 if active_task is None: 

2030 return 

2031 self._search_offset += _HF_SEARCH_LIMIT 

2032 self._search_in_flight = True 

2033 self._sync_loading_spinner() 

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

2035 

2036 def action_load_more(self) -> None: 

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

2038 if self._active_tab_id() not in TASK_TAB_IDS: 

2039 return 

2040 self._load_more() 

2041 

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

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

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

2045 

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

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

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

2049 """ 

2050 new_tab = event.pane.id or TAB_CHAT 

2051 if not self._activation_settled: 

2052 return 

2053 self._active_tab_id_cache = new_tab 

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

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

2056 # may be detached. _grid_for_tab/_list_for_tab validate via 

2057 # is_running and refetch as needed. 

2058 self._update_sort_label() 

2059 if new_tab == TAB_LIBRARY: 

2060 self._populate_library_list() 

2061 elif new_tab == TAB_DISCOVER: 

2062 self._populate_discover_rails() 

2063 elif new_tab in TASK_TAB_IDS: 

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

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

2066 # after, so re-activations stay free. 

2067 self._ensure_task_initial_fetch(TAB_ID_TO_TASK[new_tab]) 

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

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

2070 self._refresh_view() 

2071 

2072 def _populate_discover_rails(self) -> None: 

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

2074 

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

2076 engine can load and the machine can hold, so every card is a 

2077 one-click install rather than a coin flip. 

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

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

2080 single rail-friendly slice. 

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

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

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

2084 """ 

2085 try: 

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

2087 except Exception: 

2088 return 

2089 family_rows = self._all_family_rows() 

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

2091 remote_rows = self._all_remote_rows() 

2092 for_you = for_you_by_role(family_rows + hf_rows) 

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

2094 fresh = sorted( 

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

2096 key=lambda r: -r.sort_downloads, 

2097 )[:6] 

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

2099 

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

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

2102 entry = CatalogModel( 

2103 hf_repo=variant.hf_repo, 

2104 gguf_filename=variant.filename, 

2105 size_gb=variant.size_mb / 1024, 

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

2107 description=family.description, 

2108 featured=True, 

2109 downloads=0, 

2110 task=family.task, 

2111 ) 

2112 self._install_model(entry) 

2113 

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

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

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

2117 return 

2118 try: 

2119 filename = resolve_filename(model) 

2120 dest = cfg.models_dir / filename 

2121 if dest.exists(): 

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

2123 return 

2124 except Exception: 

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

2126 

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

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

2129 # not stop a second row. 

2130 shortfall = disk_shortfall( 

2131 cfg.models_dir, model.hf_repo, int(model.size_gb * _BYTES_PER_GB) 

2132 ) 

2133 if shortfall is not None: 

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

2135 return 

2136 

2137 self._enqueue_download(model) 

2138 

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

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

2141 with contextlib.suppress(NoMatches): 

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

2143 

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

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

2146 

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

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

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

2150 confirm with a modal before enqueuing; the modal returns True to 

2151 proceed with ``allow_unsupported=True`` or False to cancel. 

2152 """ 

2153 

2154 def _adopt() -> None: 

2155 self.app.call_from_thread(self._adopt_first_download, model) 

2156 

2157 if model.compat is ModelCompat.UNSUPPORTED: 

2158 

2159 def _after_confirm(verdict: bool | None) -> None: 

2160 if not verdict: 

2161 return 

2162 self.app.task_bar.start_download(model, allow_unsupported=True, on_success=_adopt) 

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

2164 

2165 self.app.push_screen( 

2166 ConfirmDialog( 

2167 msg.COMPAT_MODAL_TITLE, 

2168 msg.COMPAT_MODAL_BODY.format(arch=model.architecture or "unknown"), 

2169 ), 

2170 _after_confirm, 

2171 ) 

2172 return 

2173 

2174 self.app.task_bar.start_download(model, on_success=_adopt) 

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

2176 

2177 def _adopt_first_download(self, model: CatalogModel) -> None: 

2178 """Make the first model of an unconfigured role the active one. 

2179 

2180 A role that already has a model keeps it: a later download never 

2181 steals the assignment. Chat announces itself, since the user's next 

2182 step (start chatting) is on another screen. 

2183 """ 

2184 field = _model_field_for_task(model.task) 

2185 if getattr(cfg, field): 

2186 return 

2187 apply_active_model(self.app, field, model.ref) 

2188 if field == "chat_model": 

2189 self.app.notify(msg.CHAT_READY_TOAST) 

2190 

2191 def action_go_back(self) -> None: 

2192 # An open filter collapses to hidden (restoring grid/list focus); 

2193 # otherwise q / Esc returns to the view the user came from. 

2194 if self._filter_open: 

2195 self._search_input.value = "" 

2196 self._search_input.add_class(_HIDDEN_CLASS) 

2197 self._focus_list_or_grid() 

2198 return 

2199 self.app.go_back() 

2200 

2201 def _focus_list_or_grid(self) -> None: 

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

2203 if self._grid_view: 

2204 self._focus_first_grid() 

2205 else: 

2206 self._focus_list_item(0) 

2207 

2208 def action_show_info(self) -> None: 

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

2210 if self._search_focused: 

2211 return 

2212 row = self._highlighted_row() 

2213 if row is None: 

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

2215 return 

2216 if row.kind != CatalogRowKind.LOCAL: 

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

2218 return 

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

2220 

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

2222 

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

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

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

2226 return self._list_widget.highlighted_row() 

2227 focused_grid = self._focused_grid() 

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

2229 return None 

2230 if isinstance(focused_grid, ModelGrid): 

2231 rows = focused_grid.rows 

2232 index = focused_grid.highlighted 

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

2234 child = focused_grid.children[focused_grid.highlighted] 

2235 if isinstance(child, ModelCard): 

2236 return child.row 

2237 return None 

2238 

2239 def action_delete_model(self) -> None: 

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

2241 if self._search_focused: 

2242 return 

2243 model_name = self._get_highlighted_model_name() 

2244 if model_name is None: 

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

2246 return 

2247 

2248 if not self._row_is_installed(model_name): 

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

2250 return 

2251 

2252 if self._pending_delete == model_name: 

2253 self._pending_delete = None 

2254 self._run_delete(model_name) 

2255 else: 

2256 self._pending_delete = model_name 

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

2258 

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

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

2261 

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

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

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

2265 manager directly. 

2266 """ 

2267 if model_name in self._installed_names: 

2268 return True 

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

2270 

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

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

2273 

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

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

2276 resolve to the lexicographically-first matching installed 

2277 manifest; full refs and remote names pass through. 

2278 """ 

2279 if "/" in identity and identity.endswith(".gguf"): 

2280 return identity 

2281 prefix = identity + "/" 

2282 matches = sorted(n for n in self._installed_names if n.startswith(prefix)) 

2283 if matches: 

2284 return matches[0] 

2285 return identity 

2286 

2287 def _get_highlighted_model_name(self) -> str | None: 

2288 """Return the registry-compatible model ref for the focused/highlighted row.""" 

2289 if not self._grid_view and self._list_widget.has_focus: 

2290 row = self._list_widget.highlighted_row() 

2291 return row_delete_id(row) if row else None 

2292 focused_grid = self._focused_grid() 

2293 if focused_grid is None or focused_grid.highlighted is None: 

2294 return None 

2295 if isinstance(focused_grid, ModelGrid): 

2296 rows = focused_grid.rows 

2297 index = focused_grid.highlighted 

2298 if 0 <= index < len(rows): 

2299 return row_delete_id(rows[index]) 

2300 return None 

2301 # GridSelect path: cards are direct children indexed positionally. 

2302 child = focused_grid.children[focused_grid.highlighted] 

2303 if isinstance(child, ModelCard): 

2304 return row_delete_id(child.row) 

2305 return None 

2306 

2307 @work(thread=True) 

2308 def _run_delete(self, model_name: str) -> None: 

2309 """Remove a model in a background thread.""" 

2310 delete_ref = self._resolve_delete_ref(model_name) 

2311 try: 

2312 removed = get_services().model_manager.remove(delete_ref) 

2313 if removed: 

2314 call_from_thread(self, self.notify, msg.CATALOG_DELETED.format(name=model_name)) 

2315 call_from_thread(self, self._refresh_after_delete) 

2316 else: 

2317 call_from_thread( 

2318 self, 

2319 self.notify, 

2320 msg.CATALOG_DELETE_FAILED.format(error=model_name), 

2321 severity="error", 

2322 ) 

2323 except Exception as exc: 

2324 log.warning("Delete failed for %s", model_name, exc_info=True) 

2325 call_from_thread( 

2326 self, 

2327 self.notify, 

2328 msg.CATALOG_DELETE_FAILED.format(error=exc), 

2329 severity="error", 

2330 ) 

2331 

2332 def _refresh_after_delete(self) -> None: 

2333 """Re-fetch remote models and refresh after deletion.""" 

2334 self._fetch_installed_names() 

2335 self._refresh_view() 

2336 self._fetch_remote_models() 

2337 

2338 def _focused_grid(self) -> ModelGrid | GridSelect | None: 

2339 """Return the focused grid widget (grid view), else None.""" 

2340 if self._grid_view and isinstance(self.focused, (ModelGrid, GridSelect)): 

2341 return self.focused 

2342 return None 

2343 

2344 def _list_count(self) -> int: 

2345 """Total options currently shown in the list view (excluding headings).""" 

2346 return self._list_widget.row_count 

2347 

2348 def _focus_list_item(self, index: int) -> None: 

2349 """Highlight the row at *index*, clamped to the visible range.""" 

2350 count = self._list_widget.option_count 

2351 if not count: 

2352 return 

2353 clamped = max(0, min(index, count - 1)) 

2354 self._list_widget.highlighted = clamped 

2355 # Same invariant as _mount_remaining_grid_sections: this is reached from 

2356 # call_after_refresh, so it can run after the user has opened the filter 

2357 # with `/`, and taking the cursor back would strand them mid-keystroke. 

2358 if self._search_focused: 

2359 return 

2360 self.set_focus(self._list_widget) 

2361 

2362 def _focused_list_index(self) -> int | None: 

2363 """Index of the highlighted list row, or None when nothing is highlighted.""" 

2364 return self._list_widget.highlighted 

2365 

2366 def _nudge_list(self, delta: int) -> None: 

2367 idx = self._focused_list_index() 

2368 if idx is None: 

2369 self._focus_list_item(0) 

2370 return 

2371 self._focus_list_item(idx + delta) 

2372 self._maybe_prefetch_on_nav() 

2373 

2374 def _maybe_prefetch_on_nav(self) -> None: 

2375 if self._grid_view or not self._active_task_has_more() or self._loading_more: 

2376 return 

2377 idx = self._focused_list_index() 

2378 if idx is None: 

2379 return 

2380 if idx >= self._list_widget.option_count - _HF_LOAD_MORE_TRIGGER: 

2381 self._load_more() 

2382 

2383 def _maybe_prefetch_on_grid_nav(self) -> None: 

2384 """Fire ``_load_more`` when the keyboard cursor lands within the last 

2385 rows of the catalog. Mouse wheel triggers via ``_on_grid_scrolled`` at 

2386 the 85 % scroll threshold, but cell-by-cell keyboard nav advances 

2387 scroll_y too gradually to ever cross that threshold; this check 

2388 guarantees keyboard reaches the same prefetch trigger. 

2389 """ 

2390 if not self._grid_view or not self._active_task_has_more() or self._loading_more: 

2391 return 

2392 grids = list(self._grid_container.query(ModelGrid)) 

2393 if not grids: 

2394 return 

2395 focused = self._focused_grid() 

2396 if not isinstance(focused, ModelGrid) or focused.highlighted is None: 

2397 return 

2398 # Absolute cursor position = cards in earlier grids + cursor in this grid. 

2399 try: 

2400 grid_index = grids.index(focused) 

2401 except ValueError: 

2402 return 

2403 cards_before = sum(len(g.rows) for g in grids[:grid_index]) 

2404 absolute = cards_before + focused.highlighted 

2405 total = sum(len(g.rows) for g in grids) 

2406 if total <= 0: 

2407 return 

2408 if absolute >= total - _HF_LOAD_MORE_TRIGGER: 

2409 self._load_more() 

2410 

2411 _SCROLL_PREFETCH_RATIO = 0.85 

2412 _SCROLL_PREFETCH_COOLDOWN = 0.8 

2413 

2414 def _on_list_scrolled(self, _scroll_y: float) -> None: 

2415 """Trigger _load_more when the user scrolls near the bottom of the list.""" 

2416 if not self._scroll_prefetch_due(self._list_widget): 

2417 return 

2418 self._scroll_prefetch_armed_at = time.monotonic() 

2419 self._load_more() 

2420 

2421 def _on_grid_scrolled(self, _scroll_y: float) -> None: 

2422 """Trigger _load_more when the user scrolls near the bottom of the grid.""" 

2423 if not self._grid_view: 

2424 return 

2425 if not self._scroll_prefetch_due(self._grid_container): 

2426 return 

2427 self._scroll_prefetch_armed_at = time.monotonic() 

2428 self._load_more() 

2429 

2430 def on_mouse_scroll_down(self, event: MouseScrollDown) -> None: 

2431 """Force pagination when wheeling beyond what the active scroll can scroll. 

2432 

2433 Three collapsed triggers, both views: (1) content already fits the 

2434 viewport so ``max_scroll_y == 0`` and wheel events produce no scroll 

2435 delta, (2) the user has wheeled to ``scroll_y == max_scroll_y`` and 

2436 further wheels produce no delta, (3) list view has the same problem 

2437 as grid view -- the scroll watcher only fires on scroll_y changes, 

2438 so a wheel at max_y is invisible to ``_on_list_scrolled`` / 

2439 ``_on_grid_scrolled``. Re-check here and fetch the next page 

2440 directly. Cooldown prevents a cascade as new rows shift max_scroll_y. 

2441 """ 

2442 if not self._active_task_has_more() or self._loading_more: 

2443 return 

2444 container = self._grid_container if self._grid_view else self._list_widget 

2445 max_y = container.max_scroll_y 

2446 if max_y > 0 and container.scroll_y < max_y: 

2447 return 

2448 if self._scroll_prefetch_armed_at: 

2449 elapsed = time.monotonic() - self._scroll_prefetch_armed_at 

2450 if elapsed < self._SCROLL_PREFETCH_COOLDOWN: 

2451 return 

2452 self._scroll_prefetch_armed_at = time.monotonic() 

2453 self._load_more() 

2454 

2455 def _scroll_prefetch_due(self, widget: VerticalScroll | ModelList) -> bool: 

2456 # Cooldown blocks a runaway cascade where appending rows shifts 

2457 # max_scroll_y, the watcher refires, and load_more kicks off the 

2458 # next fetch before the user notices. 

2459 if not self._active_task_has_more() or self._loading_more: 

2460 return False 

2461 if self._scroll_prefetch_armed_at: 

2462 elapsed = time.monotonic() - self._scroll_prefetch_armed_at 

2463 if elapsed < self._SCROLL_PREFETCH_COOLDOWN: 

2464 return False 

2465 max_y = widget.max_scroll_y 

2466 if max_y <= 0: 

2467 return False 

2468 return widget.scroll_y / max_y >= self._SCROLL_PREFETCH_RATIO 

2469 

2470 def _page_rows(self) -> int: 

2471 """How many cursor steps make up one 'page' in the active view.""" 

2472 return _GRID_PAGE_ROWS if self._grid_view else _LIST_PAGE_ROWS 

2473 

2474 def action_page_down(self) -> None: 

2475 if self._search_focused: 

2476 return 

2477 if self._grid_view: 

2478 if (grid := self._focused_grid()) is not None: 

2479 for _ in range(self._page_rows()): 

2480 grid.action_cursor_down() 

2481 else: 

2482 self._nudge_list(self._page_rows()) 

2483 

2484 def action_page_up(self) -> None: 

2485 if self._search_focused: 

2486 return 

2487 if self._grid_view: 

2488 if (grid := self._focused_grid()) is not None: 

2489 for _ in range(self._page_rows()): 

2490 grid.action_cursor_up() 

2491 else: 

2492 self._nudge_list(-self._page_rows()) 

2493 

2494 def action_cursor_down(self) -> None: 

2495 if self._search_focused: 

2496 return 

2497 if self._grid_view: 

2498 grid = self._focused_grid() or self._first_grid_or_none() 

2499 if grid is not None: 

2500 grid.focus() 

2501 grid.action_cursor_down() 

2502 else: 

2503 self._nudge_list(1) 

2504 

2505 def action_cursor_up(self) -> None: 

2506 if self._search_focused: 

2507 return 

2508 if self._grid_view: 

2509 grid = self._focused_grid() or self._first_grid_or_none() 

2510 if grid is not None: 

2511 grid.focus() 

2512 grid.action_cursor_up() 

2513 else: 

2514 self._nudge_list(-1) 

2515 

2516 def _first_grid_or_none(self) -> ModelGrid | None: 

2517 """Return the first non-empty ModelGrid in the active tab's container.""" 

2518 grids = self._pane_grids_with_rows() 

2519 return grids[0] if grids else None 

2520 

2521 def action_jump_top(self) -> None: 

2522 if self._search_focused: 

2523 return 

2524 if self._grid_view: 

2525 if (grid := self._focused_grid()) is not None: 

2526 grid.highlight_first() 

2527 else: 

2528 self._focus_list_item(0) 

2529 

2530 def action_jump_bottom(self) -> None: 

2531 if self._search_focused: 

2532 return 

2533 if self._grid_view: 

2534 if (grid := self._focused_grid()) is not None: 

2535 grid.highlight_last() 

2536 else: 

2537 count = self._list_widget.option_count 

2538 if count: 

2539 self._focus_list_item(count - 1) 

2540 self._maybe_prefetch_on_nav()