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

382 statements  

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

14from textual import events, work 

15from textual.app import ComposeResult 

16from textual.binding import Binding, BindingType 

17from textual.containers import Horizontal 

18from textual.css.query import NoMatches 

19from textual.widget import Widget 

20from textual.widgets import Static 

21 

22from lilbee.app.services import peek_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, is_model_installed 

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 _collect_native_models(buckets: dict[ModelTask, list[ModelOption]], seen: set[str]) -> None: 

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

103 try: 

104 from lilbee.modelhub.registry import ModelRegistry 

105 

106 registry = ModelRegistry(cfg.models_dir) 

107 manifests = registry.list_installed() 

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

109 for m in manifests: 

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

111 

112 from lilbee.catalog.query import reclassify_by_name 

113 

114 for manifest in manifests: 

115 ref = manifest.ref 

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

117 continue 

118 task = reclassify_by_name(ref, manifest.task) 

119 label = _native_label( 

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

121 ) 

122 primary_bucket = _lookup_bucket(buckets, task, ref) 

123 if primary_bucket is None: 

124 continue 

125 seen.add(ref) 

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

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

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

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

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

131 if task != ModelTask.VISION and registry.has_vision_projector(ref): 

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

133 except Exception: 

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

135 

136 

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

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

139 

140 Skipped when the providers extra is not installed -- surfacing a model 

141 the SDK cannot route is a guaranteed runtime error. 

142 """ 

143 from lilbee.providers import availability 

144 

145 if not availability.provider_sdk_available(): 

146 return 

147 try: 

148 from lilbee.modelhub.model_manager import classify_all_remote_models 

149 

150 for model in classify_all_remote_models(): 

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

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

153 if not model.name.strip(): 

154 continue 

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

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

157 continue 

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

159 if bucket is None: 

160 continue 

161 seen.add(ref) 

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

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

164 except Exception: 

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

166 

167 

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

169 """Add frontier API chat models. Skipped without the providers extra (cannot route).""" 

170 from lilbee.providers import availability 

171 

172 if not availability.provider_sdk_available(): 

173 return 

174 try: 

175 from lilbee.modelhub.model_manager import discover_api_models 

176 

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

178 # expose embedding/vision/rerank. 

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

180 for model in models: 

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

182 if qualified in seen: 

183 continue 

184 seen.add(qualified) 

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

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

187 except Exception: 

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

189 

190 

191_CHAT_MODE_TOGGLE_ID = "chat-mode-toggle" 

192_CHAT_MODE_SEARCH_PILL_ID = "chat-mode-search" 

193_CHAT_MODE_CHAT_PILL_ID = "chat-mode-chat" 

194_CHAT_MODE_PILL_CLASS = "chat-mode-pill" 

195_CHAT_MODE_DISABLED_CLASS = "-disabled" 

196_CHAT_MODE_ACTIVE_CLASS = "-active" 

197 

198 

199_SCOPE_TO_TOOLTIP: dict[str, str] = { 

200 "chat": msg.MODEL_PICKER_CHAT_TOOLTIP, 

201 "embed": msg.MODEL_PICKER_EMBED_TOOLTIP, 

202 "vision": msg.MODEL_PICKER_VISION_TOOLTIP, 

203 "rerank": msg.MODEL_PICKER_RERANK_TOOLTIP, 

204} 

205 

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

207 

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

209 

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

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

212_NARROW_BAR_WIDTH = 100 

213_NARROW_CLASS = "-narrow" 

214 

215_SCOPE_TO_LABEL: dict[str, str] = { 

216 "chat": msg.MODEL_BAR_CHAT_LABEL, 

217 "embed": msg.MODEL_BAR_EMBED_LABEL, 

218 "vision": msg.MODEL_BAR_VISION_LABEL, 

219 "rerank": msg.MODEL_BAR_RERANK_LABEL, 

220} 

221 

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

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

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

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

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

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

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

229} 

230 

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

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

233 

234 

235class ModelPickerButton(Static, can_focus=True): 

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

237 

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

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

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

241 ] 

242 

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

244 super().__init__(id=button_id, markup=False) 

245 self._scope: PickerScope = scope 

246 self._key: str = config_key_for_scope(scope) 

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

248 self.tooltip = _SCOPE_TO_TOOLTIP[scope] 

249 

250 def on_mount(self) -> None: 

251 self._refresh() 

252 

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

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

255 self._options = options 

256 if self.is_mounted: 

257 self._refresh() 

258 

259 def _refresh(self) -> None: 

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

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

262 # the pill is the route to picking one. 

263 ref = getattr(cfg, self._key) 

264 if not ref: 

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

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

267 else: 

268 label = display_label_for_ref(ref) or ref 

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

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

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

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

273 # spawn engines as a side effect. 

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

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

276 if missing: 

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

278 self.tooltip = ( 

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

280 ) 

281 self.update(label) 

282 

283 def repaint(self) -> None: 

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

285 self._refresh() 

286 

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

288 event.stop() 

289 self.open_picker() 

290 

291 def action_open_picker(self) -> None: 

292 self.open_picker() 

293 

294 def _is_nullable(self) -> bool: 

295 from lilbee.app.settings_map import SETTINGS_MAP 

296 

297 defn = SETTINGS_MAP.get(self._key) 

298 return defn is not None and defn.nullable 

299 

300 def open_picker(self) -> None: 

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

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

303 

304 options = list(self._options) 

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

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

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

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

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

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

311 

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

313 self._focus_chat_prompt() 

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

315 return 

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

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

318 apply_model_pick( 

319 self, 

320 key=self._key, 

321 ref=ref, 

322 on_done=self._commit_after_change, 

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

324 ) 

325 

326 def _focus_chat_prompt(self) -> None: 

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

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

329 

330 screen = self.app.screen 

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

332 screen.focus_prompt() 

333 

334 def _commit_after_change(self) -> None: 

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

336 

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

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

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

340 container the button is mounted in. 

341 """ 

342 self._refresh() 

343 if self._scope != "chat": 

344 return 

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

346 

347 screen = self.app.screen 

348 if isinstance(screen, ChatScreen): 

349 screen.apply_model_change() 

350 else: 

351 reset_services() 

352 

353 

354class ChatModePill(Static, can_focus=True): 

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

356 

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

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

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

360 ] 

361 

362 def action_select(self) -> None: 

363 toggle = next( 

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

365 None, 

366 ) 

367 if toggle is None: 

368 return 

369 target = ( 

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

371 ) 

372 toggle.set_mode(target) 

373 

374 

375class ChatModeToggle(Widget, can_focus=False): 

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

377 

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

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

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

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

382 """ 

383 

384 def __init__(self) -> None: 

385 super().__init__(id=_CHAT_MODE_TOGGLE_ID) 

386 self._search_pill: ChatModePill | None = None 

387 self._chat_pill: ChatModePill | None = None 

388 

389 def compose(self) -> ComposeResult: 

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

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

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

393 self._search_pill = ChatModePill( 

394 msg.CHAT_MODE_SEARCH_LABEL, 

395 id=_CHAT_MODE_SEARCH_PILL_ID, 

396 classes=_CHAT_MODE_PILL_CLASS, 

397 ) 

398 self._chat_pill = ChatModePill( 

399 msg.CHAT_MODE_CHAT_LABEL, 

400 id=_CHAT_MODE_CHAT_PILL_ID, 

401 classes=_CHAT_MODE_PILL_CLASS, 

402 ) 

403 with Horizontal(): 

404 yield self._search_pill 

405 yield self._chat_pill 

406 

407 def on_mount(self) -> None: 

408 self._refresh() 

409 

410 def refresh_state(self) -> None: 

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

412 if self.is_mounted: 

413 self._refresh() 

414 

415 def _embedding_ready(self) -> bool: 

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

417 

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

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

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

421 """ 

422 services = peek_services() 

423 if services is not None: 

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

425 ref = cfg.embedding_model 

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

427 

428 def _refresh(self) -> None: 

429 ready = self._embedding_ready() 

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

431 active_search = mode == ChatMode.SEARCH.value 

432 search_pill = self._search_pill 

433 chat_pill = self._chat_pill 

434 if search_pill is None or chat_pill is None: 

435 return 

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

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

438 search_pill.set_class(active_search, _CHAT_MODE_ACTIVE_CLASS) 

439 search_pill.set_class(not ready, _CHAT_MODE_DISABLED_CLASS) 

440 chat_pill.set_class(not active_search, _CHAT_MODE_ACTIVE_CLASS) 

441 chat_pill.set_class(False, _CHAT_MODE_DISABLED_CLASS) 

442 # Parent carries the disabled class so external selectors can 

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

444 self.set_class(not ready, _CHAT_MODE_DISABLED_CLASS) 

445 self.tooltip = ( 

446 msg.CHAT_MODE_TOGGLE_DISABLED_TOOLTIP if not ready else msg.CHAT_MODE_TOGGLE_TOOLTIP 

447 ) 

448 

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

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

451 

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

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

454 """ 

455 if cfg.chat_mode == target: 

456 return False 

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

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

459 

460 self.app.notify(msg.SEARCH_NEEDS_EMBEDDER) 

461 _open_catalog_for_key(self, "embedding_model") 

462 return False 

463 saved = apply_setting(self.app, "chat_mode", target) 

464 self._refresh() 

465 return saved 

466 

467 def toggle(self) -> bool: 

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

469 target = ( 

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

471 ) 

472 return self.set_mode(target) 

473 

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

475 event.stop() 

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

477 # frame falls through to a toggle. 

478 widget = event.widget 

479 if widget is not None: 

480 wid = widget.id 

481 if wid == _CHAT_MODE_SEARCH_PILL_ID: 

482 self.set_mode(ChatMode.SEARCH.value) 

483 return 

484 if wid == _CHAT_MODE_CHAT_PILL_ID: 

485 self.set_mode(ChatMode.CHAT.value) 

486 return 

487 self.toggle() 

488 

489 def action_flip_mode(self) -> None: 

490 self.toggle() 

491 

492 

493class RoleRow(Widget, can_focus=False): 

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

495 

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

497 super().__init__() 

498 self.scope: PickerScope = scope 

499 self._key: str = config_key_for_scope(scope) 

500 

501 def compose(self) -> ComposeResult: 

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

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

504 

505 def on_mount(self) -> None: 

506 self.refresh_state() 

507 

508 @property 

509 def is_active(self) -> bool: 

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

511 

512 def _is_nullable(self) -> bool: 

513 from lilbee.app.settings_map import SETTINGS_MAP 

514 

515 defn = SETTINGS_MAP.get(self._key) 

516 return defn is not None and defn.nullable 

517 

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

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

520 

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

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

523 """ 

524 event.stop() 

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

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

527 else: 

528 self.query_one(ModelPickerButton).open_picker() 

529 

530 def refresh_state(self) -> None: 

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

532 active = self.is_active 

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

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

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

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

537 # should surface rather than be silently swallowed. 

538 with contextlib.suppress(NoMatches): 

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

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

541 ) 

542 self.query_one(ModelPickerButton).repaint() 

543 

544 

545class ModelBar(Widget, can_focus=False): 

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

547 

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

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

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

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

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

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

554 """ 

555 

556 app: LilbeeApp # type: ignore[assignment] 

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

558 

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

560 

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

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

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

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

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

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

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

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

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

570 ] 

571 

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

573 super().__init__(id=id) 

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

575 

576 @property 

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

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

579 

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

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

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

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

584 """ 

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

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

587 

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

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

590 

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

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

593 """ 

594 self._focus_end(first=direction > 0) 

595 

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

597 """Focus the leftmost or rightmost member. 

598 

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

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

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

602 *within* it and lands on the first. 

603 """ 

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

605 

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

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

608 members = self.strip 

609 if members: 

610 self.screen.set_focus(members[max(0, min(index, len(members) - 1))]) 

611 

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

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

614 members = self.strip 

615 focused = self.screen.focused 

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

617 

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

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

620 current = self._focused_index() 

621 if current is not None: 

622 self._focus_at(current + delta) 

623 

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

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

626 if self._focused_index() is not None: 

627 self._focus_end(first=direction < 0) 

628 

629 def compose(self) -> ComposeResult: 

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

631 for scope in self._SCOPES: 

632 yield RoleRow(scope=scope) 

633 yield ChatModeToggle() 

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

635 

636 def on_mount(self) -> None: 

637 self._refresh_cloud_warning() 

638 self._scan_models() 

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

640 

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

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

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

644 

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

646 key, _ = payload 

647 scope = model_field_to_picker_scope().get(key) 

648 if scope is None: 

649 return 

650 for row in self.query(RoleRow): 

651 if row.scope == scope: 

652 row.refresh_state() 

653 if key == "chat_model": 

654 self._refresh_cloud_warning() 

655 

656 @work(thread=True) 

657 def _scan_models(self) -> None: 

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

659 buckets = classify_installed_models_full() 

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

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

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

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

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

665 } 

666 call_from_thread(self, self._populate, scope_to_options) 

667 

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

669 for row in self.query(RoleRow): 

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

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

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

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

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

675 try: 

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

677 except NoMatches: 

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

679 # scan repopulates against the mounted picker. 

680 continue 

681 self._options_cache[row.scope] = fingerprint 

682 self._refresh_cloud_warning() 

683 

684 def _refresh_cloud_warning(self) -> None: 

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

686 try: 

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

688 except NoMatches: 

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

690 # the next refresh runs against the mounted bar. 

691 return 

692 label = _cloud_provider_label(cfg.chat_model) 

693 if label is None: 

694 warning.remove_class("-visible") 

695 return 

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

697 warning.add_class("-visible") 

698 

699 def refresh_models(self) -> None: 

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

701 self._scan_models()