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
« 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."""
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
13 from lilbee.modelhub.registry import ModelRegistry
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
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
38log = logging.getLogger(__name__)
40_MMPROJ_MARKER = "mmproj"
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}
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)
57class ModelOption(NamedTuple):
58 """A selectable model with display label and config ref."""
60 label: str # human-readable name for the dropdown
61 ref: str # canonical ref persisted to config
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()
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()
74 _collect_native_models(buckets, seen)
75 _collect_remote_models(buckets, seen)
76 _collect_api_models(buckets, seen)
78 return {task: sorted(opts, key=lambda o: o.ref) for task, opts in buckets.items()}
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)
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
102def _has_vision_sidecar(registry: ModelRegistry, ref: str) -> bool:
103 """Return True if *ref* resolves to a model with an adjacent ``*mmproj*.gguf`` file.
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"))
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
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
128 from lilbee.catalog.query import reclassify_by_name
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)
153def _collect_remote_models(buckets: dict[ModelTask, list[ModelOption]], seen: set[str]) -> None:
154 """Add remote (Ollama / OpenAI-compatible) models, prefixed for routing.
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
161 if not litellm_available():
162 return
163 try:
164 from lilbee.modelhub.model_manager import classify_all_remote_models
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)
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
188 if not litellm_available():
189 return
190 try:
191 from lilbee.modelhub.model_manager import discover_api_models
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)
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"
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}
222_CSS_FILE = Path(__file__).parent / "model_bar.tcss"
224_CLOUD_WARNING_ID = "model-bar-cloud-warning"
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"
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}
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}
247# Muted pill for an optional role that is currently off.
248_OFF_PILL_COLORS: tuple[str, str] = ("$surface-lighten-2", "$text-muted")
251class ModelPickerButton(Static, can_focus=True):
252 """Pill button that opens a ModelPickerModal scoped to one of the four roles."""
254 BINDINGS: ClassVar[list[BindingType]] = [
255 Binding("enter", "open_picker", "Pick model", show=False),
256 Binding("space", "open_picker", "Pick model", show=False),
257 ]
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]
266 def on_mount(self) -> None:
267 self._refresh()
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()
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)
299 def repaint(self) -> None:
300 """Public entry for a parent container to repaint the label from cfg."""
301 self._refresh()
303 def on_click(self, event: events.Click) -> None:
304 event.stop()
305 self.open_picker()
307 def action_open_picker(self) -> None:
308 self.open_picker()
310 def _is_nullable(self) -> bool:
311 from lilbee.app.settings_map import SETTINGS_MAP
313 defn = SETTINGS_MAP.get(self._key)
314 return defn is not None and defn.nullable
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
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)
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 )
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
346 screen = self.app.screen
347 if isinstance(screen, ChatScreen): # the model bar is also hosted off-chat
348 screen.focus_prompt()
350 def _commit_after_change(self) -> None:
351 """Repaint the label, then run the chat-screen side effect for chat swaps.
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
363 screen = self.app.screen
364 if isinstance(screen, ChatScreen):
365 screen.apply_model_change()
366 else:
367 reset_services()
370class ChatModePill(Static, can_focus=True):
371 """Single focusable mode pill; Enter / Space picks this pill's mode."""
373 BINDINGS: ClassVar[list[BindingType]] = [
374 Binding("enter", "select", "Pick mode", show=False),
375 Binding("space", "select", "Pick mode", show=False),
376 ]
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)
391class ChatModeToggle(Widget, can_focus=False):
392 """Two-pill control toggling cfg.chat_mode between Search and Chat.
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 """
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
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
423 def on_mount(self) -> None:
424 self._refresh()
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()
431 def _embedding_ready(self) -> bool:
432 """Whether Search is usable. Paint path, so it must not build services.
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))
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 )
465 def set_mode(self, target: str) -> bool:
466 """Apply *target* if it differs from the current mode and Search is allowed.
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
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
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)
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()
505 def action_flip_mode(self) -> None:
506 self.toggle()
509class RoleRow(Widget, can_focus=False):
510 """One role unit in the bar: a colored role pill + its picker button."""
512 def __init__(self, *, scope: PickerScope) -> None:
513 super().__init__()
514 self.scope: PickerScope = scope
515 self._key: str = config_key_for_scope(scope)
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}")
521 def on_mount(self) -> None:
522 self.refresh_state()
524 @property
525 def is_active(self) -> bool:
526 return bool(getattr(cfg, self._key))
528 def _is_nullable(self) -> bool:
529 from lilbee.app.settings_map import SETTINGS_MAP
531 defn = SETTINGS_MAP.get(self._key)
532 return defn is not None and defn.nullable
534 def on_click(self, event: events.Click) -> None:
535 """Click the pill to toggle an optional role off; otherwise open the picker.
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()
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()
561class ModelBar(Widget, can_focus=False):
562 """Horizontal band of the four role pickers + the Search/Chat toggle, below the input.
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 """
572 app: LilbeeApp # type: ignore[assignment]
573 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
575 _SCOPES: ClassVar[tuple[PickerScope, ...]] = ("chat", "embed", "vision", "rerank")
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 ]
588 def __init__(self, id: str | None = None) -> None:
589 super().__init__(id=id)
590 self._options_cache: dict[str, tuple[tuple[str, str], ...]] = {}
592 @property
593 def strip(self) -> list[Widget]:
594 """The bar's focusable members, left to right as rendered.
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]
604 def focus_strip(self, direction: int = 1) -> None:
605 """Enter the strip from outside it, travelling in *direction*.
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)
612 def _focus_end(self, *, first: bool) -> None:
613 """Focus the leftmost or rightmost member.
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)
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()
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
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)
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)
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")
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)
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)
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()
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)
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()
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")
715 def refresh_models(self) -> None:
716 """Re-scan installed models (called after downloads complete)."""
717 self._scan_models()