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
« 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
10class ConfigWizard(ModalScreen[bool]):
11 """
12 Interactive Configuration Wizard (V5.0 - Smooth Interactions)
13 """
15 CSS = """
16 ConfigWizard {
17 align: center middle;
18 background: rgba(0,0,0,0.85);
19 }
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 }
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 }
40 .step-label {
41 color: #00ff9d;
42 text-style: bold;
43 margin: 1 0;
44 }
46 OptionList {
47 height: 12;
48 background: transparent;
49 border: none;
50 scrollbar-gutter: stable;
51 }
53 OptionList > .option--highlight {
54 background: #27272a;
55 color: #ffffff;
56 text-style: bold;
57 }
59 Input {
60 margin-bottom: 1;
61 border: none;
62 background: #18181b;
63 color: #fff;
64 }
66 Input:focus {
67 border: solid #6366f1;
68 }
70 #buttons {
71 align: center bottom;
72 height: auto;
73 margin-top: 2;
74 padding-top: 1;
75 border-top: solid #27272a;
76 }
78 Button {
79 margin: 0 1;
80 background: transparent;
81 color: #a1a1aa;
82 border: none;
83 min-width: 10;
84 }
86 Button:hover {
87 color: #ffffff;
88 text-style: bold;
89 }
91 Button.primary {
92 color: #818cf8;
93 }
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 """
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 = []
115 def compose(self) -> ComposeResult:
116 with Vertical(id="wizard-container"):
117 yield Label("SYSTEM CONFIGURATION", classes="header")
119 # Container for dynamic step content
120 with Container(id="step-content"):
121 pass
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")
128 def on_mount(self):
129 self.show_step_1()
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()
137 container.mount(Label("1. Select Intelligence Provider", classes="step-label"))
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)"
152 options.add_option(f"{data['name']}{configured}")
154 container.mount(options)
155 options.focus()
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()
163 api_key_name = self.selected_provider_data["api_key_name"]
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
170 container.mount(Label(f"2. Authenticate {self.selected_provider_data['name']}", classes="step-label"))
172 if error_msg:
173 container.mount(Label(f"❌ {error_msg}", classes="error-msg"))
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 ""
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)"
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()
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()
201 container.mount(Label("Connecting to Provider...", classes="step-label"))
202 container.mount(LoadingIndicator())
204 self.query_one("#next", Button).disabled = True
205 self.run_worker(self.fetch_models_task())
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
218 dynamic_models = await fetch_models_dynamic(self.selected_provider_key, key)
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
228 if dynamic_models:
229 self.fetched_models = dynamic_models
230 else:
231 self.fetched_models = self.selected_provider_data["models"]
233 # Proceed to Step 3
234 self.show_step_3()
235 self.query_one("#next", Button).disabled = False
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()
243 container.mount(Label(f"3. Select Model ({len(self.fetched_models)} available)", classes="step-label"))
245 # Search Bar
246 container.mount(Input(placeholder="Type to filter models...", id="model-search"))
248 options = OptionList(id="model-list")
249 self._populate_model_list(options, "")
251 container.mount(options)
252 self.query_one("#model-search").focus()
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)
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()
266 def _populate_model_list(self, options: OptionList, filter_text: str):
267 options.clear_options()
268 models = self.fetched_models
270 filtered = [m for m in models if filter_text.lower() in m.lower()]
272 for model in filtered:
273 options.add_option(model)
275 if filter_text and filter_text not in models:
276 options.add_option(f"Use custom: {filter_text}")
278 # Reset highlight to 0 so Enter selects first result
279 if filtered or filter_text:
280 options.highlighted = 0
282 # --- INTERACTION HANDLERS ---
284 def on_option_list_option_selected(self, event: OptionList.OptionSelected):
285 """Handle clicking/enter on a list item."""
286 self.process_next()
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()
296 def on_key(self, event: Key):
297 if event.key == "escape":
298 self.dismiss(False)
300 def on_click(self, event):
301 if self.get_widget_at(event.screen_x, event.screen_y) is self:
302 self.dismiss(False)
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
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()
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()
319 elif self.current_step == 3:
320 mlist = self.query_one("#model-list", OptionList)
321 search_inp = self.query_one("#model-search", Input)
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()]
328 idx = mlist.highlighted
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
340 self.save_and_exit()
342 def save_and_exit(self):
343 settings.provider = self.selected_provider_key
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)
350 if self.selected_model:
351 settings.default_model = self.selected_model
353 settings.save()
354 self.dismiss(True)
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()