Coverage for src/lilbee/cli/tui/widgets/model_pick.py: 100%
70 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"""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.cli.tui.thread_safe import call_from_thread
14from lilbee.providers.roles import MODEL_FIELD_TO_ROLE
16if TYPE_CHECKING:
17 from textual.app import App
18 from textual.widget import Widget
20 from lilbee.cli.tui.screens.model_picker import PickerScope
22log = logging.getLogger(__name__)
24# Name for the thread worker that persists + reloads a non-chat role off the
25# event loop (the chat scope has its own worker in chat.py).
26_PERSIST_WORKER_NAME = "model_swap_persist"
29def config_key_for_scope(scope: PickerScope) -> str:
30 """Inverse of ``model_field_to_picker_scope``: scope -> config attribute name."""
31 from lilbee.cli.tui.screens.settings_widgets import model_field_to_picker_scope
33 for key, sc in model_field_to_picker_scope().items():
34 if sc == scope:
35 return key
36 raise KeyError(scope)
39def apply_model_pick(
40 host: Widget,
41 *,
42 key: str,
43 ref: str | None,
44 on_done: Callable[[], None],
45 reload_worker: bool = True,
46) -> None:
47 """Persist a picker selection and reload the affected worker.
49 ``ref is None`` means the user cancelled (Esc); leave the field alone.
50 ``ref == ""`` for a nullable field means the user picked the explicit
51 "disabled" row; clear the field. ``ref == BROWSE_CATALOG_REF`` is the
52 on-ramp action: open the Catalog focused on the role's task tab.
53 Embedding-model swaps against a populated store route through a
54 confirm modal first so the user is not surprised by the rebuild
55 requirement. ``on_done`` runs after a successful write, never after
56 a cancel and never after the catalog jump.
58 Pass ``reload_worker=False`` when the caller resets the worker another
59 way (the chat screen cancels its stream and resets services on a chat
60 swap, so reloading the chat role here too would tear that work down twice).
61 """
62 if ref is None:
63 return
64 from lilbee.cli.tui.screens.model_picker import BROWSE_CATALOG_REF
66 if ref == BROWSE_CATALOG_REF:
67 _open_catalog_for_key(host, key)
68 return
69 defn = SETTINGS_MAP.get(key)
70 if not ref and (defn is None or not defn.nullable):
71 return
72 if key == "embedding_model" and ref and get_services().store.has_chunks():
73 _push_embed_swap_confirm(host, key, ref, on_done, reload_worker)
74 return
75 _persist(host.app, key, ref, on_done, reload_worker)
78def _open_catalog_for_key(host: Widget, key: str) -> None:
79 """Push CatalogScreen focused on the task tab matching the role's key."""
80 # circular: model_pick -> catalog (catalog imports settings_widgets which
81 # imports model_picker, which transitively pulls in model_pick).
82 from lilbee.cli.tui.screens.catalog import CatalogScreen
83 from lilbee.cli.tui.screens.catalog_utils import TASK_TO_TAB_ID
84 from lilbee.cli.tui.screens.settings_widgets import (
85 model_field_to_picker_scope,
86 picker_scope_to_task,
87 )
89 scope = model_field_to_picker_scope().get(key)
90 if scope is None:
91 log.debug("Cannot open catalog for unknown model key %r", key)
92 return
93 tab_id = TASK_TO_TAB_ID[picker_scope_to_task(scope)]
94 host.app.push_screen(CatalogScreen(focus_task=tab_id))
97def _push_embed_swap_confirm(
98 host: Widget, key: str, ref: str, on_done: Callable[[], None], reload_worker: bool
99) -> None:
100 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
102 host.app.push_screen(
103 ConfirmDialog(msg.EMBED_SWAP_CONFIRM_TITLE, msg.EMBED_SWAP_CONFIRM_MESSAGE),
104 lambda confirmed: _on_embed_confirm(host.app, key, ref, confirmed, on_done, reload_worker),
105 )
108def _on_embed_confirm(
109 app: App,
110 key: str,
111 ref: str,
112 confirmed: bool | None,
113 on_done: Callable[[], None],
114 reload_worker: bool,
115) -> None:
116 if not confirmed:
117 app.notify(msg.EMBED_SWAP_CANCELLED)
118 return
119 _persist(app, key, ref, on_done, reload_worker)
122def _persist(
123 app: App, key: str, ref: str, on_done: Callable[[], None], reload_worker: bool
124) -> None:
125 """Persist the picked ref and reload the affected worker without freezing the UI.
127 A worker reload is a multi-second fleet restart, so when one is needed the
128 write and reload run in a thread worker behind an indicator toast and
129 ``on_done`` runs back on the main thread. The chat scope passes
130 ``reload_worker=False`` and resets services itself (see
131 ``ChatScreen.apply_model_change``), so its cheap config write stays inline.
132 """
133 role = MODEL_FIELD_TO_ROLE.get(key)
134 if not (reload_worker and role is not None):
135 apply_active_model(app, key, ref)
136 on_done()
137 return
139 target_role = role # narrowed to non-None; bind for the worker closure
140 app.notify(msg.MODEL_SWAP_APPLYING)
142 def _runner() -> None:
143 try:
144 # set_active_model toasts and publishes a signal, both event-loop-only,
145 # so the write runs on the main thread; the slow part is the reload below.
146 call_from_thread(app, apply_active_model, app, key, ref)
147 # wait=True runs the reload in this worker thread (already off the event
148 # loop) so a failure is caught here and the done toast fires only once
149 # the fleet has actually restarted, not before.
150 get_services().reload_role(target_role, wait=True)
151 except Exception as exc: # any reload failure becomes a toast, never a crash
152 call_from_thread(
153 app, app.notify, msg.MODEL_SWAP_FAILED.format(error=exc), severity="error"
154 )
155 return
156 call_from_thread(app, _finish)
158 def _finish() -> None:
159 on_done()
160 app.notify(msg.MODEL_SWAP_DONE.format(name=ref))
162 app.run_worker(_runner, thread=True, exit_on_error=False, name=_PERSIST_WORKER_NAME)