Coverage for src / osiris_cli / config_wizard.py: 0%

189 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-27 17:41 +0200

1from textual.app import ComposeResult 

2from textual.containers import Container, Vertical, Horizontal 

3from textual.screen import ModalScreen 

4from textual.widgets import Button, Label, Input, OptionList, Static, LoadingIndicator 

5from textual.events import Key 

6from .config import settings, KNOWN_PROVIDERS 

7from .client import fetch_models_dynamic 

8from pydantic import SecretStr 

9 

10class ConfigWizard(ModalScreen[bool]): 

11 """ 

12 Interactive Configuration Wizard (V5.0 - Smooth Interactions) 

13 """ 

14 

15 CSS = """ 

16 ConfigWizard { 

17 align: center middle; 

18 background: rgba(0,0,0,0.85); 

19 } 

20 

21 #wizard-container { 

22 width: 60%; 

23 height: auto; 

24 max-height: 80%; 

25 background: #09090b; 

26 border: round #3f3f46; 

27 layout: vertical; 

28 padding: 1 2; 

29 } 

30 

31 .header { 

32 text-align: center; 

33 text-style: bold; 

34 color: #818cf8;  

35 padding-bottom: 1; 

36 border-bottom: solid #27272a; 

37 margin-bottom: 1; 

38 } 

39 

40 .step-label { 

41 color: #00ff9d; 

42 text-style: bold; 

43 margin: 1 0; 

44 } 

45 

46 OptionList { 

47 height: 12; 

48 background: transparent; 

49 border: none; 

50 scrollbar-gutter: stable; 

51 } 

52  

53 OptionList > .option--highlight { 

54 background: #27272a; 

55 color: #ffffff; 

56 text-style: bold; 

57 } 

58 

59 Input { 

60 margin-bottom: 1; 

61 border: none; 

62 background: #18181b; 

63 color: #fff; 

64 } 

65  

66 Input:focus { 

67 border: solid #6366f1; 

68 } 

69 

70 #buttons { 

71 align: center bottom; 

72 height: auto; 

73 margin-top: 2; 

74 padding-top: 1; 

75 border-top: solid #27272a; 

76 } 

77 

78 Button { 

79 margin: 0 1; 

80 background: transparent; 

81 color: #a1a1aa; 

82 border: none; 

83 min-width: 10; 

84 } 

85  

86 Button:hover { 

87 color: #ffffff; 

88 text-style: bold; 

89 } 

90  

91 Button.primary { 

92 color: #818cf8; 

93 } 

94  

95 LoadingIndicator { 

96 height: 8; 

97 color: #6366f1; 

98 } 

99 .error-msg { 

100 color: red; 

101 text-style: bold; 

102 margin-bottom: 1; 

103 } 

104 """ 

105 

106 def __init__(self): 

107 super().__init__() 

108 self.current_step = 1 

109 self.selected_provider_key = None 

110 self.selected_provider_data = None 

111 self.entered_api_key = None 

112 self.selected_model = None 

113 self.fetched_models = [] 

114 

115 def compose(self) -> ComposeResult: 

116 with Vertical(id="wizard-container"): 

117 yield Label("SYSTEM CONFIGURATION", classes="header") 

118 

119 # Container for dynamic step content 

120 with Container(id="step-content"): 

121 pass 

122 

123 with Horizontal(id="buttons"): 

124 yield Button("Back", id="back") 

125 yield Button("Cancel", id="cancel") 

126 yield Button("Next", variant="primary", id="next") 

127 

128 def on_mount(self): 

129 self.show_step_1() 

130 

131 def show_step_1(self): 

132 """Step 1: Select Provider""" 

133 self.current_step = 1 

134 container = self.query_one("#step-content") 

135 container.remove_children() 

136 

137 container.mount(Label("1. Select Intelligence Provider", classes="step-label")) 

138 

139 options = OptionList(id="provider-list") 

140 for key, data in KNOWN_PROVIDERS.items(): 

141 configured = "" 

142 key_name = data.get("api_key_name") 

143 if key_name: 

144 val = getattr(settings, key_name, None) 

145 if isinstance(val, SecretStr): 

146 if val.get_secret_value(): configured = " [✓]" 

147 elif val: 

148 configured = " [✓]" 

149 elif key == "ollama": 

150 configured = " (Local)" 

151 

152 options.add_option(f"{data['name']}{configured}") 

153 

154 container.mount(options) 

155 options.focus() 

156 

157 def show_step_2(self, error_msg: str = None): 

158 """Step 2: Input API Key""" 

159 self.current_step = 2 

160 container = self.query_one("#step-content") 

161 container.remove_children() 

162 

163 api_key_name = self.selected_provider_data["api_key_name"] 

164 

165 if not api_key_name: 

166 # No key needed (e.g. Ollama) -> Go to Step 3 Load 

167 self.initiate_model_fetch() 

168 return 

169 

170 container.mount(Label(f"2. Authenticate {self.selected_provider_data['name']}", classes="step-label")) 

171 

172 if error_msg: 

173 container.mount(Label(f"{error_msg}", classes="error-msg")) 

174 

175 # Check if we already have one 

176 current_val = getattr(settings, api_key_name, "") 

177 current_key = "" 

178 if isinstance(current_val, SecretStr): 

179 current_key = current_val.get_secret_value() 

180 else: 

181 current_key = current_val or "" 

182 

183 placeholder = "Enter API Key (starts with sk-...)" 

184 if current_key: 

185 # Secure placeholder 

186 placeholder = f"Current Key Set (Press Enter to keep)" 

187 

188 inp = Input(placeholder=placeholder, password=True, id="api-key-input") 

189 # prefill if user already entered during this wizard 

190 if self.entered_api_key: 

191 inp.value = self.entered_api_key 

192 container.mount(inp) 

193 inp.focus() 

194 

195 def initiate_model_fetch(self): 

196 """Show loading and start fetch worker.""" 

197 self.current_step = 2.5 # Intermediate state 

198 container = self.query_one("#step-content") 

199 container.remove_children() 

200 

201 container.mount(Label("Connecting to Provider...", classes="step-label")) 

202 container.mount(LoadingIndicator()) 

203 

204 self.query_one("#next", Button).disabled = True 

205 self.run_worker(self.fetch_models_task()) 

206 

207 async def fetch_models_task(self): 

208 """Async worker to fetch models.""" 

209 # Use entered key or fallback to settings/env 

210 key = self.entered_api_key 

211 if not key and self.selected_provider_data["api_key_name"]: 

212 val = getattr(settings, self.selected_provider_data["api_key_name"], None) 

213 if isinstance(val, SecretStr): 

214 key = val.get_secret_value() 

215 else: 

216 key = val 

217 

218 dynamic_models = await fetch_models_dynamic(self.selected_provider_key, key) 

219 

220 if dynamic_models is None: 

221 self.show_step_2("Connection Failed. Invalid Key?") 

222 self.query_one("#next", Button).disabled = False 

223 # Preserve entered key 

224 if self.entered_api_key: 

225 self.query_one("#api-key-input", Input).value = self.entered_api_key 

226 return 

227 

228 if dynamic_models: 

229 self.fetched_models = dynamic_models 

230 else: 

231 self.fetched_models = self.selected_provider_data["models"] 

232 

233 # Proceed to Step 3 

234 self.show_step_3() 

235 self.query_one("#next", Button).disabled = False 

236 

237 def show_step_3(self): 

238 """Step 3: Select Model""" 

239 self.current_step = 3 

240 container = self.query_one("#step-content") 

241 container.remove_children() 

242 

243 container.mount(Label(f"3. Select Model ({len(self.fetched_models)} available)", classes="step-label")) 

244 

245 # Search Bar 

246 container.mount(Input(placeholder="Type to filter models...", id="model-search")) 

247 

248 options = OptionList(id="model-list") 

249 self._populate_model_list(options, "") 

250 

251 container.mount(options) 

252 self.query_one("#model-search").focus() 

253 

254 def on_input_changed(self, message: Input.Changed): 

255 if message.input.id == "model-search": 

256 options = self.query_one("#model-list", OptionList) 

257 self._populate_model_list(options, message.value) 

258 

259 def on_input_submitted(self, message: Input.Submitted): 

260 """Handle Enter key in Input fields to advance steps.""" 

261 if self.current_step == 3 and message.input.id == "model-search": 

262 self.process_next() 

263 elif self.current_step == 2 and message.input.id == "api-key-input": 

264 self.process_next() 

265 

266 def _populate_model_list(self, options: OptionList, filter_text: str): 

267 options.clear_options() 

268 models = self.fetched_models 

269 

270 filtered = [m for m in models if filter_text.lower() in m.lower()] 

271 

272 for model in filtered: 

273 options.add_option(model) 

274 

275 if filter_text and filter_text not in models: 

276 options.add_option(f"Use custom: {filter_text}") 

277 

278 # Reset highlight to 0 so Enter selects first result 

279 if filtered or filter_text: 

280 options.highlighted = 0 

281 

282 # --- INTERACTION HANDLERS --- 

283 

284 def on_option_list_option_selected(self, event: OptionList.OptionSelected): 

285 """Handle clicking/enter on a list item.""" 

286 self.process_next() 

287 

288 def on_button_pressed(self, event: Button.Pressed): 

289 if event.button.id == "cancel": 

290 self.dismiss(False) 

291 elif event.button.id == "back": 

292 self.process_prev() 

293 elif event.button.id == "next": 

294 self.process_next() 

295 

296 def on_key(self, event: Key): 

297 if event.key == "escape": 

298 self.dismiss(False) 

299 

300 def on_click(self, event): 

301 if self.get_widget_at(event.screen_x, event.screen_y) is self: 

302 self.dismiss(False) 

303 

304 def process_next(self): 

305 if self.current_step == 1: 

306 plist = self.query_one("#provider-list", OptionList) 

307 if plist.highlighted is None: return 

308 

309 keys = list(KNOWN_PROVIDERS.keys()) 

310 self.selected_provider_key = keys[plist.highlighted] 

311 self.selected_provider_data = KNOWN_PROVIDERS[self.selected_provider_key] 

312 self.show_step_2() 

313 

314 elif self.current_step == 2: 

315 inp = self.query_one("#api-key-input", Input) 

316 self.entered_api_key = inp.value.strip() 

317 self.initiate_model_fetch() 

318 

319 elif self.current_step == 3: 

320 mlist = self.query_one("#model-list", OptionList) 

321 search_inp = self.query_one("#model-search", Input) 

322 

323 if mlist.highlighted is not None: 

324 filter_text = search_inp.value 

325 models = self.fetched_models 

326 filtered = [m for m in models if filter_text.lower() in m.lower()] 

327 

328 idx = mlist.highlighted 

329 

330 if idx < len(filtered): 

331 raw = filtered[idx] 

332 self.selected_model = raw.split(" ")[0] 

333 else: 

334 self.selected_model = filter_text.strip() 

335 elif search_inp.value: 

336 self.selected_model = search_inp.value.strip() 

337 else: 

338 return 

339 

340 self.save_and_exit() 

341 

342 def save_and_exit(self): 

343 settings.provider = self.selected_provider_key 

344 

345 if self.entered_api_key: 

346 key_name = self.selected_provider_data["api_key_name"] 

347 if key_name: 

348 setattr(settings, key_name, self.entered_api_key) 

349 

350 if self.selected_model: 

351 settings.default_model = self.selected_model 

352 

353 settings.save() 

354 self.dismiss(True) 

355 

356 def process_prev(self): 

357 if self.current_step == 1: 

358 # back from first step acts like cancel 

359 self.dismiss(False) 

360 elif self.current_step == 2: 

361 self.show_step_1() 

362 elif self.current_step >= 3: 

363 # go back to step 2 (API key) 

364 self.show_step_2()