Coverage for src/lilbee/cli/tui/widgets/model_pick.py: 100%
96 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"""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, cast
9from lilbee.app.services import get_services
10from lilbee.app.settings import (
11 SettingsSnapshot,
12 embed_pick_needs_rebuild,
13 index_has_chunks,
14 restore_settings,
15 snapshot_settings,
16)
17from lilbee.app.settings_map import SETTINGS_MAP
18from lilbee.cli.tui import messages as msg
19from lilbee.cli.tui.app import apply_active_model
20from lilbee.cli.tui.thread_safe import call_from_thread
21from lilbee.core.config import cfg
22from lilbee.providers.roles import MODEL_FIELD_TO_ROLE
24if TYPE_CHECKING:
25 from textual.app import App
26 from textual.widget import Widget
28 from lilbee.cli.tui.app import LilbeeApp
29 from lilbee.cli.tui.screens.model_picker import PickerScope
31log = logging.getLogger(__name__)
33# Name for the thread worker that reloads a non-chat role off the event loop
34# (the chat scope has its own worker in chat.py).
35_PERSIST_WORKER_NAME = "model_swap_persist"
36# What LanceDB raises from a read or a write, and what config.toml raises from a write.
37STORE_ERRORS = (ValueError, OSError, RuntimeError)
40def config_key_for_scope(scope: PickerScope) -> str:
41 """Inverse of ``model_field_to_picker_scope``: scope -> config attribute name."""
42 from lilbee.cli.tui.screens.settings_widgets import model_field_to_picker_scope
44 for key, sc in model_field_to_picker_scope().items():
45 if sc == scope:
46 return key
47 raise KeyError(scope)
50def apply_model_pick(
51 host: Widget,
52 *,
53 key: str,
54 ref: str | None,
55 on_done: Callable[[], None],
56 reload_worker: bool = True,
57) -> None:
58 """Persist a picker selection and reload the affected worker.
60 ``ref is None`` means the user cancelled (Esc); leave the field alone.
61 ``ref == ""`` for a nullable field means the user picked the explicit
62 "disabled" row; clear the field. ``ref == BROWSE_CATALOG_REF`` is the
63 on-ramp action: open the Catalog focused on the role's task tab.
64 An embedding-model swap against a populated store asks one question
65 before anything changes: the swap needs a rebuild of the index. Yes
66 applies the swap and starts the rebuild; No changes nothing. A swap with
67 no question offers the rebuild after it when the write reports a stale
68 index. ``on_done`` runs after a successful write and reload, never after a
69 cancel, a refused write, a failed reload or the catalog jump.
71 Pass ``reload_worker=False`` when the caller resets the worker another
72 way (the chat screen cancels its stream and resets services on a chat
73 swap, so reloading the chat role here too would tear that work down twice).
74 """
75 if ref is None:
76 return
77 from lilbee.cli.tui.screens.model_picker import BROWSE_CATALOG_REF
79 if ref == BROWSE_CATALOG_REF:
80 _open_catalog_for_key(host, key)
81 return
82 defn = SETTINGS_MAP.get(key)
83 if not ref and (defn is None or not defn.nullable):
84 return
85 if key == "embedding_model" and ref != cfg.embedding_model:
86 try:
87 ask = index_has_chunks() and embed_pick_needs_rebuild(ref)
88 except STORE_ERRORS as exc:
89 host.app.notify(msg.MODEL_ASSIGN_REJECTED.format(error=exc), severity="error")
90 return
91 if ask:
92 _ask_embed_swap(host.app, key, ref, on_done, reload_worker)
93 return
94 _swap(host.app, key, ref, on_done, reload_worker, asked=False)
97def _ask_embed_swap(
98 app: App, key: str, ref: str, on_done: Callable[[], None], reload_worker: bool
99) -> None:
100 """Ask before an embedder swap that needs a rebuild; only Yes changes anything."""
101 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
103 def _answered(rebuild: bool | None) -> None:
104 if rebuild:
105 _swap(app, key, ref, on_done, reload_worker, asked=True)
107 app.push_screen(
108 ConfirmDialog(msg.CMD_REBUILD_CONFIRM_TITLE, msg.EMBED_SWAP_REBUILD_MESSAGE),
109 _answered,
110 )
113def _open_catalog_for_key(host: Widget, key: str) -> None:
114 """Push CatalogScreen focused on the task tab matching the role's key."""
115 # circular: model_pick -> catalog (catalog imports settings_widgets which
116 # imports model_picker, which transitively pulls in model_pick).
117 from lilbee.cli.tui.screens.catalog import CatalogScreen
118 from lilbee.cli.tui.screens.catalog_utils import TASK_TO_TAB_ID
119 from lilbee.cli.tui.screens.settings_widgets import (
120 model_field_to_picker_scope,
121 picker_scope_to_task,
122 )
124 scope = model_field_to_picker_scope().get(key)
125 if scope is None:
126 log.debug("Cannot open catalog for unknown model key %r", key)
127 return
128 tab_id = TASK_TO_TAB_ID[picker_scope_to_task(scope)]
129 host.app.push_screen(CatalogScreen(focus_task=tab_id))
132def _swap(
133 app: App,
134 key: str,
135 ref: str,
136 on_done: Callable[[], None],
137 reload_worker: bool,
138 *,
139 asked: bool,
140) -> None:
141 """Write the ref, reload its worker, restore the old ref on failure, then settle the rebuild.
143 A refused write ends here. A worker reload is a multi-second fleet restart,
144 so it runs in a thread worker behind an indicator toast, and the rest runs
145 back on the main thread. The write's verdict decides the rebuild: Yes
146 (*asked*) starts it, and a swap with no question offers it.
147 """
148 before = snapshot_settings([key])
149 result = apply_active_model(app, key, ref)
150 if result is None:
151 return
152 lilbee_app = cast("LilbeeApp", app)
154 def _settle() -> None:
155 on_done()
156 if not result.reindex_required:
157 return
158 if asked:
159 lilbee_app.start_rebuild()
160 else:
161 lilbee_app.offer_rebuild(msg.SETTINGS_REINDEX_MESSAGE)
163 role = MODEL_FIELD_TO_ROLE.get(key)
164 if not (reload_worker and role is not None):
165 _settle()
166 return
168 target_role = role # narrowed to non-None; bind for the worker closure
169 app.notify(msg.MODEL_SWAP_APPLYING)
171 def _runner() -> None:
172 try:
173 # wait=True runs the reload in this worker thread (already off the event
174 # loop) so a failure is caught here and the done toast fires only once
175 # the fleet has actually restarted, not before.
176 get_services().reload_role(target_role, wait=True)
177 except Exception as exc: # any reload failure becomes a toast, never a crash
178 call_from_thread(app, _roll_back, lilbee_app, key, before, exc)
179 return
180 call_from_thread(app, _finish)
182 def _finish() -> None:
183 app.notify(msg.MODEL_SWAP_DONE.format(name=ref))
184 _settle()
186 app.run_worker(_runner, thread=True, exit_on_error=False, name=_PERSIST_WORKER_NAME)
189def _roll_back(app: LilbeeApp, key: str, before: SettingsSnapshot, error: Exception) -> None:
190 """Put the old model and its config entry back; the toast names the model now in use."""
191 try:
192 restore_settings(before)
193 except STORE_ERRORS:
194 log.warning("Could not restore %s after a failed reload", key, exc_info=True)
195 in_use = getattr(cfg, key)
196 toast = (
197 msg.MODEL_SWAP_FAILED.format(error=error)
198 if in_use == before.values[key]
199 else msg.MODEL_SWAP_STUCK.format(error=error, name=in_use)
200 )
201 app.notify(toast, severity="error")
202 app.publish_settings([key])