Coverage for src / lilbee / cli / tui / widgets / model_pick.py: 100%
55 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-06-28 01:01 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-06-28 01:01 +0000
1"""Shared picker-dismiss logic for the model rail and settings screen."""
3from __future__ import annotations
5import logging
6from collections.abc import Callable
7from typing import TYPE_CHECKING
9from lilbee.app.services import get_services
10from lilbee.app.settings_map import SETTINGS_MAP
11from lilbee.cli.tui import messages as msg
12from lilbee.cli.tui.app import apply_active_model
13from lilbee.providers.worker.transport import WorkerRole
15if TYPE_CHECKING:
16 from textual.app import App
17 from textual.widget import Widget
19 from lilbee.cli.tui.screens.model_picker import PickerScope
21log = logging.getLogger(__name__)
23# Single source of truth for "after a model-key write, which worker pool role
24# needs to respawn so the next call picks up the new ref?". Used by both the
25# Settings picker dismiss path and the chat-screen model rail's button.
26_MODEL_KEY_TO_WORKER_ROLE: dict[str, WorkerRole] = {
27 "chat_model": WorkerRole.CHAT,
28 "embedding_model": WorkerRole.EMBED,
29 "reranker_model": WorkerRole.RERANK,
30 "vision_model": WorkerRole.VISION,
31}
34def config_key_for_scope(scope: PickerScope) -> str:
35 """Inverse of ``model_field_to_picker_scope``: scope -> config attribute name."""
36 from lilbee.cli.tui.screens.settings_widgets import model_field_to_picker_scope
38 for key, sc in model_field_to_picker_scope().items():
39 if sc == scope:
40 return key
41 raise KeyError(scope)
44def apply_model_pick(
45 host: Widget,
46 *,
47 key: str,
48 ref: str | None,
49 on_done: Callable[[], None],
50 reload_worker: bool = True,
51) -> None:
52 """Persist a picker selection and reload the affected worker.
54 ``ref is None`` means the user cancelled (Esc); leave the field alone.
55 ``ref == ""`` for a nullable field means the user picked the explicit
56 "disabled" row; clear the field. ``ref == BROWSE_CATALOG_REF`` is the
57 on-ramp action: open the Catalog focused on the role's task tab.
58 Embedding-model swaps against a populated store route through a
59 confirm modal first so the user is not surprised by the rebuild
60 requirement. ``on_done`` runs after a successful write, never after
61 a cancel and never after the catalog jump.
63 Pass ``reload_worker=False`` when the caller resets the worker another
64 way (the chat screen cancels its stream and resets services on a chat
65 swap, so reloading the chat role here too would tear that work down twice).
66 """
67 if ref is None:
68 return
69 from lilbee.cli.tui.screens.model_picker import BROWSE_CATALOG_REF
71 if ref == BROWSE_CATALOG_REF:
72 _open_catalog_for_key(host, key)
73 return
74 defn = SETTINGS_MAP.get(key)
75 if not ref and (defn is None or not defn.nullable):
76 return
77 if key == "embedding_model" and ref and get_services().store.has_chunks():
78 _push_embed_swap_confirm(host, key, ref, on_done, reload_worker)
79 return
80 _persist(host.app, key, ref, on_done, reload_worker)
83def _open_catalog_for_key(host: Widget, key: str) -> None:
84 """Push CatalogScreen focused on the task tab matching the role's key."""
85 # circular: model_pick -> catalog (catalog imports settings_widgets which
86 # imports model_picker, which transitively pulls in model_pick).
87 from lilbee.cli.tui.screens.catalog import CatalogScreen
88 from lilbee.cli.tui.screens.catalog_utils import TASK_TO_TAB_ID
89 from lilbee.cli.tui.screens.settings_widgets import (
90 model_field_to_picker_scope,
91 picker_scope_to_task,
92 )
94 scope = model_field_to_picker_scope().get(key)
95 if scope is None:
96 log.debug("Cannot open catalog for unknown model key %r", key)
97 return
98 tab_id = TASK_TO_TAB_ID[picker_scope_to_task(scope)]
99 host.app.push_screen(CatalogScreen(focus_task=tab_id))
102def _push_embed_swap_confirm(
103 host: Widget, key: str, ref: str, on_done: Callable[[], None], reload_worker: bool
104) -> None:
105 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
107 host.app.push_screen(
108 ConfirmDialog(msg.EMBED_SWAP_CONFIRM_TITLE, msg.EMBED_SWAP_CONFIRM_MESSAGE),
109 lambda confirmed: _on_embed_confirm(host.app, key, ref, confirmed, on_done, reload_worker),
110 )
113def _on_embed_confirm(
114 app: App,
115 key: str,
116 ref: str,
117 confirmed: bool | None,
118 on_done: Callable[[], None],
119 reload_worker: bool,
120) -> None:
121 if not confirmed:
122 app.notify(msg.EMBED_SWAP_CANCELLED)
123 return
124 _persist(app, key, ref, on_done, reload_worker)
127def _persist(
128 app: App, key: str, ref: str, on_done: Callable[[], None], reload_worker: bool
129) -> None:
130 apply_active_model(app, key, ref)
131 role = _MODEL_KEY_TO_WORKER_ROLE.get(key)
132 if reload_worker and role is not None:
133 get_services().reload_role(role)
134 on_done()