"""Modern Textual UI for driving Medeia-Macina pipelines.""" from __future__ import annotations import json import re import sys from pathlib import Path from typing import Any, List, Optional, Sequence, Tuple from textual import on, work from textual.app import App, ComposeResult from textual.binding import Binding from textual.events import Key from textual.containers import Container, Horizontal, Vertical from textual.screen import ModalScreen from textual.widgets import Button, DataTable, Footer, Header, Input, Label, OptionList, Select, Static, TextArea from textual.widgets.option_list import Option BASE_DIR = Path(__file__).resolve().parent ROOT_DIR = BASE_DIR.parent for path in (BASE_DIR, ROOT_DIR): str_path = str(path) if str_path not in sys.path: sys.path.insert(0, str_path) from pipeline_runner import PipelineRunResult # type: ignore # noqa: E402 from result_table import ResultTable # type: ignore # noqa: E402 from config import load_config # type: ignore # noqa: E402 from Store.registry import Store as StoreRegistry # type: ignore # noqa: E402 from cmdlet_catalog import ensure_registry_loaded, list_cmdlet_names # type: ignore # noqa: E402 from cli_syntax import validate_pipeline_text # type: ignore # noqa: E402 from pipeline_runner import PipelineRunner # type: ignore # noqa: E402 def _dedup_preserve_order(items: List[str]) -> List[str]: out: List[str] = [] seen: set[str] = set() for raw in items: s = str(raw or "").strip() if not s: continue key = s.lower() if key in seen: continue seen.add(key) out.append(s) return out def _extract_tag_names(emitted: Sequence[Any]) -> List[str]: tags: List[str] = [] for obj in emitted or []: try: if hasattr(obj, "tag_name"): val = getattr(obj, "tag_name") if val: tags.append(str(val)) continue except Exception: pass if isinstance(obj, dict): for k in ("tag_name", "tag", "name", "value"): v = obj.get(k) if isinstance(v, str) and v.strip(): tags.append(v.strip()) break continue return _dedup_preserve_order(tags) class TextPopup(ModalScreen[None]): def __init__(self, *, title: str, text: str) -> None: super().__init__() self._title = str(title) self._text = str(text or "") def compose(self) -> ComposeResult: yield Static(self._title, id="popup-title") yield TextArea(self._text, id="popup-text", read_only=True) yield Button("Close", id="popup-close") def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "popup-close": self.dismiss(None) class TagEditorPopup(ModalScreen[None]): def __init__(self, *, seeds: Any, store_name: str, file_hash: Optional[str]) -> None: super().__init__() self._seeds = seeds self._store = str(store_name or "").strip() self._hash = str(file_hash or "").strip() if file_hash else "" self._original_tags: List[str] = [] self._status: Optional[Static] = None self._editor: Optional[TextArea] = None def compose(self) -> ComposeResult: yield Static("Tags", id="popup-title") yield TextArea("", id="tags-editor") with Horizontal(id="tags-buttons"): yield Button("Save", id="tags-save") yield Button("Close", id="tags-close") yield Static("", id="tags-status") def on_mount(self) -> None: self._status = self.query_one("#tags-status", Static) self._editor = self.query_one("#tags-editor", TextArea) self._set_status("Loading tags…") self._load_tags_background() def _set_status(self, msg: str) -> None: if self._status: self._status.update(str(msg or "")) @work(thread=True) def _load_tags_background(self) -> None: app = self.app # PipelineHubApp try: runner: PipelineRunner = getattr(app, "executor") cmd = f"@1 | get-tag -emit" res = runner.run_pipeline(cmd, seeds=self._seeds, isolate=True) tags = _extract_tag_names(res.emitted) except Exception as exc: tags = [] try: app.call_from_thread(self._set_status, f"Error: {type(exc).__name__}: {exc}") except Exception: self._set_status(f"Error: {type(exc).__name__}: {exc}") self._original_tags = tags try: app.call_from_thread(self._apply_loaded_tags, tags) except Exception: self._apply_loaded_tags(tags) def _apply_loaded_tags(self, tags: List[str]) -> None: if self._editor: self._editor.text = "\n".join(tags) self._set_status(f"Loaded {len(tags)} tag(s)") def _parse_editor_tags(self) -> List[str]: raw = "" try: raw = str(self._editor.text or "") if self._editor else "" except Exception: raw = "" lines = [t.strip() for t in raw.replace("\r\n", "\n").split("\n")] return _dedup_preserve_order([t for t in lines if t]) def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "tags-close": self.dismiss(None) return if event.button.id == "tags-save": self._save_tags() def _save_tags(self) -> None: desired = self._parse_editor_tags() current = _dedup_preserve_order(list(self._original_tags or [])) desired_set = {t.lower() for t in desired} current_set = {t.lower() for t in current} to_add = [t for t in desired if t.lower() not in current_set] to_del = [t for t in current if t.lower() not in desired_set] if not to_add and not to_del: self._set_status("No changes") return self._set_status("Saving…") self._save_tags_background(to_add, to_del, desired) @work(thread=True) def _save_tags_background(self, to_add: List[str], to_del: List[str], desired: List[str]) -> None: app = self.app # PipelineHubApp try: runner: PipelineRunner = getattr(app, "executor") store_tok = json.dumps(self._store) query_chunk = f" -query {json.dumps(f'hash:{self._hash}')}" if self._hash else "" failures: List[str] = [] if to_del: del_args = " ".join(json.dumps(t) for t in to_del) del_cmd = f"@1 | delete-tag -store {store_tok}{query_chunk} {del_args}" del_res = runner.run_pipeline(del_cmd, seeds=self._seeds, isolate=True) if not getattr(del_res, "success", False): failures.append(str(getattr(del_res, "error", "") or getattr(del_res, "stderr", "") or "delete-tag failed").strip()) if to_add: add_args = " ".join(json.dumps(t) for t in to_add) add_cmd = f"@1 | add-tag -store {store_tok}{query_chunk} {add_args}" add_res = runner.run_pipeline(add_cmd, seeds=self._seeds, isolate=True) if not getattr(add_res, "success", False): failures.append(str(getattr(add_res, "error", "") or getattr(add_res, "stderr", "") or "add-tag failed").strip()) if failures: msg = failures[0] try: app.call_from_thread(self._set_status, f"Error: {msg}") except Exception: self._set_status(f"Error: {msg}") return self._original_tags = list(desired) try: app.call_from_thread(self._set_status, f"Saved (+{len(to_add)}, -{len(to_del)})") except Exception: self._set_status(f"Saved (+{len(to_add)}, -{len(to_del)})") except Exception as exc: try: app.call_from_thread(self._set_status, f"Error: {type(exc).__name__}: {exc}") except Exception: self._set_status(f"Error: {type(exc).__name__}: {exc}") class PipelineHubApp(App): """Textual front-end that executes cmdlet pipelines inline.""" CSS_PATH = "tui.tcss" BINDINGS = [ Binding("ctrl+enter", "run_pipeline", "Run Pipeline"), Binding("f5", "refresh_workers", "Refresh Workers"), Binding("ctrl+l", "focus_command", "Focus Input", show=False), Binding("ctrl+g", "focus_logs", "Focus Logs", show=False), ] def __init__(self) -> None: super().__init__() self.executor = PipelineRunner() self.result_items: List[Any] = [] self.log_lines: List[str] = [] self.command_input: Optional[Input] = None self.store_select: Optional[Select] = None self.path_input: Optional[Input] = None self.log_output: Optional[TextArea] = None self.results_table: Optional[DataTable] = None self.worker_table: Optional[DataTable] = None self.status_panel: Optional[Static] = None self.current_result_table: Optional[ResultTable] = None self.suggestion_list: Optional[OptionList] = None self._cmdlet_names: List[str] = [] self._pipeline_running = False self._pipeline_worker: Any = None self._selected_row_index: int = 0 # ------------------------------------------------------------------ # Layout # ------------------------------------------------------------------ def compose(self) -> ComposeResult: # noqa: D401 - Textual compose hook yield Header(show_clock=True) with Container(id="app-shell"): with Vertical(id="command-pane"): with Horizontal(id="command-row"): yield Input(placeholder="Enter pipeline command...", id="pipeline-input") yield Button("Run", id="run-button") yield Button("Tags", id="tags-button") yield Button("Metadata", id="metadata-button") yield Button("Relationships", id="relationships-button") yield Static("Ready", id="status-panel") yield OptionList(id="cmd-suggestions") with Vertical(id="results-pane"): yield Label("Results", classes="section-title") yield DataTable(id="results-table") with Vertical(id="bottom-pane"): yield Label("Store + Output", classes="section-title") with Horizontal(id="store-row"): yield Select([], id="store-select") yield Input(placeholder="Output path (optional)", id="output-path") with Horizontal(id="logs-workers-row"): with Vertical(id="logs-pane"): yield Label("Logs", classes="section-title") yield TextArea(id="log-output", read_only=True) with Vertical(id="workers-pane"): yield Label("Workers", classes="section-title") yield DataTable(id="workers-table") yield Footer() def on_mount(self) -> None: self.command_input = self.query_one("#pipeline-input", Input) self.status_panel = self.query_one("#status-panel", Static) self.results_table = self.query_one("#results-table", DataTable) self.worker_table = self.query_one("#workers-table", DataTable) self.log_output = self.query_one("#log-output", TextArea) self.store_select = self.query_one("#store-select", Select) self.path_input = self.query_one("#output-path", Input) self.suggestion_list = self.query_one("#cmd-suggestions", OptionList) if self.suggestion_list: self.suggestion_list.display = False if self.results_table: self.results_table.cursor_type = "row" self.results_table.zebra_stripes = True self.results_table.add_columns("Row", "Title", "Source", "File") if self.worker_table: self.worker_table.add_columns("ID", "Type", "Status", "Details") self._populate_store_options() self._load_cmdlet_names() if self.executor.worker_manager: self.set_interval(2.0, self.refresh_workers) self.refresh_workers() if self.command_input: self.command_input.focus() # ------------------------------------------------------------------ # Actions # ------------------------------------------------------------------ def action_focus_command(self) -> None: if self.command_input: self.command_input.focus() def action_focus_logs(self) -> None: if self.log_output: self.log_output.focus() def action_run_pipeline(self) -> None: if self._pipeline_running: # Self-heal if the background worker already stopped (e.g. error in thread). worker = self._pipeline_worker try: is_running = bool(getattr(worker, "is_running", False)) except Exception: is_running = True if (worker is None) or (not is_running): self._pipeline_running = False self._pipeline_worker = None else: self.notify("Pipeline already running", severity="warning", timeout=3) return if not self.command_input: return pipeline_text = self.command_input.value.strip() if not pipeline_text: self.notify("Enter a pipeline to run", severity="warning", timeout=3) return pipeline_text = self._apply_store_path_and_tags(pipeline_text) self._pipeline_running = True self._set_status("Running…", level="info") self._clear_log() self._append_log_line(f"$ {pipeline_text}") self._clear_results() self._pipeline_worker = self._run_pipeline_background(pipeline_text) @on(Input.Changed, "#pipeline-input") def on_pipeline_input_changed(self, event: Input.Changed) -> None: text = str(event.value or "") self._update_suggestions(text) self._update_syntax_status(text) @on(OptionList.OptionSelected, "#cmd-suggestions") def on_suggestion_selected(self, event: OptionList.OptionSelected) -> None: if not self.command_input or not self.suggestion_list: return try: suggestion = str(event.option.prompt) except Exception: return new_text = self._apply_suggestion_to_text(str(self.command_input.value or ""), suggestion) self.command_input.value = new_text self.suggestion_list.display = False self.command_input.focus() def action_refresh_workers(self) -> None: self.refresh_workers() # ------------------------------------------------------------------ # Event handlers # ------------------------------------------------------------------ def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "run-button": self.action_run_pipeline() elif event.button.id == "tags-button": self._open_tags_popup() elif event.button.id == "metadata-button": self._open_metadata_popup() elif event.button.id == "relationships-button": self._open_relationships_popup() def on_input_submitted(self, event: Input.Submitted) -> None: if event.input.id == "pipeline-input": self.action_run_pipeline() def on_key(self, event: Key) -> None: # Make Tab accept autocomplete when typing commands. if event.key != "tab": return if not self.command_input or not self.command_input.has_focus: return suggestion = self._get_first_suggestion() if not suggestion: return self.command_input.value = self._apply_suggestion_to_text(str(self.command_input.value or ""), suggestion) if self.suggestion_list: self.suggestion_list.display = False event.prevent_default() event.stop() def _get_first_suggestion(self) -> str: if not self.suggestion_list or not bool(getattr(self.suggestion_list, "display", False)): return "" # Textual OptionList API differs across versions; handle best-effort. try: options = list(getattr(self.suggestion_list, "options", []) or []) if options: first = options[0] return str(getattr(first, "prompt", "") or "") except Exception: pass return "" def _populate_store_options(self) -> None: """Populate the store dropdown from the configured Store registry.""" if not self.store_select: return try: cfg = load_config() or {} except Exception: cfg = {} stores: List[str] = [] try: stores = StoreRegistry(config=cfg, suppress_debug=True).list_backends() except Exception: stores = [] # Always offer a reasonable default even if config is missing. if "local" not in [s.lower() for s in stores]: stores = ["local", *stores] options = [(name, name) for name in stores] try: self.store_select.set_options(options) if options: current = getattr(self.store_select, "value", None) # Textual Select uses a sentinel for "no selection". if (current is None) or (current == "") or (current is Select.BLANK): self.store_select.value = options[0][1] except Exception: pass def _get_selected_store(self) -> Optional[str]: if not self.store_select: return None try: value = getattr(self.store_select, "value", None) except Exception: return None if value is None or value is Select.BLANK: return None try: text = str(value).strip() except Exception: return None if not text or text == "Select.BLANK": return None return text def _apply_store_path_and_tags(self, pipeline_text: str) -> str: """Apply store/path/tags UI fields to the pipeline text. Rules (simple + non-destructive): - If output path is set and the first stage is download-media and has no -path/--path, append -path. - If a store is selected and pipeline has no add-file stage, append add-file -store . """ base = str(pipeline_text or "").strip() if not base: return base selected_store = self._get_selected_store() output_path = "" if self.path_input: try: output_path = str(self.path_input.value or "").strip() except Exception: output_path = "" stages = [s.strip() for s in base.split("|") if s.strip()] if not stages: return base # Identify first stage command name for conservative auto-augmentation. first_stage_cmd = "" try: first_stage_cmd = str(stages[0].split()[0]).replace("_", "-").strip().lower() if stages[0].split() else "" except Exception: first_stage_cmd = "" # Apply -path to download-media first stage (only if missing) if output_path: first = stages[0] low = first.lower() if low.startswith("download-media") and " -path" not in low and " --path" not in low: stages[0] = f"{first} -path {json.dumps(output_path)}" joined = " | ".join(stages) low_joined = joined.lower() # Only auto-append add-file for download pipelines. should_auto_add_file = bool( selected_store and ("add-file" not in low_joined) and (first_stage_cmd in {"download-media", "download-file", "download-torrent"}) ) if should_auto_add_file: store_token = json.dumps(selected_store) joined = f"{joined} | add-file -store {store_token}" return joined def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: if not self.results_table or event.control is not self.results_table: return index = int(event.cursor_row or 0) if index < 0: index = 0 self._selected_row_index = index # ------------------------------------------------------------------ # Pipeline execution helpers # ------------------------------------------------------------------ @work(exclusive=True, thread=True) def _run_pipeline_background(self, pipeline_text: str) -> None: try: run_result = self.executor.run_pipeline(pipeline_text, on_log=self._log_from_worker) except Exception as exc: # Ensure the UI never gets stuck in "running" state. run_result = PipelineRunResult( pipeline=str(pipeline_text or ""), success=False, error=f"{type(exc).__name__}: {exc}", stderr=f"{type(exc).__name__}: {exc}", ) self.call_from_thread(self._on_pipeline_finished, run_result) def _on_pipeline_finished(self, run_result: PipelineRunResult) -> None: self._pipeline_running = False self._pipeline_worker = None status_level = "success" if run_result.success else "error" status_text = "Completed" if run_result.success else "Failed" self._set_status(status_text, level=status_level) if not run_result.success: self.notify(run_result.error or "Pipeline failed", severity="error", timeout=6) else: self.notify("Pipeline completed", timeout=3) if run_result.stdout.strip(): self._append_log_line("stdout:") self._append_block(run_result.stdout) if run_result.stderr.strip(): self._append_log_line("stderr:") self._append_block(run_result.stderr) for stage in run_result.stages: summary = f"[{stage.status}] {stage.name} -> {len(stage.emitted)} item(s)" if stage.error: summary += f" ({stage.error})" self._append_log_line(summary) emitted = run_result.emitted if isinstance(emitted, list): self.result_items = emitted elif emitted: self.result_items = [emitted] else: self.result_items = [] self.current_result_table = run_result.result_table self._populate_results_table() self.refresh_workers() if self.result_items: self._selected_row_index = 0 def _log_from_worker(self, message: str) -> None: self.call_from_thread(self._append_log_line, message) # ------------------------------------------------------------------ # UI helpers # ------------------------------------------------------------------ def _populate_results_table(self) -> None: if not self.results_table: return self.results_table.clear(columns=True) if self.current_result_table and self.current_result_table.rows: # Use ResultTable headers from the first row first_row = self.current_result_table.rows[0] headers = ["#"] + [col.name for col in first_row.columns] self.results_table.add_columns(*headers) rows = self.current_result_table.to_datatable_rows() for idx, row_values in enumerate(rows, 1): self.results_table.add_row(str(idx), *row_values, key=str(idx - 1)) else: # Fallback or empty state self.results_table.add_columns("Row", "Title", "Source", "File") if not self.result_items: self.results_table.add_row("—", "No results", "", "") return # Fallback for items without a table for idx, item in enumerate(self.result_items, start=1): self.results_table.add_row(str(idx), str(item), "—", "—", key=str(idx - 1)) def _load_cmdlet_names(self) -> None: try: ensure_registry_loaded() names = list_cmdlet_names() or [] self._cmdlet_names = sorted({str(n).replace("_", "-") for n in names if str(n).strip()}) except Exception: self._cmdlet_names = [] def _update_syntax_status(self, text: str) -> None: if self._pipeline_running: return raw = str(text or "").strip() if not raw: self._set_status("Ready", level="info") return try: err = validate_pipeline_text(raw) except Exception: err = None if err: self._set_status(err.message, level="error") else: self._set_status("Ready", level="info") def _update_suggestions(self, text: str) -> None: if not self.suggestion_list: return raw = str(text or "") prefix = self._current_cmd_prefix(raw) if not prefix: self.suggestion_list.display = False return pref_low = prefix.lower() matches = [n for n in self._cmdlet_names if n.lower().startswith(pref_low)] matches = matches[:10] if not matches: self.suggestion_list.display = False return try: self.suggestion_list.clear_options() # type: ignore[attr-defined] except Exception: try: # Fallback for older/newer Textual APIs. self.suggestion_list.options = [] # type: ignore[attr-defined] except Exception: pass try: self.suggestion_list.add_options([Option(m) for m in matches]) # type: ignore[attr-defined] except Exception: try: self.suggestion_list.options = [Option(m) for m in matches] # type: ignore[attr-defined] except Exception: pass self.suggestion_list.display = True @staticmethod def _current_cmd_prefix(text: str) -> str: """Best-effort prefix for cmdlet name completion. Completes the token immediately after start-of-line or a '|'. """ raw = str(text or "") # Find the segment after the last pipe. segment = raw.split("|")[-1] # Remove leading whitespace. segment = segment.lstrip() if not segment: return "" # Only complete the first token of the segment. m = re.match(r"([A-Za-z0-9_\-]*)", segment) return m.group(1) if m else "" @staticmethod def _apply_suggestion_to_text(text: str, suggestion: str) -> str: raw = str(text or "") parts = raw.split("|") if not parts: return suggestion last = parts[-1] # Preserve leading spaces after the pipe. leading = "".join(ch for ch in last if ch.isspace()) trimmed = last.lstrip() # Replace first token in last segment. replaced = re.sub(r"^[A-Za-z0-9_\-]*", suggestion, trimmed) parts[-1] = leading + replaced return "|".join(parts) def _resolve_selected_item(self) -> Tuple[Optional[Any], Optional[str], Optional[str]]: """Return (item, store_name, hash) for the currently selected row.""" index = int(getattr(self, "_selected_row_index", 0) or 0) if index < 0: index = 0 item: Any = None # Prefer mapping displayed table row -> source item. if self.current_result_table and 0 <= index < len(getattr(self.current_result_table, "rows", []) or []): row = self.current_result_table.rows[index] src_idx = getattr(row, "source_index", None) if isinstance(src_idx, int) and 0 <= src_idx < len(self.result_items): item = self.result_items[src_idx] if item is None and 0 <= index < len(self.result_items): item = self.result_items[index] store_name = None file_hash = None if isinstance(item, dict): store_name = item.get("store") file_hash = item.get("hash") else: store_name = getattr(item, "store", None) file_hash = getattr(item, "hash", None) store_text = str(store_name).strip() if store_name is not None else "" hash_text = str(file_hash).strip() if file_hash is not None else "" if not store_text: # Fallback to UI store selection when item doesn't carry it. store_text = self._get_selected_store() or "" return item, (store_text or None), (hash_text or None) def _open_tags_popup(self) -> None: if self._pipeline_running: self.notify("Pipeline already running", severity="warning", timeout=3) return item, store_name, file_hash = self._resolve_selected_item() if item is None: self.notify("No selected item", severity="warning", timeout=3) return if not store_name: self.notify("Selected item missing store", severity="warning", timeout=4) return seeds: Any = item if isinstance(item, dict): seeds = dict(item) try: if store_name and not str(seeds.get("store") or "").strip(): seeds["store"] = store_name except Exception: pass try: if file_hash and not str(seeds.get("hash") or "").strip(): seeds["hash"] = file_hash except Exception: pass self.push_screen(TagEditorPopup(seeds=seeds, store_name=store_name, file_hash=file_hash)) def _open_metadata_popup(self) -> None: item, _store_name, _file_hash = self._resolve_selected_item() if item is None: self.notify("No selected item", severity="warning", timeout=3) return text = "" idx = int(getattr(self, "_selected_row_index", 0) or 0) if self.current_result_table and 0 <= idx < len(getattr(self.current_result_table, "rows", []) or []): row = self.current_result_table.rows[idx] lines = [f"{col.name}: {col.value}" for col in getattr(row, "columns", []) or []] text = "\n".join(lines) elif isinstance(item, dict): try: text = json.dumps(item, indent=2, ensure_ascii=False) except Exception: text = str(item) else: text = str(item) self.push_screen(TextPopup(title="Metadata", text=text)) def _open_relationships_popup(self) -> None: item, _store_name, _file_hash = self._resolve_selected_item() if item is None: self.notify("No selected item", severity="warning", timeout=3) return relationships = None if isinstance(item, dict): relationships = item.get("relationships") or item.get("relationship") else: relationships = getattr(item, "relationships", None) if not relationships: relationships = getattr(item, "get_relationships", lambda: None)() if not relationships: self.push_screen(TextPopup(title="Relationships", text="No relationships")) return lines: List[str] = [] if isinstance(relationships, dict): for rel_type, value in relationships.items(): if isinstance(value, list): if not value: lines.append(f"{rel_type}: (empty)") for v in value: lines.append(f"{rel_type}: {v}") else: lines.append(f"{rel_type}: {value}") else: lines.append(str(relationships)) self.push_screen(TextPopup(title="Relationships", text="\n".join(lines))) def _clear_log(self) -> None: self.log_lines = [] if self.log_output: self.log_output.text = "" def _append_log_line(self, line: str) -> None: self.log_lines.append(line) if len(self.log_lines) > 500: self.log_lines = self.log_lines[-500:] if self.log_output: self.log_output.text = "\n".join(self.log_lines) def _append_block(self, text: str) -> None: for line in text.strip().splitlines(): self._append_log_line(f" {line}") def _clear_results(self) -> None: self.result_items = [] if self.results_table: self.results_table.clear() self._selected_row_index = 0 def _set_status(self, message: str, *, level: str = "info") -> None: if not self.status_panel: return for css in ("status-info", "status-success", "status-error"): self.status_panel.remove_class(css) css_class = f"status-{level if level in {'success', 'error'} else 'info'}" self.status_panel.add_class(css_class) self.status_panel.update(message) def refresh_workers(self) -> None: if not self.worker_table: return manager = self.executor.worker_manager self.worker_table.clear() if manager is None: self.worker_table.add_row("—", "—", "—", "Worker manager unavailable") return workers = manager.get_active_workers() if not workers: self.worker_table.add_row("—", "—", "—", "No active workers") return for worker in workers: worker_id = str(worker.get("worker_id") or worker.get("id") or "?")[:8] worker_type = str(worker.get("worker_type") or worker.get("type") or "?") status = str(worker.get("status") or worker.get("result") or "running") details = worker.get("current_step") or worker.get("description") or worker.get("pipe") or "" self.worker_table.add_row(worker_id, worker_type, status, str(details)[:80]) if __name__ == "__main__": PipelineHubApp().run()