Coverage for src / lilbee / cli / tui / screens / setup.py: 100%

156 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-04-29 19:16 +0000

1"""First-run setup — single-screen model picker with RAM-based recommendations. 

2 

3The wizard mirrors the catalog's grid aesthetic: one ``GridSelect`` per 

4section (chat, embed), pressing Enter on a card installs that model 

5immediately via ``TaskBarController.start_download``. No separate 

6Install & Go button, no Browse, no Skip — pick what you want, press 

7Esc when done. Downloads continue under the app-level controller, so 

8dismissing the wizard while they're in flight is fine. 

9 

10Scope: chat and embedding only. Vision and reranker roles are optional, 

11so they are configured post-setup via the catalog screen rather than 

12gating the first-run path on them. 

13""" 

14 

15from __future__ import annotations 

16 

17import contextlib 

18import logging 

19from typing import ClassVar 

20 

21from textual import on 

22from textual.app import ComposeResult 

23from textual.binding import Binding, BindingType 

24from textual.containers import VerticalScroll 

25from textual.screen import Screen 

26from textual.widgets import Label, Static 

27 

28from lilbee.catalog import FEATURED_CHAT, FEATURED_EMBEDDING, CatalogModel 

29from lilbee.cli.tui import messages as msg 

30from lilbee.cli.tui.app import apply_active_model 

31from lilbee.cli.tui.screens.catalog_utils import ( 

32 TableRow, 

33 catalog_to_row, 

34 parse_param_label, 

35) 

36from lilbee.cli.tui.widgets.grid_select import GridSelect 

37from lilbee.cli.tui.widgets.model_card import ModelCard 

38from lilbee.config import cfg 

39from lilbee.models import ModelTask, get_system_ram_gb 

40from lilbee.services import get_services, reset_services 

41 

42log = logging.getLogger(__name__) 

43 

44SETUP_CHAT_GRID_ID = "setup-chat-grid" 

45 

46 

47def _scan_installed_models() -> tuple[list[str], list[str]]: 

48 """List installed models from the registry, split into chat vs embedding.""" 

49 try: 

50 from lilbee.registry import ModelRegistry 

51 

52 registry = ModelRegistry(cfg.models_dir) 

53 chat: list[str] = [] 

54 embed: list[str] = [] 

55 for m in registry.list_installed(): 

56 if m.task == ModelTask.EMBEDDING: 

57 embed.append(m.ref) 

58 elif m.task == ModelTask.CHAT: 

59 chat.append(m.ref) 

60 return sorted(chat), sorted(embed) 

61 except Exception: 

62 return [], [] 

63 

64 

65def _installed_name_to_row(name: str, task: str) -> TableRow: 

66 """Create a minimal TableRow for an already-installed model.""" 

67 return TableRow( 

68 name=name, 

69 task=task, 

70 params=parse_param_label(name), 

71 size="--", 

72 quant="--", 

73 downloads="--", 

74 featured=False, 

75 installed=True, 

76 sort_downloads=0, 

77 sort_size=0.0, 

78 ref=name, 

79 ) 

80 

81 

82def _pick_recommended(ram_gb: float) -> tuple[CatalogModel, CatalogModel]: 

83 """Pick chat + embedding models appropriate for system RAM.""" 

84 eligible = [m for m in FEATURED_CHAT if m.min_ram_gb <= ram_gb] 

85 chat = max(eligible, key=lambda m: m.size_gb) if eligible else FEATURED_CHAT[0] 

86 embed = FEATURED_EMBEDDING[0] 

87 return chat, embed 

88 

89 

90def _pending_download(card: ModelCard | None) -> CatalogModel | None: 

91 """Return the CatalogModel to download for a non-installed card, or None.""" 

92 if card and not card.row.installed: 

93 return card.row.catalog_model 

94 return None 

95 

96 

97class SetupWizard(Screen[str | None]): 

98 """First-run setup — pick chat + embedding, Enter installs, Esc exits. 

99 

100 Each card you press Enter on: 

101 1. Becomes the saved selection for its task (chat or embedding). 

102 2. Triggers a download via the app's ``TaskBarController`` unless 

103 the card is already installed. 

104 3. Leaves the wizard open so you can pick the other task next. 

105 

106 Selections are persisted to settings eagerly (not at dismiss time), 

107 so Esc-ing out mid-wizard keeps your picks. 

108 """ 

109 

110 CSS_PATH = "setup.tcss" 

111 

112 BINDINGS: ClassVar[list[BindingType]] = [ 

113 Binding("escape", "cancel", "Done", show=True), 

114 Binding("tab", "app.focus_next", "Next", show=False), 

115 Binding("shift+tab", "app.focus_previous", "Prev", show=False), 

116 ] 

117 

118 def __init__(self) -> None: 

119 super().__init__() 

120 self._selections: dict[str, tuple[str | None, ModelCard | None]] = { 

121 ModelTask.CHAT: (None, None), 

122 ModelTask.EMBEDDING: (None, None), 

123 } 

124 self._chat_installed, self._embed_installed = _scan_installed_models() 

125 self._recommended_chat: CatalogModel | None = None 

126 self._recommended_embed: CatalogModel | None = None 

127 # Model refs already submitted to the controller (avoid duplicate 

128 # start_download calls when a card is re-selected by arrow + Enter). 

129 self._submitted: set[str] = set() 

130 

131 @property 

132 def _selected_chat(self) -> str | None: 

133 return self._selections[ModelTask.CHAT][0] 

134 

135 @property 

136 def _selected_embed(self) -> str | None: 

137 return self._selections[ModelTask.EMBEDDING][0] 

138 

139 def compose(self) -> ComposeResult: 

140 from textual.widgets import Footer 

141 

142 from lilbee.cli.tui.widgets.bottom_bars import BottomBars 

143 from lilbee.cli.tui.widgets.status_bar import ViewTabs 

144 from lilbee.cli.tui.widgets.task_bar import TaskBar 

145 from lilbee.cli.tui.widgets.top_bars import TopBars 

146 

147 with TopBars(): 

148 yield ViewTabs() 

149 yield Static(msg.SETUP_WELCOME, id="setup-title") 

150 yield Static(msg.SETUP_INTRO, id="setup-intro") 

151 yield VerticalScroll(id="setup-grid-container") 

152 with BottomBars(): 

153 yield Label(self._initial_hint_text(), id="setup-enter-hint") 

154 yield TaskBar() 

155 yield Footer() 

156 

157 def _initial_hint_text(self) -> str: 

158 """Return SETUP_RETURN_HINT when both roles already resolve, else SETUP_ENTER_HINT.""" 

159 if self._chat_installed and self._embed_installed: 

160 return msg.SETUP_RETURN_HINT 

161 return msg.SETUP_ENTER_HINT 

162 

163 def on_mount(self) -> None: 

164 self._build_grid() 

165 # Focus the chat-model grid so arrow keys / Enter work without a mouse. 

166 with contextlib.suppress(Exception): 

167 self.query_one(f"#{SETUP_CHAT_GRID_ID}", GridSelect).focus() 

168 

169 def _build_section( 

170 self, 

171 heading: str, 

172 models: tuple[CatalogModel, ...], 

173 installed_refs: set[str], 

174 widgets_out: list[Static | GridSelect], 

175 grid_id: str | None = None, 

176 ) -> list[ModelCard]: 

177 """Build a heading + GridSelect for a list of catalog models.""" 

178 widgets_out.append(Static(heading, classes="section-heading")) 

179 cards = [ModelCard(catalog_to_row(m, installed=m.ref in installed_refs)) for m in models] 

180 widgets_out.append(GridSelect(*cards, min_column_width=30, max_column_width=50, id=grid_id)) 

181 return cards 

182 

183 def _build_grid(self) -> None: 

184 """Build all model sections and pre-select recommended combo.""" 

185 ram_gb = get_system_ram_gb() 

186 rec_chat, rec_embed = _pick_recommended(ram_gb) 

187 self._recommended_chat = rec_chat 

188 self._recommended_embed = rec_embed 

189 

190 container = self.query_one("#setup-grid-container", VerticalScroll) 

191 widgets_to_mount: list[Static | GridSelect] = [] 

192 installed_refs = set(self._chat_installed) | set(self._embed_installed) 

193 

194 if self._chat_installed or self._embed_installed: 

195 widgets_to_mount.append(Static(msg.HEADING_INSTALLED, classes="section-heading")) 

196 installed_cards = [ 

197 ModelCard(_installed_name_to_row(n, ModelTask.CHAT)) for n in self._chat_installed 

198 ] + [ 

199 ModelCard(_installed_name_to_row(n, ModelTask.EMBEDDING)) 

200 for n in self._embed_installed 

201 ] 

202 widgets_to_mount.append( 

203 GridSelect(*installed_cards, min_column_width=30, max_column_width=50) 

204 ) 

205 

206 chat_cards = self._build_section( 

207 msg.SETUP_HEADING_CHAT, 

208 FEATURED_CHAT, 

209 installed_refs, 

210 widgets_to_mount, 

211 grid_id=SETUP_CHAT_GRID_ID, 

212 ) 

213 embed_cards = self._build_section( 

214 msg.SETUP_HEADING_EMBED, FEATURED_EMBEDDING, installed_refs, widgets_to_mount 

215 ) 

216 

217 container.mount_all(widgets_to_mount) 

218 self._preselect_recommended(chat_cards, embed_cards) 

219 

220 def _preselect_recommended( 

221 self, chat_cards: list[ModelCard], embed_cards: list[ModelCard] 

222 ) -> None: 

223 """Pre-select the RAM-appropriate recommended models (without installing).""" 

224 for cards, recommended in [ 

225 (chat_cards, self._recommended_chat), 

226 (embed_cards, self._recommended_embed), 

227 ]: 

228 if not recommended: 

229 continue 

230 for card in cards: 

231 cm = card.row.catalog_model 

232 if cm and cm.ref == recommended.ref: 

233 self._mark_selection(card, card.row.task) 

234 break 

235 

236 def _mark_selection(self, card: ModelCard, task: str) -> None: 

237 """Record a selection and repaint its card. No download yet.""" 

238 _ref, prev_card = self._selections[task] 

239 if prev_card is not None and prev_card is not card: 

240 prev_card.selected = False 

241 ref = card.row.ref or card.row.name 

242 card.selected = True 

243 self._selections[task] = (ref, card) 

244 

245 def _commit_selection(self, card: ModelCard, task: str) -> None: 

246 """Persist the selection to settings and submit a download if pending. 

247 

248 Called when the user presses Enter on a card. Saves the config 

249 fragment eagerly so Esc mid-wizard doesn't lose the pick. 

250 """ 

251 from lilbee.cli.tui.app import LilbeeApp 

252 

253 self._mark_selection(card, task) 

254 ref = self._selections[task][0] 

255 if ref is None: 

256 return 

257 if task == ModelTask.CHAT: 

258 apply_active_model(self.app, "chat_model", ref) 

259 elif task == ModelTask.EMBEDDING: 

260 # Pin a legacy store's identity to the OLD model BEFORE the cfg 

261 # mutation so the gate in store.search/add_chunks correctly detects 

262 # drift on the next op. See bb-x1qa. 

263 get_services().store.initialize_meta_if_legacy() 

264 apply_active_model(self.app, "embedding_model", ref) 

265 

266 pending = _pending_download(card) 

267 if ( 

268 pending is not None 

269 and pending.ref not in self._submitted 

270 and isinstance(self.app, LilbeeApp) 

271 ): 

272 self._submitted.add(pending.ref) 

273 self.app.task_bar.start_download(pending) 

274 

275 @on(GridSelect.Selected) 

276 def _on_grid_selected(self, event: GridSelect.Selected) -> None: 

277 """Enter on a card installs it (or records selection if already installed).""" 

278 if not isinstance(event.widget, ModelCard): 

279 return 

280 card = event.widget 

281 task = card.row.task 

282 if task in self._selections: 

283 self._commit_selection(card, task) 

284 

285 @on(GridSelect.LeaveDown) 

286 def _on_grid_leave_down(self, event: GridSelect.LeaveDown) -> None: 

287 """Arrow-down past the last card walks to the next focusable widget.""" 

288 self.focus_next() 

289 

290 @on(GridSelect.LeaveUp) 

291 def _on_grid_leave_up(self, event: GridSelect.LeaveUp) -> None: 

292 """Arrow-up past the first card walks to the previous focusable widget.""" 

293 self.focus_previous() 

294 

295 def action_cancel(self) -> None: 

296 """Escape dismisses the wizard; any submitted downloads keep running. 

297 

298 Selections are saved eagerly in ``_commit_selection``; we reset 

299 services here so the next screen pulls the updated config. 

300 """ 

301 if self._selected_chat or self._selected_embed: 

302 reset_services() 

303 self.dismiss("completed") 

304 else: 

305 self.dismiss("skipped")