Coverage for src / lilbee / cli / tui / widgets / model_bar.py: 100%

331 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-06-28 01:01 +0000

1"""Model bar: a horizontal band of the four role pickers plus the Search/Chat toggle.""" 

2 

3from __future__ import annotations 

4 

5import contextlib 

6import logging 

7from pathlib import Path 

8from typing import TYPE_CHECKING, ClassVar, NamedTuple 

9 

10if TYPE_CHECKING: 

11 from lilbee.cli.tui.app import LilbeeApp 

12 from lilbee.cli.tui.screens.model_picker import PickerScope 

13 from lilbee.modelhub.registry import ModelRegistry 

14 

15from textual import events, work 

16from textual.app import ComposeResult 

17from textual.binding import Binding, BindingType 

18from textual.containers import Horizontal 

19from textual.widget import Widget 

20from textual.widgets import Static 

21 

22from lilbee.app.services import get_services, reset_services 

23from lilbee.catalog import clean_display_name, display_label_for_ref, extract_quant 

24from lilbee.catalog.types import ModelTask 

25from lilbee.cli.tui import messages as msg 

26from lilbee.cli.tui.app import apply_setting 

27from lilbee.cli.tui.pill import pill 

28from lilbee.cli.tui.screens.settings_widgets import model_field_to_picker_scope 

29from lilbee.cli.tui.thread_safe import call_from_thread 

30from lilbee.cli.tui.widgets.model_pick import apply_model_pick, config_key_for_scope 

31from lilbee.core.config import cfg 

32from lilbee.core.config.enums import ChatMode 

33from lilbee.providers.model_ref import format_remote_ref, parse_model_ref 

34from lilbee.providers.sdk_backend import PROVIDER_KEYS 

35from lilbee.retrieval.embedder import is_model_available 

36 

37log = logging.getLogger(__name__) 

38 

39_MMPROJ_MARKER = "mmproj" 

40 

41# Routing-name -> display-label map derived from PROVIDER_KEYS. Any new 

42# entry added there lights up the warning without further changes here. 

43_CLOUD_PROVIDER_LABELS: dict[str, str] = {name: label for name, _, _, label in PROVIDER_KEYS} 

44 

45 

46def _cloud_provider_label(chat_model: str) -> str | None: 

47 """Return the provider display label for cloud-routed models, else None.""" 

48 if not chat_model: 

49 return None 

50 ref = parse_model_ref(chat_model) 

51 if not ref.is_api: 

52 return None 

53 return _CLOUD_PROVIDER_LABELS.get(ref.provider) 

54 

55 

56class ModelOption(NamedTuple): 

57 """A selectable model with display label and config ref.""" 

58 

59 label: str # human-readable name for the dropdown 

60 ref: str # canonical ref persisted to config 

61 

62 

63def _is_mmproj(name: str) -> bool: 

64 """Return True if a model name refers to an mmproj projection file.""" 

65 return _MMPROJ_MARKER in name.lower() 

66 

67 

68def classify_installed_models_full() -> dict[ModelTask, list[ModelOption]]: 

69 """Classify installed models into per-task lists, dropping mmproj entries.""" 

70 buckets: dict[ModelTask, list[ModelOption]] = {task: [] for task in ModelTask} 

71 seen: set[str] = set() 

72 

73 _collect_native_models(buckets, seen) 

74 _collect_remote_models(buckets, seen) 

75 _collect_api_models(buckets, seen) 

76 

77 return {task: sorted(opts, key=lambda o: o.ref) for task, opts in buckets.items()} 

78 

79 

80def _lookup_bucket( 

81 buckets: dict[ModelTask, list[ModelOption]], task: str, ref: str 

82) -> list[ModelOption] | None: 

83 """Return the bucket for *task*, or None if it is not a known ModelTask.""" 

84 try: 

85 key = ModelTask(task) 

86 except ValueError: 

87 log.debug("dropping %r with unknown task %r", ref, task) 

88 return None 

89 return buckets.get(key) 

90 

91 

92def _native_label(hf_repo: str, gguf_filename: str, repo_count: int) -> str: 

93 """Build the picker label, appending the quant suffix only on collision.""" 

94 base = clean_display_name(hf_repo) 

95 if repo_count <= 1: 

96 return base 

97 quant = extract_quant(gguf_filename) 

98 return f"{base} ({quant})" if quant else base 

99 

100 

101def _has_vision_sidecar(registry: ModelRegistry, ref: str) -> bool: 

102 """Return True if *ref* resolves to a model with an adjacent ``*mmproj*.gguf`` file. 

103 

104 Models like ``google/gemma-3-12b-it`` carry their vision capability in 

105 a sibling ``mmproj`` GGUF; without checking the file system, the 

106 ref's name alone gives no signal that the model is multimodal, so the 

107 vision picker would silently miss it. 

108 """ 

109 try: 

110 path = registry.resolve(ref) 

111 except (KeyError, ValueError): 

112 return False 

113 return any(path.parent.glob("*mmproj*.gguf")) 

114 

115 

116def _collect_native_models(buckets: dict[ModelTask, list[ModelOption]], seen: set[str]) -> None: 

117 """Add native registry models to buckets.""" 

118 try: 

119 from lilbee.modelhub.registry import ModelRegistry 

120 

121 registry = ModelRegistry(cfg.models_dir) 

122 manifests = registry.list_installed() 

123 repo_counts: dict[str, int] = {} 

124 for m in manifests: 

125 repo_counts[m.hf_repo] = repo_counts.get(m.hf_repo, 0) + 1 

126 

127 from lilbee.modelhub.model_manager.discovery import reclassify_by_name 

128 

129 for manifest in manifests: 

130 ref = manifest.ref 

131 if _is_mmproj(manifest.gguf_filename) or ref in seen: 

132 continue 

133 task = reclassify_by_name(ref, manifest.task) 

134 label = _native_label( 

135 manifest.hf_repo, manifest.gguf_filename, repo_counts[manifest.hf_repo] 

136 ) 

137 primary_bucket = _lookup_bucket(buckets, task, ref) 

138 if primary_bucket is None: 

139 continue 

140 seen.add(ref) 

141 primary_bucket.append(ModelOption(label=label, ref=ref)) 

142 # If the model has an mmproj sidecar it is also vision-capable. 

143 # Surface it under the vision picker too without dropping its 

144 # primary classification, so a chat model with vision (e.g. 

145 # gemma-3 with mmproj) shows up in both pickers. 

146 if task != ModelTask.VISION and _has_vision_sidecar(registry, ref): 

147 buckets[ModelTask.VISION].append(ModelOption(label=label, ref=ref)) 

148 except Exception: 

149 log.debug("Could not read native model registry", exc_info=True) 

150 

151 

152def _collect_remote_models(buckets: dict[ModelTask, list[ModelOption]], seen: set[str]) -> None: 

153 """Add remote (Ollama / OpenAI-compatible) models, prefixed for routing. 

154 

155 Skipped when the litellm extra is not installed -- surfacing a model 

156 the SDK cannot route is a guaranteed runtime error. 

157 """ 

158 from lilbee.providers.litellm_sdk import litellm_available 

159 

160 if not litellm_available(): 

161 return 

162 try: 

163 from lilbee.modelhub.model_manager import classify_all_remote_models 

164 

165 for model in classify_all_remote_models(): 

166 # Skip backend rows with a blank model name so the picker 

167 # doesn't render an empty " (Ollama)" row. 

168 if not model.name.strip(): 

169 continue 

170 ref = format_remote_ref(model.name, model.provider) 

171 if ref in seen or _is_mmproj(model.name): 

172 continue 

173 bucket = _lookup_bucket(buckets, model.task, ref) 

174 if bucket is None: 

175 continue 

176 seen.add(ref) 

177 label = f"{model.name} ({model.provider})" 

178 bucket.append(ModelOption(label=label, ref=ref)) 

179 except Exception: 

180 log.debug("Could not classify remote models", exc_info=True) 

181 

182 

183def _collect_api_models(buckets: dict[ModelTask, list[ModelOption]], seen: set[str]) -> None: 

184 """Add frontier API chat models. Skipped without litellm (cannot route).""" 

185 from lilbee.providers.litellm_sdk import litellm_available 

186 

187 if not litellm_available(): 

188 return 

189 try: 

190 from lilbee.modelhub.model_manager import discover_api_models 

191 

192 # API discovery returns only chat-capable refs; revisit if providers 

193 # expose embedding/vision/rerank. 

194 for display_name, models in discover_api_models().items(): 

195 for model in models: 

196 qualified = format_remote_ref(model.name, model.provider) 

197 if qualified in seen: 

198 continue 

199 seen.add(qualified) 

200 label = f"{model.name} ({display_name})" 

201 buckets[ModelTask.CHAT].append(ModelOption(label=label, ref=qualified)) 

202 except Exception: 

203 log.debug("Could not discover API models", exc_info=True) 

204 

205 

206_CHAT_MODE_TOGGLE_ID = "chat-mode-toggle" 

207_CHAT_MODE_SEARCH_PILL_ID = "chat-mode-search" 

208_CHAT_MODE_CHAT_PILL_ID = "chat-mode-chat" 

209_CHAT_MODE_PILL_CLASS = "chat-mode-pill" 

210_CHAT_MODE_DISABLED_CLASS = "-disabled" 

211_CHAT_MODE_ACTIVE_CLASS = "-active" 

212 

213 

214_SCOPE_TO_TOOLTIP: dict[str, str] = { 

215 "chat": msg.MODEL_PICKER_CHAT_TOOLTIP, 

216 "embed": msg.MODEL_PICKER_EMBED_TOOLTIP, 

217 "vision": msg.MODEL_PICKER_VISION_TOOLTIP, 

218 "rerank": msg.MODEL_PICKER_RERANK_TOOLTIP, 

219} 

220 

221_CSS_FILE = Path(__file__).parent / "model_bar.tcss" 

222 

223_CLOUD_WARNING_ID = "model-bar-cloud-warning" 

224 

225_SCOPE_TO_LABEL: dict[str, str] = { 

226 "chat": msg.MODEL_BAR_CHAT_LABEL, 

227 "embed": msg.MODEL_BAR_EMBED_LABEL, 

228 "vision": msg.MODEL_BAR_VISION_LABEL, 

229 "rerank": msg.MODEL_BAR_RERANK_LABEL, 

230} 

231 

232# Per-role pill colors (background, foreground) when the role is active. Chat and 

233# Embed mirror the original bar; Vision and Rerank get their own accent hues. 

234_SCOPE_PILL_COLORS: dict[str, tuple[str, str]] = { 

235 "chat": ("$primary", "$text"), 

236 "embed": ("$secondary", "$text"), 

237 "vision": ("#bc8cff", "$text"), 

238 "rerank": ("#f0883e", "$text"), 

239} 

240 

241# Muted pill for an optional role that is currently off. 

242_OFF_PILL_COLORS: tuple[str, str] = ("$surface-lighten-2", "$text-muted") 

243 

244 

245class ModelPickerButton(Static, can_focus=True): 

246 """Pill button that opens a ModelPickerModal scoped to one of the four roles.""" 

247 

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

249 Binding("enter", "open_picker", "Pick model", show=False), 

250 Binding("space", "open_picker", "Pick model", show=False), 

251 ] 

252 

253 def __init__(self, *, scope: PickerScope, button_id: str) -> None: 

254 super().__init__(id=button_id) 

255 self._scope: PickerScope = scope 

256 self._key: str = config_key_for_scope(scope) 

257 self._options: list[ModelOption] = [] 

258 self.tooltip = _SCOPE_TO_TOOLTIP[scope] 

259 

260 def on_mount(self) -> None: 

261 self._refresh() 

262 

263 def set_options(self, options: list[ModelOption]) -> None: 

264 """Update the options pool. Repaints the label from cfg.""" 

265 self._options = options 

266 if self.is_mounted: 

267 self._refresh() 

268 

269 def _refresh(self) -> None: 

270 # Only optional roles (vision/rerank) can be empty; chat/embed are 

271 # non-nullable, so an empty ref means the role is off, not a model 

272 # called "(none)". 

273 ref = getattr(cfg, self._key) 

274 label = (display_label_for_ref(ref) or ref) if ref else msg.MODEL_BAR_DISABLED 

275 self.update(label) 

276 

277 def repaint(self) -> None: 

278 """Public entry for a parent container to repaint the label from cfg.""" 

279 self._refresh() 

280 

281 def on_click(self, event: events.Click) -> None: 

282 event.stop() 

283 self.open_picker() 

284 

285 def action_open_picker(self) -> None: 

286 self.open_picker() 

287 

288 def _is_nullable(self) -> bool: 

289 from lilbee.app.settings_map import SETTINGS_MAP 

290 

291 defn = SETTINGS_MAP.get(self._key) 

292 return defn is not None and defn.nullable 

293 

294 def open_picker(self) -> None: 

295 # Lazy import: model_picker imports ModelOption from this module. 

296 from lilbee.cli.tui.screens.model_picker import ModelPickerModal 

297 

298 options = list(self._options) 

299 # Optional role that's on: offer an explicit "turn off" action in the 

300 # modal (ref "" disables it) so disabling isn't only a pill click. 

301 if self._is_nullable() and getattr(cfg, self._key): 

302 options.append(ModelOption(label=msg.MODEL_PICKER_TURN_OFF, ref="")) 

303 modal = ModelPickerModal(scope=self._scope, options=options) 

304 self.app.push_screen(modal, self._on_picker_dismissed) 

305 

306 def _on_picker_dismissed(self, ref: str | None) -> None: 

307 if ref is not None and ref == getattr(cfg, self._key): 

308 return 

309 # Chat swaps reset services in _commit_after_change -> apply_model_change, 

310 # so the helper must not also reload the chat worker (double teardown). 

311 apply_model_pick( 

312 self, 

313 key=self._key, 

314 ref=ref, 

315 on_done=self._commit_after_change, 

316 reload_worker=self._scope != "chat", 

317 ) 

318 

319 def _commit_after_change(self) -> None: 

320 """Repaint the label, then run the chat-screen side effect for chat swaps. 

321 

322 ``apply_model_pick`` already persisted the ref and (for non-chat 

323 scopes) reloaded the worker. Chat swaps cancel the in-flight stream 

324 and reset services here so the new chat model takes over cleanly. Works 

325 Works regardless of which container the button is mounted in. 

326 """ 

327 self._refresh() 

328 if self._scope != "chat": 

329 return 

330 from lilbee.cli.tui.screens.chat import ChatScreen 

331 

332 screen = self.app.screen 

333 if isinstance(screen, ChatScreen): 

334 screen.apply_model_change() 

335 else: 

336 reset_services() 

337 

338 

339class ChatModePill(Static, can_focus=True): 

340 """Single focusable mode pill; Enter / Space picks this pill's mode.""" 

341 

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

343 Binding("enter", "select", "Pick mode", show=False), 

344 Binding("space", "select", "Pick mode", show=False), 

345 ] 

346 

347 def action_select(self) -> None: 

348 toggle = next( 

349 (n for n in self.ancestors_with_self if isinstance(n, ChatModeToggle)), 

350 None, 

351 ) 

352 if toggle is None: 

353 return 

354 target = ( 

355 ChatMode.SEARCH.value if self.id == _CHAT_MODE_SEARCH_PILL_ID else ChatMode.CHAT.value 

356 ) 

357 toggle._set_mode(target) 

358 

359 

360class ChatModeToggle(Widget, can_focus=False): 

361 """Two-pill control toggling cfg.chat_mode between Search and Chat. 

362 

363 The toggle itself is not focusable; the inner pills are. Tab walks 

364 Search then Chat, Enter / Space picks. The container keeps left / 

365 right arrow handling so the legacy keyboard flow still works. 

366 """ 

367 

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

369 Binding("left", "select_search", "Search mode", show=False), 

370 Binding("right", "select_chat", "Chat mode", show=False), 

371 ] 

372 

373 def __init__(self) -> None: 

374 super().__init__(id=_CHAT_MODE_TOGGLE_ID) 

375 

376 def compose(self) -> ComposeResult: 

377 with Horizontal(): 

378 yield ChatModePill( 

379 msg.CHAT_MODE_SEARCH_LABEL, 

380 id=_CHAT_MODE_SEARCH_PILL_ID, 

381 classes=_CHAT_MODE_PILL_CLASS, 

382 ) 

383 yield ChatModePill( 

384 msg.CHAT_MODE_CHAT_LABEL, 

385 id=_CHAT_MODE_CHAT_PILL_ID, 

386 classes=_CHAT_MODE_PILL_CLASS, 

387 ) 

388 

389 def on_mount(self) -> None: 

390 self._refresh() 

391 

392 def refresh_state(self) -> None: 

393 """Repaint label/state. Call after settings or embedding-model changes.""" 

394 if self.is_mounted: 

395 self._refresh() 

396 

397 def _embedding_ready(self) -> bool: 

398 return is_model_available(cfg.embedding_model, get_services().provider) 

399 

400 def _refresh(self) -> None: 

401 ready = self._embedding_ready() 

402 mode = cfg.chat_mode if ready else ChatMode.CHAT.value 

403 active_search = mode == ChatMode.SEARCH.value 

404 search_pill = self.query_one(f"#{_CHAT_MODE_SEARCH_PILL_ID}", ChatModePill) 

405 chat_pill = self.query_one(f"#{_CHAT_MODE_CHAT_PILL_ID}", ChatModePill) 

406 # Search half is disabled whenever embedding isn't ready; Chat is 

407 # always reachable so it never carries the disabled class. 

408 search_pill.set_class(active_search, _CHAT_MODE_ACTIVE_CLASS) 

409 search_pill.set_class(not ready, _CHAT_MODE_DISABLED_CLASS) 

410 chat_pill.set_class(not active_search, _CHAT_MODE_ACTIVE_CLASS) 

411 chat_pill.set_class(False, _CHAT_MODE_DISABLED_CLASS) 

412 # Parent carries the disabled class so external selectors can 

413 # disable interaction on the whole toggle when search is gated. 

414 self.set_class(not ready, _CHAT_MODE_DISABLED_CLASS) 

415 self.tooltip = ( 

416 msg.CHAT_MODE_TOGGLE_DISABLED_TOOLTIP if not ready else msg.CHAT_MODE_TOGGLE_TOOLTIP 

417 ) 

418 

419 def _set_mode(self, target: str) -> bool: 

420 """Apply *target* if it differs from the current mode and Search is allowed.""" 

421 if cfg.chat_mode == target: 

422 return False 

423 if target == ChatMode.SEARCH.value and not self._embedding_ready(): 

424 return False 

425 apply_setting(self.app, "chat_mode", target) 

426 self._refresh() 

427 return True 

428 

429 def toggle(self) -> bool: 

430 """Flip mode if embedding is ready. Returns True when the mode changed.""" 

431 target = ( 

432 ChatMode.CHAT.value if cfg.chat_mode == ChatMode.SEARCH.value else ChatMode.SEARCH.value 

433 ) 

434 return self._set_mode(target) 

435 

436 def on_click(self, event: events.Click) -> None: 

437 event.stop() 

438 # Click on a specific pill picks that side; click on the container 

439 # frame falls through to a toggle. 

440 widget = event.widget 

441 if widget is not None: 

442 wid = widget.id 

443 if wid == _CHAT_MODE_SEARCH_PILL_ID: 

444 self._set_mode(ChatMode.SEARCH.value) 

445 return 

446 if wid == _CHAT_MODE_CHAT_PILL_ID: 

447 self._set_mode(ChatMode.CHAT.value) 

448 return 

449 self.toggle() 

450 

451 def action_flip_mode(self) -> None: 

452 self.toggle() 

453 

454 def action_select_search(self) -> None: 

455 self._set_mode(ChatMode.SEARCH.value) 

456 

457 def action_select_chat(self) -> None: 

458 self._set_mode(ChatMode.CHAT.value) 

459 

460 

461class RoleRow(Widget, can_focus=False): 

462 """One role unit in the bar: a colored role pill + its picker button.""" 

463 

464 def __init__(self, *, scope: PickerScope) -> None: 

465 super().__init__() 

466 self.scope: PickerScope = scope 

467 self._key: str = config_key_for_scope(scope) 

468 

469 def compose(self) -> ComposeResult: 

470 yield Static("", classes="model-bar-pill") 

471 yield ModelPickerButton(scope=self.scope, button_id=f"model-pick-{self.scope}") 

472 

473 def on_mount(self) -> None: 

474 self.refresh_state() 

475 

476 @property 

477 def is_active(self) -> bool: 

478 return bool(getattr(cfg, self._key)) 

479 

480 def _is_nullable(self) -> bool: 

481 from lilbee.app.settings_map import SETTINGS_MAP 

482 

483 defn = SETTINGS_MAP.get(self._key) 

484 return defn is not None and defn.nullable 

485 

486 def on_click(self, event: events.Click) -> None: 

487 """Click the pill to toggle an optional role off; otherwise open the picker. 

488 

489 The picker button stops its own click events, so this handler only runs 

490 for clicks on the role pill (or the row gutter). 

491 """ 

492 event.stop() 

493 if self._is_nullable() and self.is_active: 

494 apply_model_pick(self, key=self._key, ref="", on_done=self.refresh_state) 

495 else: 

496 self.query_one(ModelPickerButton).open_picker() 

497 

498 def refresh_state(self) -> None: 

499 """Repaint the role pill (colored when on, muted when off) and the picker label.""" 

500 active = self.is_active 

501 self.set_class(active, "-active") 

502 self.set_class(not active, "-off") 

503 bg, fg = _SCOPE_PILL_COLORS[self.scope] if active else _OFF_PILL_COLORS 

504 with contextlib.suppress(Exception): 

505 self.query_one(".model-bar-pill", Static).update( 

506 pill(_SCOPE_TO_LABEL[self.scope], bg, fg) 

507 ) 

508 self.query_one(ModelPickerButton).repaint() 

509 

510 

511class ModelBar(Widget, can_focus=False): 

512 """Horizontal band of the four role pickers + the Search/Chat toggle, below the input.""" 

513 

514 app: LilbeeApp # type: ignore[assignment] 

515 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8") 

516 

517 _SCOPES: ClassVar[tuple[PickerScope, ...]] = ("chat", "embed", "vision", "rerank") 

518 

519 def __init__(self, id: str | None = None) -> None: 

520 super().__init__(id=id) 

521 self._options_cache: dict[str, tuple[tuple[str, str], ...]] = {} 

522 

523 def compose(self) -> ComposeResult: 

524 with Horizontal(classes="model-bar-roles"): 

525 for scope in self._SCOPES: 

526 yield RoleRow(scope=scope) 

527 yield ChatModeToggle() 

528 yield Static("", id=_CLOUD_WARNING_ID, classes="cloud-warning") 

529 

530 def on_mount(self) -> None: 

531 self._refresh_cloud_warning() 

532 self._scan_models() 

533 self.app.settings_changed_signal.subscribe(self, self._on_settings_changed) 

534 

535 def _on_settings_changed(self, payload: tuple[str, object]) -> None: 

536 key, _ = payload 

537 scope = model_field_to_picker_scope().get(key) 

538 if scope is None: 

539 return 

540 for row in self.query(RoleRow): 

541 if row.scope == scope: 

542 row.refresh_state() 

543 if key == "chat_model": 

544 self._refresh_cloud_warning() 

545 

546 @work(thread=True) 

547 def _scan_models(self) -> None: 

548 """Scan installed models off the UI thread and populate every role button.""" 

549 buckets = classify_installed_models_full() 

550 scope_to_options: dict[str, list[ModelOption]] = { 

551 "chat": list(buckets.get(ModelTask.CHAT, [])), 

552 "embed": list(buckets.get(ModelTask.EMBEDDING, [])), 

553 "vision": list(buckets.get(ModelTask.VISION, [])), 

554 "rerank": list(buckets.get(ModelTask.RERANK, [])), 

555 } 

556 call_from_thread(self, self._populate, scope_to_options) 

557 

558 def _populate(self, scope_to_options: dict[str, list[ModelOption]]) -> None: 

559 for row in self.query(RoleRow): 

560 # Empty pool stays empty: the picker shows just its "Browse catalog" 

561 # row rather than a pickable "(none)" pseudo-model. 

562 opts = scope_to_options.get(row.scope, []) 

563 fingerprint = tuple((o.label, o.ref) for o in opts) 

564 if self._options_cache.get(row.scope) != fingerprint: 

565 row.query_one(ModelPickerButton).set_options(opts) 

566 self._options_cache[row.scope] = fingerprint 

567 self._refresh_cloud_warning() 

568 

569 def _refresh_cloud_warning(self) -> None: 

570 """Show a warning if the active chat model routes to a cloud provider.""" 

571 warning = self.query_one(f"#{_CLOUD_WARNING_ID}", Static) 

572 label = _cloud_provider_label(cfg.chat_model) 

573 if label is None: 

574 warning.remove_class("-visible") 

575 return 

576 warning.update(msg.MODEL_BAR_CLOUD_PROVIDER_WARNING.format(provider=label)) 

577 warning.add_class("-visible") 

578 

579 def refresh_models(self) -> None: 

580 """Re-scan installed models (called after downloads complete).""" 

581 self._scan_models()