66 Button ,
77 OptionList ,
88 Input ,
9+ Switch ,
10+ Select ,
911)
1012from textual .widgets .option_list import Option
11- from textual .containers import Vertical
13+ from textual .containers import Vertical , ScrollableContainer
1214from textual .screen import ModalScreen
1315from textual import work , on
1416
1517from transformerlab_cli .util import api
18+ from transformerlab_cli .commands .task import fetch_providers , build_launch_payload , launch_task_on_provider
19+
20+
21+ def _infer_param_type (value ) -> str :
22+ """Infer parameter type from a simple shorthand value."""
23+ if isinstance (value , bool ):
24+ return "bool"
25+ if isinstance (value , int ):
26+ return "int"
27+ if isinstance (value , float ):
28+ return "float"
29+ return "string"
30+
31+
32+ def _normalize_param (key : str , value ) -> dict :
33+ """Normalize a parameter definition to extended schema format."""
34+ if isinstance (value , dict ) and "type" in value :
35+ schema = dict (value )
36+ schema .setdefault ("title" , key )
37+ return schema
38+ return {
39+ "type" : _infer_param_type (value ),
40+ "default" : value ,
41+ "title" : key ,
42+ }
1643
1744
1845class TaskQueueModal (ModalScreen ):
@@ -23,13 +50,17 @@ class TaskQueueModal(ModalScreen):
2350 align: center middle;
2451 }
2552 #queue-modal {
26- width: 50 %;
27- min-width: 40 ;
28- height: auto ;
53+ width: 60 %;
54+ min-width: 50 ;
55+ max- height: 80% ;
2956 padding: 2;
3057 border: round $primary;
3158 background: $panel;
3259 }
60+ #queue-form-container {
61+ height: 1fr;
62+ max-height: 20;
63+ }
3364 .form-row {
3465 height: auto;
3566 margin-bottom: 1;
@@ -41,37 +72,262 @@ class TaskQueueModal(ModalScreen):
4172 .form-input {
4273 width: 100%;
4374 }
75+ Select {
76+ width: 100%;
77+ height: auto;
78+ min-height: 3;
79+ }
80+ Select > SelectCurrent {
81+ width: 1fr;
82+ height: auto;
83+ min-height: 1;
84+ padding: 0 1;
85+ }
86+ Select > SelectCurrent > Static {
87+ width: 1fr;
88+ }
89+ .form-switch {
90+ height: auto;
91+ }
4492 #queue-submit-btn {
4593 margin-top: 1;
4694 width: 100%;
4795 }
96+ #queue-spinner {
97+ height: 3;
98+ display: none;
99+ }
100+ #queue-status {
101+ text-align: center;
102+ height: auto;
103+ }
48104 """
49105
50106 BINDINGS = [("escape" , "dismiss" , "Close" )]
51107
52108 def __init__ (self , task_info : dict ) -> None :
53109 super ().__init__ ()
54110 self .task_info = task_info
111+ self .param_widgets : dict [str , str ] = {}
112+ self .providers : list [dict ] = []
113+ self .selected_provider_id : str = task_info .get ("provider_id" , "" )
55114
56115 def compose (self ) -> ComposeResult :
57116 task_name = self .task_info .get ("name" , "Unknown" )
117+ parameters = self .task_info .get ("parameters" , {})
118+
58119 with Vertical (id = "queue-modal" ):
59120 yield Label (f"[b]Queue Task: { task_name } [/b]" )
60121
61122 with Vertical (classes = "form-row" ):
62- yield Label ("CPU Resources:" , classes = "form-label" )
63- yield Input (placeholder = "e.g. 2" , id = "cpu-input" , classes = "form-input" )
123+ yield Label ("Provider:" , classes = "form-label" )
124+ yield Select (
125+ [("Loading..." , "_loading" )],
126+ value = "_loading" ,
127+ allow_blank = False ,
128+ id = "provider-select" ,
129+ classes = "form-input" ,
130+ )
131+ # yield Label("[DEBUG] Selected: (none)", id="debug-provider-label")
64132
65- with Vertical (classes = "form-row" ):
66- yield Label ("Memory Resources:" , classes = "form-label" )
67- yield Input (placeholder = "e.g. 4GB" , id = "memory-input" , classes = "form-input" )
133+ with ScrollableContainer (id = "queue-form-container" ):
134+ yield Label ("[b]Task Parameters[/b]" )
135+ if not parameters :
136+ yield Label ("[dim]No configurable parameters[/dim]" )
137+ else :
138+ for key , raw_value in parameters .items ():
139+ schema = _normalize_param (key , raw_value )
140+ yield from self ._render_param_field (key , schema )
68141
69142 yield Button ("Queue" , id = "queue-submit-btn" , variant = "primary" )
143+ yield LoadingIndicator (id = "queue-spinner" )
144+ yield Label ("" , id = "queue-status" )
145+
146+ def on_mount (self ) -> None :
147+ self ._fetch_providers_async ()
148+
149+ @work (thread = True )
150+ def _fetch_providers_async (self ) -> None :
151+ """Fetch available compute providers from the API."""
152+ providers = fetch_providers ()
153+ self .app .call_from_thread (self .populate_providers , providers )
154+
155+ def populate_providers (self , providers : list [dict ]) -> None :
156+ """Populate the provider select widget."""
157+ self .providers = providers
158+ provider_select = self .query_one ("#provider-select" , Select )
159+
160+ if not providers :
161+ with provider_select .prevent (Select .Changed ):
162+ provider_select .set_options ([("No providers available" , "_none" )])
163+ provider_select .value = "_none"
164+ return
165+
166+ options = [(p .get ("name" , p .get ("id" )), p .get ("id" )) for p in providers ]
167+
168+ task_provider_id = self .task_info .get ("provider_id" , "" )
169+ if task_provider_id and any (p .get ("id" ) == task_provider_id for p in providers ):
170+ initial_value = task_provider_id
171+ else :
172+ initial_value = providers [0 ].get ("id" )
173+
174+ with provider_select .prevent (Select .Changed ):
175+ provider_select .set_options (options )
176+ provider_select .value = initial_value
177+
178+ self .selected_provider_id = initial_value
179+ # self._update_debug_label(initial_value)
180+
181+ # def _update_debug_label(self, value: str) -> None:
182+ # """Update the debug label with current selection."""
183+ # try:
184+ # debug_label = self.query_one("#debug-provider-label", Label)
185+ # provider_name = next((p.get("name") for p in self.providers if p.get("id") == value), value)
186+ # debug_label.update(f"[DEBUG] Selected: {provider_name} (id={value})")
187+ # except Exception:
188+ # pass
189+
190+ @on (Select .Changed , "#provider-select" )
191+ def on_provider_changed (self , event : Select .Changed ) -> None :
192+ if event .value != Select .BLANK :
193+ self .selected_provider_id = str (event .value )
194+ # self._update_debug_label(str(event.value))
195+
196+ def _render_param_field (self , key : str , schema : dict ) -> ComposeResult :
197+ """Render a single parameter field based on its schema."""
198+ param_type = schema .get ("type" , "string" )
199+ title = schema .get ("title" , key )
200+ default = schema .get ("default" , "" )
201+ ui_widget = schema .get ("ui_widget" )
202+ options = schema .get ("options" , [])
203+
204+ widget_id = f"param-{ key } "
205+ self .param_widgets [key ] = widget_id
206+
207+ with Vertical (classes = "form-row" ):
208+ yield Label (f"{ title } :" , classes = "form-label" )
209+
210+ if param_type == "bool" :
211+ yield Switch (value = bool (default ), id = widget_id , classes = "form-switch" )
212+
213+ elif param_type == "enum" or (ui_widget == "select" and options ):
214+ select_options = [(opt , opt ) for opt in options ]
215+ yield Select (
216+ select_options ,
217+ value = str (default ) if default else Select .BLANK ,
218+ id = widget_id ,
219+ classes = "form-input" ,
220+ )
221+
222+ elif ui_widget == "password" :
223+ yield Input (
224+ value = str (default ) if default else "" ,
225+ password = True ,
226+ id = widget_id ,
227+ classes = "form-input" ,
228+ )
229+
230+ else :
231+ placeholder = ""
232+ if param_type == "int" :
233+ placeholder = f"Integer (default: { default } )"
234+ elif param_type == "float" :
235+ placeholder = f"Float (default: { default } )"
236+ else :
237+ placeholder = f"default: { default } " if default else ""
238+
239+ yield Input (
240+ value = str (default ) if default else "" ,
241+ placeholder = placeholder ,
242+ id = widget_id ,
243+ classes = "form-input" ,
244+ )
245+
246+ def _collect_param_values (self ) -> dict :
247+ """Collect current values from all parameter widgets."""
248+ values = {}
249+ parameters = self .task_info .get ("parameters" , {})
250+
251+ for key , raw_value in parameters .items ():
252+ schema = _normalize_param (key , raw_value )
253+ widget_id = self .param_widgets .get (key )
254+ if not widget_id :
255+ continue
256+
257+ try :
258+ widget = self .query_one (f"#{ widget_id } " )
259+ except Exception :
260+ continue
261+
262+ if isinstance (widget , Switch ):
263+ values [key ] = widget .value
264+ elif isinstance (widget , Select ):
265+ values [key ] = widget .value if widget .value != Select .BLANK else schema .get ("default" , "" )
266+ elif isinstance (widget , Input ):
267+ raw = widget .value
268+ param_type = schema .get ("type" , "string" )
269+ if param_type == "int" :
270+ try :
271+ values [key ] = int (raw ) if raw else schema .get ("default" , 0 )
272+ except ValueError :
273+ values [key ] = schema .get ("default" , 0 )
274+ elif param_type == "float" :
275+ try :
276+ values [key ] = float (raw ) if raw else schema .get ("default" , 0.0 )
277+ except ValueError :
278+ values [key ] = schema .get ("default" , 0.0 )
279+ else :
280+ values [key ] = raw if raw else schema .get ("default" , "" )
281+
282+ return values
70283
71284 def on_button_pressed (self , event : Button .Pressed ) -> None :
72285 if event .button .id == "queue-submit-btn" :
73- self .notify ("Queue functionality not implemented yet." , severity = "information" )
74- self .dismiss ()
286+ self ._show_spinner (True , "Queuing task..." )
287+ self .queue_task ()
288+
289+ def _show_spinner (self , show : bool , message : str = "" ) -> None :
290+ """Show or hide the spinner and update status message."""
291+ spinner = self .query_one ("#queue-spinner" , LoadingIndicator )
292+ status = self .query_one ("#queue-status" , Label )
293+ button = self .query_one ("#queue-submit-btn" , Button )
294+
295+ spinner .display = show
296+ button .display = not show
297+ status .update (message )
298+
299+ @work (thread = True )
300+ def queue_task (self ) -> None :
301+ """Queue the task by calling the provider launch endpoint."""
302+ param_values = self ._collect_param_values ()
303+ task = self .task_info
304+
305+ provider_id = self .selected_provider_id
306+ if not provider_id :
307+ self .app .call_from_thread (self ._show_spinner , False )
308+ self .app .call_from_thread (self .notify , "Please select a provider before queuing." , severity = "error" )
309+ return
310+
311+ selected_provider = next ((p for p in self .providers if p .get ("id" ) == provider_id ), None )
312+ provider_name = selected_provider .get ("name" ) if selected_provider else task .get ("provider_name" )
313+
314+ payload = build_launch_payload (task , provider_name , param_values )
315+
316+ try :
317+ data = launch_task_on_provider (provider_id , payload )
318+ job_id = data .get ("job_id" , "unknown" )
319+ self .app .call_from_thread (
320+ self .notify , f"Task queued successfully. Job ID: { job_id } " , severity = "information"
321+ )
322+ self .app .call_from_thread (self ._dismiss_all_modals )
323+ except RuntimeError as e :
324+ self .app .call_from_thread (self ._show_spinner , False )
325+ self .app .call_from_thread (self .notify , str (e ), severity = "error" )
326+
327+ def _dismiss_all_modals (self ) -> None :
328+ """Dismiss this modal and the parent TaskListModal to return to homepage."""
329+ self .dismiss ()
330+ self .app .pop_screen ()
75331
76332
77333class TaskListModal (ModalScreen ):
@@ -101,8 +357,9 @@ class TaskListModal(ModalScreen):
101357
102358 BINDINGS = [("escape" , "dismiss" , "Close" )]
103359
104- def __init__ (self ) -> None :
360+ def __init__ (self , experiment_id : str = "default" ) -> None :
105361 super ().__init__ ()
362+ self .experiment_id = experiment_id
106363 self .tasks_data : list [dict ] = []
107364
108365 def compose (self ) -> ComposeResult :
@@ -121,7 +378,7 @@ def on_mount(self) -> None:
121378 @work (thread = True )
122379 def fetch_tasks (self ) -> None :
123380 try :
124- response = api .get ("/tasks /list" )
381+ response = api .get (f"/experiment/ { self . experiment_id } /task /list" )
125382 if response .status_code == 200 :
126383 data = response .json ()
127384 else :
@@ -145,11 +402,12 @@ def populate_tasks(self, tasks: list[dict]) -> None:
145402
146403 self .tasks_data = tasks
147404 option_list .clear_options ()
405+ print ("[DEBUG] Populating tasks:" , tasks )
148406 for task in tasks :
149407 task_id = task .get ("id" , "?" )
150408 task_name = task .get ("name" , "Unknown" )
151- task_type = task .get ("type" , "N/A" )
152- option_list .add_option (Option (f"{ task_name } ( { task_type } ) " , id = task_id ))
409+ # task_type = task.get("type", "N/A")
410+ option_list .add_option (Option (f"{ task_name } " , id = task_id ))
153411
154412 @on (OptionList .OptionSelected , "#task-option-list" )
155413 def on_task_selected (self , event : OptionList .OptionSelected ) -> None :
0 commit comments