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

1"""Shared picker-dismiss logic for the model rail and settings screen.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6from collections.abc import Callable 

7from typing import TYPE_CHECKING 

8 

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 

15 

16if TYPE_CHECKING: 

17 from textual.app import App 

18 from textual.widget import Widget 

19 

20 from lilbee.cli.tui.screens.model_picker import PickerScope 

21 

22log = logging.getLogger(__name__) 

23 

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" 

27 

28 

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 

32 

33 for key, sc in model_field_to_picker_scope().items(): 

34 if sc == scope: 

35 return key 

36 raise KeyError(scope) 

37 

38 

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. 

48 

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. 

57 

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 

65 

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) 

76 

77 

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 ) 

88 

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)) 

95 

96 

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 

101 

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 ) 

106 

107 

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) 

120 

121 

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. 

126 

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 

138 

139 target_role = role # narrowed to non-None; bind for the worker closure 

140 app.notify(msg.MODEL_SWAP_APPLYING) 

141 

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) 

157 

158 def _finish() -> None: 

159 on_done() 

160 app.notify(msg.MODEL_SWAP_DONE.format(name=ref)) 

161 

162 app.run_worker(_runner, thread=True, exit_on_error=False, name=_PERSIST_WORKER_NAME)