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

388 statements  

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

20from textual.widget import Widget 

21from textual.widgets import Static 

22 

23from lilbee.app.services import peek_services, reset_services 

24from lilbee.catalog import clean_display_name, display_label_for_ref, extract_quant 

25from lilbee.catalog.types import ModelTask 

26from lilbee.cli.tui import messages as msg 

27from lilbee.cli.tui.app import apply_setting 

28from lilbee.cli.tui.pill import pill 

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

30from lilbee.cli.tui.thread_safe import call_from_thread 

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

32from lilbee.core.config import cfg 

33from lilbee.core.config.enums import ChatMode 

34from lilbee.providers.model_ref import format_remote_ref, parse_model_ref 

35from lilbee.providers.sdk_backend import PROVIDER_KEYS 

36from lilbee.retrieval.embedder import is_model_available, is_model_installed 

37 

38log = logging.getLogger(__name__) 

39 

40_MMPROJ_MARKER = "mmproj" 

41 

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

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

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

45 

46 

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

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

49 if not chat_model: 

50 return None 

51 ref = parse_model_ref(chat_model) 

52 if not ref.is_api: 

53 return None 

54 return _CLOUD_PROVIDER_LABELS.get(ref.provider) 

55 

56 

57class ModelOption(NamedTuple): 

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

59 

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

61 ref: str # canonical ref persisted to config 

62 

63 

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

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

66 return _MMPROJ_MARKER in name.lower() 

67 

68 

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

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

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

72 seen: set[str] = set() 

73 

74 _collect_native_models(buckets, seen) 

75 _collect_remote_models(buckets, seen) 

76 _collect_api_models(buckets, seen) 

77 

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

79 

80 

81def _lookup_bucket( 

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

83) -> list[ModelOption] | None: 

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

85 try: 

86 key = ModelTask(task) 

87 except ValueError: 

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

89 return None 

90 return buckets.get(key) 

91 

92 

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

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

95 base = clean_display_name(hf_repo) 

96 if repo_count <= 1: 

97 return base 

98 quant = extract_quant(gguf_filename) 

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

100 

101 

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

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

104 

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

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

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

108 vision picker would silently miss it. 

109 """ 

110 try: 

111 path = registry.resolve(ref) 

112 except (KeyError, ValueError): 

113 return False 

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

115 

116 

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

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

119 try: 

120 from lilbee.modelhub.registry import ModelRegistry 

121 

122 registry = ModelRegistry(cfg.models_dir) 

123 manifests = registry.list_installed() 

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

125 for m in manifests: 

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

127 

128 from lilbee.catalog.query import reclassify_by_name 

129 

130 for manifest in manifests: 

131 ref = manifest.ref 

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

133 continue 

134 task = reclassify_by_name(ref, manifest.task) 

135 label = _native_label( 

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

137 ) 

138 primary_bucket = _lookup_bucket(buckets, task, ref) 

139 if primary_bucket is None: 

140 continue 

141 seen.add(ref) 

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

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

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

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

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

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

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

149 except Exception: 

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

151 

152 

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

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

155 

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

157 the SDK cannot route is a guaranteed runtime error. 

158 """ 

159 from lilbee.providers.litellm_sdk import litellm_available 

160 

161 if not litellm_available(): 

162 return 

163 try: 

164 from lilbee.modelhub.model_manager import classify_all_remote_models 

165 

166 for model in classify_all_remote_models(): 

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

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

169 if not model.name.strip(): 

170 continue 

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

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

173 continue 

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

175 if bucket is None: 

176 continue 

177 seen.add(ref) 

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

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

180 except Exception: 

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

182 

183 

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

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

186 from lilbee.providers.litellm_sdk import litellm_available 

187 

188 if not litellm_available(): 

189 return 

190 try: 

191 from lilbee.modelhub.model_manager import discover_api_models 

192 

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

194 # expose embedding/vision/rerank. 

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

196 for model in models: 

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

198 if qualified in seen: 

199 continue 

200 seen.add(qualified) 

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

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

203 except Exception: 

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

205 

206 

207_CHAT_MODE_TOGGLE_ID = "chat-mode-toggle" 

208_CHAT_MODE_SEARCH_PILL_ID = "chat-mode-search" 

209_CHAT_MODE_CHAT_PILL_ID = "chat-mode-chat" 

210_CHAT_MODE_PILL_CLASS = "chat-mode-pill" 

211_CHAT_MODE_DISABLED_CLASS = "-disabled" 

212_CHAT_MODE_ACTIVE_CLASS = "-active" 

213 

214 

215_SCOPE_TO_TOOLTIP: dict[str, str] = { 

216 "chat": msg.MODEL_PICKER_CHAT_TOOLTIP, 

217 "embed": msg.MODEL_PICKER_EMBED_TOOLTIP, 

218 "vision": msg.MODEL_PICKER_VISION_TOOLTIP, 

219 "rerank": msg.MODEL_PICKER_RERANK_TOOLTIP, 

220} 

221 

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

223 

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

225 

226# Below this width the bar degrades: disabled role rows hide, picker labels 

227# compact, and the mode toggle docks right so it never clips off-screen. 

228_NARROW_BAR_WIDTH = 100 

229_NARROW_CLASS = "-narrow" 

230 

231_SCOPE_TO_LABEL: dict[str, str] = { 

232 "chat": msg.MODEL_BAR_CHAT_LABEL, 

233 "embed": msg.MODEL_BAR_EMBED_LABEL, 

234 "vision": msg.MODEL_BAR_VISION_LABEL, 

235 "rerank": msg.MODEL_BAR_RERANK_LABEL, 

236} 

237 

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

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

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

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

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

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

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

245} 

246 

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

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

249 

250 

251class ModelPickerButton(Static, can_focus=True): 

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

253 

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

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

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

257 ] 

258 

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

260 super().__init__(id=button_id) 

261 self._scope: PickerScope = scope 

262 self._key: str = config_key_for_scope(scope) 

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

264 self.tooltip = _SCOPE_TO_TOOLTIP[scope] 

265 

266 def on_mount(self) -> None: 

267 self._refresh() 

268 

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

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

271 self._options = options 

272 if self.is_mounted: 

273 self._refresh() 

274 

275 def _refresh(self) -> None: 

276 # Any role can be empty. For the optional roles (vision/rerank) that 

277 # means "off"; for chat/embed it means nothing is configured yet and 

278 # the pill is the route to picking one. 

279 ref = getattr(cfg, self._key) 

280 if not ref: 

281 unconfigured = self._key in ("chat_model", "embedding_model") 

282 label = msg.MODEL_BAR_NONE if unconfigured else msg.MODEL_BAR_DISABLED 

283 else: 

284 label = display_label_for_ref(ref) or ref 

285 # Only local models can be "not installed": remote refs (ollama, cloud 

286 # APIs) resolve through their backend at call time. Checked against the 

287 # registry, never get_services(): a cold get_services() builds the whole 

288 # container and eager-starts the worker pool, and a repaint must not 

289 # spawn engines as a side effect. 

290 missing = bool(ref) and not parse_model_ref(ref).is_remote and not is_model_installed(ref) 

291 self.set_class(missing, "-missing") 

292 if missing: 

293 label = msg.MODEL_BAR_NOT_INSTALLED.format(name=label) 

294 self.tooltip = ( 

295 msg.MODEL_BAR_NOT_INSTALLED_TOOLTIP if missing else _SCOPE_TO_TOOLTIP[self._scope] 

296 ) 

297 self.update(label) 

298 

299 def repaint(self) -> None: 

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

301 self._refresh() 

302 

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

304 event.stop() 

305 self.open_picker() 

306 

307 def action_open_picker(self) -> None: 

308 self.open_picker() 

309 

310 def _is_nullable(self) -> bool: 

311 from lilbee.app.settings_map import SETTINGS_MAP 

312 

313 defn = SETTINGS_MAP.get(self._key) 

314 return defn is not None and defn.nullable 

315 

316 def open_picker(self) -> None: 

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

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

319 

320 options = list(self._options) 

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

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

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

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

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

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

327 

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

329 self._focus_chat_prompt() 

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

331 return 

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

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

334 apply_model_pick( 

335 self, 

336 key=self._key, 

337 ref=ref, 

338 on_done=self._commit_after_change, 

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

340 ) 

341 

342 def _focus_chat_prompt(self) -> None: 

343 """Hand focus back to the chat prompt; parking it on this pill dead-ends.""" 

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

345 

346 screen = self.app.screen 

347 if isinstance(screen, ChatScreen): # the model bar is also hosted off-chat 

348 screen.focus_prompt() 

349 

350 def _commit_after_change(self) -> None: 

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

352 

353 ``apply_model_pick`` already persisted the ref and (for non-chat scopes) 

354 reloaded the worker. Chat swaps hand off to the chat screen, which defers 

355 the reload while an answer is streaming. Works regardless of which 

356 container the button is mounted in. 

357 """ 

358 self._refresh() 

359 if self._scope != "chat": 

360 return 

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

362 

363 screen = self.app.screen 

364 if isinstance(screen, ChatScreen): 

365 screen.apply_model_change() 

366 else: 

367 reset_services() 

368 

369 

370class ChatModePill(Static, can_focus=True): 

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

372 

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

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

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

376 ] 

377 

378 def action_select(self) -> None: 

379 toggle = next( 

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

381 None, 

382 ) 

383 if toggle is None: 

384 return 

385 target = ( 

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

387 ) 

388 toggle.set_mode(target) 

389 

390 

391class ChatModeToggle(Widget, can_focus=False): 

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

393 

394 The toggle itself is not focusable; the inner pills are. Enter / Space 

395 picks the focused pill. Left / Right belong to the enclosing ModelBar, 

396 which steps across the whole strip, so they are not bound here: one pair 

397 of arrows cannot both move along the bar and pick a mode. 

398 """ 

399 

400 def __init__(self) -> None: 

401 super().__init__(id=_CHAT_MODE_TOGGLE_ID) 

402 self._search_pill: ChatModePill | None = None 

403 self._chat_pill: ChatModePill | None = None 

404 

405 def compose(self) -> ComposeResult: 

406 # Keep direct references: on_mount can run while the composed pills 

407 # are still being attached to the DOM, so a query there raises 

408 # NoMatches on a slow mount. Attributes carry no such ordering. 

409 self._search_pill = ChatModePill( 

410 msg.CHAT_MODE_SEARCH_LABEL, 

411 id=_CHAT_MODE_SEARCH_PILL_ID, 

412 classes=_CHAT_MODE_PILL_CLASS, 

413 ) 

414 self._chat_pill = ChatModePill( 

415 msg.CHAT_MODE_CHAT_LABEL, 

416 id=_CHAT_MODE_CHAT_PILL_ID, 

417 classes=_CHAT_MODE_PILL_CLASS, 

418 ) 

419 with Horizontal(): 

420 yield self._search_pill 

421 yield self._chat_pill 

422 

423 def on_mount(self) -> None: 

424 self._refresh() 

425 

426 def refresh_state(self) -> None: 

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

428 if self.is_mounted: 

429 self._refresh() 

430 

431 def _embedding_ready(self) -> bool: 

432 """Whether Search is usable. Paint path, so it must not build services. 

433 

434 With a live container the provider answers authoritatively (it can see 

435 remote-backend models); before one exists, fall back to the registry so 

436 a repaint never triggers a container build and its engine spawns. 

437 """ 

438 services = peek_services() 

439 if services is not None: 

440 return is_model_available(cfg.embedding_model, services.provider) 

441 ref = cfg.embedding_model 

442 return bool(ref) and (parse_model_ref(ref).is_remote or is_model_installed(ref)) 

443 

444 def _refresh(self) -> None: 

445 ready = self._embedding_ready() 

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

447 active_search = mode == ChatMode.SEARCH.value 

448 search_pill = self._search_pill 

449 chat_pill = self._chat_pill 

450 if search_pill is None or chat_pill is None: 

451 return 

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

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

454 search_pill.set_class(active_search, _CHAT_MODE_ACTIVE_CLASS) 

455 search_pill.set_class(not ready, _CHAT_MODE_DISABLED_CLASS) 

456 chat_pill.set_class(not active_search, _CHAT_MODE_ACTIVE_CLASS) 

457 chat_pill.set_class(False, _CHAT_MODE_DISABLED_CLASS) 

458 # Parent carries the disabled class so external selectors can 

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

460 self.set_class(not ready, _CHAT_MODE_DISABLED_CLASS) 

461 self.tooltip = ( 

462 msg.CHAT_MODE_TOGGLE_DISABLED_TOOLTIP if not ready else msg.CHAT_MODE_TOGGLE_TOOLTIP 

463 ) 

464 

465 def set_mode(self, target: str) -> bool: 

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

467 

468 A Search flip with no embedding model routes to the catalog's embedding 

469 tab instead of dead-ending on a disabled pill. 

470 """ 

471 if cfg.chat_mode == target: 

472 return False 

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

474 from lilbee.cli.tui.widgets.model_pick import _open_catalog_for_key 

475 

476 self.app.notify(msg.SEARCH_NEEDS_EMBEDDER) 

477 _open_catalog_for_key(self, "embedding_model") 

478 return False 

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

480 self._refresh() 

481 return True 

482 

483 def toggle(self) -> bool: 

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

485 target = ( 

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

487 ) 

488 return self.set_mode(target) 

489 

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

491 event.stop() 

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

493 # frame falls through to a toggle. 

494 widget = event.widget 

495 if widget is not None: 

496 wid = widget.id 

497 if wid == _CHAT_MODE_SEARCH_PILL_ID: 

498 self.set_mode(ChatMode.SEARCH.value) 

499 return 

500 if wid == _CHAT_MODE_CHAT_PILL_ID: 

501 self.set_mode(ChatMode.CHAT.value) 

502 return 

503 self.toggle() 

504 

505 def action_flip_mode(self) -> None: 

506 self.toggle() 

507 

508 

509class RoleRow(Widget, can_focus=False): 

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

511 

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

513 super().__init__() 

514 self.scope: PickerScope = scope 

515 self._key: str = config_key_for_scope(scope) 

516 

517 def compose(self) -> ComposeResult: 

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

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

520 

521 def on_mount(self) -> None: 

522 self.refresh_state() 

523 

524 @property 

525 def is_active(self) -> bool: 

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

527 

528 def _is_nullable(self) -> bool: 

529 from lilbee.app.settings_map import SETTINGS_MAP 

530 

531 defn = SETTINGS_MAP.get(self._key) 

532 return defn is not None and defn.nullable 

533 

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

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

536 

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

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

539 """ 

540 event.stop() 

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

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

543 else: 

544 self.query_one(ModelPickerButton).open_picker() 

545 

546 def refresh_state(self) -> None: 

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

548 active = self.is_active 

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

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

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

552 # Tolerate "children not mounted yet" only; a real pill/repaint failure 

553 # should surface rather than be silently swallowed. 

554 with contextlib.suppress(NoMatches): 

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

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

557 ) 

558 self.query_one(ModelPickerButton).repaint() 

559 

560 

561class ModelBar(Widget, can_focus=False): 

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

563 

564 The bar reads as one strip, so it walks like one: Left / Right step between 

565 its members, and Enter acts on the focused one (opening a picker, or picking 

566 a chat mode). The bindings live here rather than on the members because key 

567 lookup walks the focused widget's ancestors: one definition covers every 

568 member, and it cannot fire anywhere else, so the arrows stay free for the 

569 prompt's own cursor movement without needing a guard. 

570 """ 

571 

572 app: LilbeeApp # type: ignore[assignment] 

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

574 

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

576 

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

578 Binding("left", "step(-1)", "Prev role", show=False), 

579 Binding("right", "step(1)", "Next role", show=False), 

580 # h / l alongside the arrows: the strip is horizontal, so j / k would 

581 # mean nothing here and stay with the transcript's vim scrolling. 

582 Binding("h", "step(-1)", "Prev role", show=False), 

583 Binding("l", "step(1)", "Next role", show=False), 

584 Binding("home", "step_end(-1)", "First role", show=False), 

585 Binding("end", "step_end(1)", "Last role", show=False), 

586 ] 

587 

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

589 super().__init__(id=id) 

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

591 

592 @property 

593 def strip(self) -> list[Widget]: 

594 """The bar's focusable members, left to right as rendered. 

595 

596 Ordered and filtered by the screen's focus chain rather than by a 

597 hand-rolled visibility test: the narrow layout hides switched-off roles 

598 on the RoleRow, so the button's own ``display`` still reads True and 

599 Left / Right would step onto a member the user cannot see. 

600 """ 

601 members = {*self.query(ModelPickerButton).results(), *self.query(ChatModePill).results()} 

602 return [w for w in self.screen.focus_chain if w in members] 

603 

604 def focus_strip(self, direction: int = 1) -> None: 

605 """Enter the strip from outside it, travelling in *direction*. 

606 

607 Walking rightwards enters at the first role, so the next step continues 

608 the way the key pointed. No-op while the bar is still composing. 

609 """ 

610 self._focus_end(first=direction > 0) 

611 

612 def _focus_end(self, *, first: bool) -> None: 

613 """Focus the leftmost or rightmost member. 

614 

615 Named by which end it lands on rather than by a direction: entering the 

616 strip and jumping to an end read the same sign oppositely, since walking 

617 left *into* the strip lands on its last member while Home walks left 

618 *within* it and lands on the first. 

619 """ 

620 self._focus_at(0 if first else len(self.strip) - 1) 

621 

622 def _focus_at(self, index: int) -> None: 

623 """Focus the member at *index*, clamped to the ends of the strip.""" 

624 members = self.strip 

625 if members: 

626 members[max(0, min(index, len(members) - 1))].focus() 

627 

628 def _focused_index(self) -> int | None: 

629 """Position of the focused member, or None when focus is off the strip.""" 

630 members = self.strip 

631 focused = self.screen.focused 

632 return members.index(focused) if focused in members else None 

633 

634 def action_step(self, delta: int) -> None: 

635 """Move focus one member along the strip; clamps at both ends.""" 

636 current = self._focused_index() 

637 if current is not None: 

638 self._focus_at(current + delta) 

639 

640 def action_step_end(self, direction: int) -> None: 

641 """Home / End jump to the first or last member of the strip.""" 

642 if self._focused_index() is not None: 

643 self._focus_end(first=direction < 0) 

644 

645 def compose(self) -> ComposeResult: 

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

647 for scope in self._SCOPES: 

648 yield RoleRow(scope=scope) 

649 yield ChatModeToggle() 

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

651 

652 def on_mount(self) -> None: 

653 self._refresh_cloud_warning() 

654 self._scan_models() 

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

656 

657 def on_resize(self, event: events.Resize) -> None: 

658 """Toggle the narrow-layout class so the bar degrades instead of clipping.""" 

659 self.set_class(event.size.width < _NARROW_BAR_WIDTH, _NARROW_CLASS) 

660 

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

662 key, _ = payload 

663 scope = model_field_to_picker_scope().get(key) 

664 if scope is None: 

665 return 

666 for row in self.query(RoleRow): 

667 if row.scope == scope: 

668 row.refresh_state() 

669 if key == "chat_model": 

670 self._refresh_cloud_warning() 

671 

672 @work(thread=True) 

673 def _scan_models(self) -> None: 

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

675 buckets = classify_installed_models_full() 

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

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

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

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

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

681 } 

682 call_from_thread(self, self._populate, scope_to_options) 

683 

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

685 for row in self.query(RoleRow): 

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

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

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

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

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

691 try: 

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

693 except NoMatches: 

694 # The scan can land while a row is still composing; the next 

695 # scan repopulates against the mounted picker. 

696 continue 

697 self._options_cache[row.scope] = fingerprint 

698 self._refresh_cloud_warning() 

699 

700 def _refresh_cloud_warning(self) -> None: 

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

702 try: 

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

704 except NoMatches: 

705 # A scan worker can finish before compose mounts the warning row; 

706 # the next refresh runs against the mounted bar. 

707 return 

708 label = _cloud_provider_label(cfg.chat_model) 

709 if label is None: 

710 warning.remove_class("-visible") 

711 return 

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

713 warning.add_class("-visible") 

714 

715 def refresh_models(self) -> None: 

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

717 self._scan_models()