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
« 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."""
3from __future__ import annotations
5import contextlib
6import logging
7from pathlib import Path
8from typing import TYPE_CHECKING, ClassVar, NamedTuple
10if TYPE_CHECKING:
11 from lilbee.cli.tui.app import LilbeeApp
12 from lilbee.cli.tui.screens.model_picker import PickerScope
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
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
37log = logging.getLogger(__name__)
39_MMPROJ_MARKER = "mmproj"
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}
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)
56class ModelOption(NamedTuple):
57 """A selectable model with display label and config ref."""
59 label: str # human-readable name for the dropdown
60 ref: str # canonical ref persisted to config
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()
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()
73 _collect_native_models(buckets, seen)
74 _collect_remote_models(buckets, seen)
75 _collect_api_models(buckets, seen)
77 return {task: sorted(opts, key=lambda o: o.ref) for task, opts in buckets.items()}
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)
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
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
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
112 from lilbee.catalog.query import reclassify_by_name
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)
137def _collect_remote_models(buckets: dict[ModelTask, list[ModelOption]], seen: set[str]) -> None:
138 """Add remote (Ollama / OpenAI-compatible) models, prefixed for routing.
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
145 if not availability.provider_sdk_available():
146 return
147 try:
148 from lilbee.modelhub.model_manager import classify_all_remote_models
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)
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
172 if not availability.provider_sdk_available():
173 return
174 try:
175 from lilbee.modelhub.model_manager import discover_api_models
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)
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"
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}
206_CSS_FILE = Path(__file__).parent / "model_bar.tcss"
208_CLOUD_WARNING_ID = "model-bar-cloud-warning"
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"
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}
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}
231# Muted pill for an optional role that is currently off.
232_OFF_PILL_COLORS: tuple[str, str] = ("$surface-lighten-2", "$text-muted")
235class ModelPickerButton(Static, can_focus=True):
236 """Pill button that opens a ModelPickerModal scoped to one of the four roles."""
238 BINDINGS: ClassVar[list[BindingType]] = [
239 Binding("enter", "open_picker", "Pick model", show=False),
240 Binding("space", "open_picker", "Pick model", show=False),
241 ]
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]
250 def on_mount(self) -> None:
251 self._refresh()
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()
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)
283 def repaint(self) -> None:
284 """Public entry for a parent container to repaint the label from cfg."""
285 self._refresh()
287 def on_click(self, event: events.Click) -> None:
288 event.stop()
289 self.open_picker()
291 def action_open_picker(self) -> None:
292 self.open_picker()
294 def _is_nullable(self) -> bool:
295 from lilbee.app.settings_map import SETTINGS_MAP
297 defn = SETTINGS_MAP.get(self._key)
298 return defn is not None and defn.nullable
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
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)
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 )
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
330 screen = self.app.screen
331 if isinstance(screen, ChatScreen): # the model bar is also hosted off-chat
332 screen.focus_prompt()
334 def _commit_after_change(self) -> None:
335 """Repaint the label, then run the chat-screen side effect for chat swaps.
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
347 screen = self.app.screen
348 if isinstance(screen, ChatScreen):
349 screen.apply_model_change()
350 else:
351 reset_services()
354class ChatModePill(Static, can_focus=True):
355 """Single focusable mode pill; Enter / Space picks this pill's mode."""
357 BINDINGS: ClassVar[list[BindingType]] = [
358 Binding("enter", "select", "Pick mode", show=False),
359 Binding("space", "select", "Pick mode", show=False),
360 ]
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)
375class ChatModeToggle(Widget, can_focus=False):
376 """Two-pill control toggling cfg.chat_mode between Search and Chat.
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 """
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
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
407 def on_mount(self) -> None:
408 self._refresh()
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()
415 def _embedding_ready(self) -> bool:
416 """Whether Search is usable. Paint path, so it must not build services.
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))
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 )
449 def set_mode(self, target: str) -> bool:
450 """Apply *target* if it differs from the current mode and Search is allowed.
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
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
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)
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()
489 def action_flip_mode(self) -> None:
490 self.toggle()
493class RoleRow(Widget, can_focus=False):
494 """One role unit in the bar: a colored role pill + its picker button."""
496 def __init__(self, *, scope: PickerScope) -> None:
497 super().__init__()
498 self.scope: PickerScope = scope
499 self._key: str = config_key_for_scope(scope)
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}")
505 def on_mount(self) -> None:
506 self.refresh_state()
508 @property
509 def is_active(self) -> bool:
510 return bool(getattr(cfg, self._key))
512 def _is_nullable(self) -> bool:
513 from lilbee.app.settings_map import SETTINGS_MAP
515 defn = SETTINGS_MAP.get(self._key)
516 return defn is not None and defn.nullable
518 def on_click(self, event: events.Click) -> None:
519 """Click the pill to toggle an optional role off; otherwise open the picker.
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()
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()
545class ModelBar(Widget, can_focus=False):
546 """Horizontal band of the four role pickers + the Search/Chat toggle, below the input.
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 """
556 app: LilbeeApp # type: ignore[assignment]
557 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
559 _SCOPES: ClassVar[tuple[PickerScope, ...]] = ("chat", "embed", "vision", "rerank")
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 ]
572 def __init__(self, id: str | None = None) -> None:
573 super().__init__(id=id)
574 self._options_cache: dict[str, tuple[tuple[str, str], ...]] = {}
576 @property
577 def strip(self) -> list[Widget]:
578 """The bar's focusable members, left to right as rendered.
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]
588 def focus_strip(self, direction: int = 1) -> None:
589 """Enter the strip from outside it, travelling in *direction*.
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)
596 def _focus_end(self, *, first: bool) -> None:
597 """Focus the leftmost or rightmost member.
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)
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))])
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
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)
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)
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")
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)
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)
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()
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)
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()
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")
699 def refresh_models(self) -> None:
700 """Re-scan installed models (called after downloads complete)."""
701 self._scan_models()