Files
Medios-Macina/TUI/tui.py

1004 lines
36 KiB
Python
Raw Normal View History

2025-11-25 20:09:33 -08:00
"""Modern Textual UI for driving Medeia-Macina pipelines."""
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
from __future__ import annotations
2025-12-24 02:13:21 -08:00
import json
import re
2025-11-25 20:09:33 -08:00
import sys
from pathlib import Path
2025-12-24 02:13:21 -08:00
from typing import Any, List, Optional, Sequence, Tuple
2025-11-25 20:09:33 -08:00
2025-12-24 02:13:21 -08:00
from textual import on, work
2025-11-25 20:09:33 -08:00
from textual.app import App, ComposeResult
from textual.binding import Binding
2025-12-24 02:13:21 -08:00
from textual.events import Key
from textual.containers import Container, Horizontal, Vertical
from textual.screen import ModalScreen
2025-12-29 17:05:03 -08:00
from textual.widgets import (
Button,
DataTable,
Footer,
Header,
Input,
Label,
OptionList,
Select,
Static,
TextArea,
)
2025-12-24 02:13:21 -08:00
from textual.widgets.option_list import Option
2025-11-25 20:09:33 -08:00
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)
2025-12-24 02:13:21 -08:00
from pipeline_runner import PipelineRunResult # type: ignore # noqa: E402
from SYS.result_table import ResultTable # type: ignore # noqa: E402
2025-11-25 20:09:33 -08:00
from SYS.config import load_config # type: ignore # noqa: E402
2025-12-24 02:13:21 -08:00
from Store.registry import Store as StoreRegistry # type: ignore # noqa: E402
from SYS.cmdlet_catalog import ensure_registry_loaded, list_cmdlet_names # type: ignore # noqa: E402
from SYS.cli_syntax import validate_pipeline_text # type: ignore # noqa: E402
2025-12-24 02:13:21 -08:00
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]):
2025-12-24 02:13:21 -08:00
def __init__(self, *, title: str, text: str) -> None:
super().__init__()
self._title = str(title)
self._text = str(text or "")
2025-11-25 20:09:33 -08:00
2025-12-24 02:13:21 -08:00
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")
2025-11-25 20:09:33 -08:00
2025-12-24 02:13:21 -08:00
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:
2025-12-24 02:13:21 -08:00
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}"
)
2025-12-24 02:13:21 -08:00
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}
2025-12-24 02:13:21 -08:00
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)
2025-12-29 17:05:03 -08:00
def _save_tags_background(
self,
to_add: List[str],
to_del: List[str],
desired: List[str]
2025-12-29 17:05:03 -08:00
) -> None:
2025-12-24 02:13:21 -08:00
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):
2025-12-29 17:05:03 -08:00
failures.append(
str(
getattr(del_res,
"error",
"") or getattr(del_res,
"stderr",
"") or "delete-tag failed"
2025-12-29 17:05:03 -08:00
).strip()
)
2025-12-24 02:13:21 -08:00
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):
2025-12-29 17:05:03 -08:00
failures.append(
str(
getattr(add_res,
"error",
"") or getattr(add_res,
"stderr",
"") or "add-tag failed"
2025-12-29 17:05:03 -08:00
).strip()
)
2025-12-24 02:13:21 -08:00
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)})"
)
2025-12-24 02:13:21 -08:00
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}"
)
2025-12-24 02:13:21 -08:00
except Exception:
self._set_status(f"Error: {type(exc).__name__}: {exc}")
2025-11-25 20:09:33 -08:00
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),
2025-11-25 20:09:33 -08:00
]
def __init__(self) -> None:
super().__init__()
2025-12-24 02:13:21 -08:00
self.executor = PipelineRunner()
2025-11-25 20:09:33 -08:00
self.result_items: List[Any] = []
self.log_lines: List[str] = []
self.command_input: Optional[Input] = None
2025-12-24 02:13:21 -08:00
self.store_select: Optional[Select] = None
self.path_input: Optional[Input] = None
2025-11-25 20:09:33 -08:00
self.log_output: Optional[TextArea] = None
self.results_table: Optional[DataTable] = None
self.worker_table: Optional[DataTable] = None
self.status_panel: Optional[Static] = None
2025-11-27 10:59:01 -08:00
self.current_result_table: Optional[ResultTable] = None
2025-12-24 02:13:21 -08:00
self.suggestion_list: Optional[OptionList] = None
self._cmdlet_names: List[str] = []
2025-11-25 20:09:33 -08:00
self._pipeline_running = False
2025-12-24 02:13:21 -08:00
self._pipeline_worker: Any = None
self._selected_row_index: int = 0
2025-11-25 20:09:33 -08:00
# ------------------------------------------------------------------
# Layout
# ------------------------------------------------------------------
def compose(self) -> ComposeResult: # noqa: D401 - Textual compose hook
yield Header(show_clock=True)
with Container(id="app-shell"):
2025-12-24 02:13:21 -08:00
with Vertical(id="command-pane"):
with Horizontal(id="command-row"):
yield Input(
placeholder="Enter pipeline command...",
id="pipeline-input"
)
2025-12-24 02:13:21 -08:00
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")
2025-11-25 20:09:33 -08:00
yield Footer()
def on_mount(self) -> None:
2025-12-24 02:13:21 -08:00
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
2025-11-25 20:09:33 -08:00
if self.results_table:
2025-12-24 02:13:21 -08:00
self.results_table.cursor_type = "row"
self.results_table.zebra_stripes = True
2025-11-25 20:09:33 -08:00
self.results_table.add_columns("Row", "Title", "Source", "File")
if self.worker_table:
self.worker_table.add_columns("ID", "Type", "Status", "Details")
2025-12-24 02:13:21 -08:00
self._populate_store_options()
self._load_cmdlet_names()
2025-11-25 20:09:33 -08:00
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()
2025-12-24 02:13:21 -08:00
def action_focus_logs(self) -> None:
if self.log_output:
self.log_output.focus()
2025-11-25 20:09:33 -08:00
def action_run_pipeline(self) -> None:
if self._pipeline_running:
2025-12-24 02:13:21 -08:00
# 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
2025-11-25 20:09:33 -08:00
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
2025-12-24 02:13:21 -08:00
pipeline_text = self._apply_store_path_and_tags(pipeline_text)
2025-11-25 20:09:33 -08:00
self._pipeline_running = True
self._set_status("Running…", level="info")
self._clear_log()
self._append_log_line(f"$ {pipeline_text}")
self._clear_results()
2025-12-24 02:13:21 -08:00
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
)
2025-12-24 02:13:21 -08:00
self.command_input.value = new_text
self.suggestion_list.display = False
self.command_input.focus()
2025-11-25 20:09:33 -08:00
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()
2025-12-24 02:13:21 -08:00
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()
2025-11-25 20:09:33 -08:00
def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id == "pipeline-input":
self.action_run_pipeline()
2025-12-24 02:13:21 -08:00
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
2025-12-29 17:05:03 -08:00
self.command_input.value = self._apply_suggestion_to_text(
str(self.command_input.value or ""),
suggestion
2025-12-29 17:05:03 -08:00
)
2025-12-24 02:13:21 -08:00
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)):
2025-12-24 02:13:21 -08:00
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 <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:
2025-12-29 17:05:03 -08:00
first_stage_cmd = (
str(stages[0].split()[0]).replace("_",
"-").strip().lower()
if stages[0].split() else ""
2025-12-29 17:05:03 -08:00
)
2025-12-24 02:13:21 -08:00
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:
2025-12-24 02:13:21 -08:00
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"}
)
2025-12-24 02:13:21 -08:00
)
if should_auto_add_file:
store_token = json.dumps(selected_store)
joined = f"{joined} | add-file -store {store_token}"
return joined
2025-11-25 20:09:33 -08:00
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
2025-12-24 02:13:21 -08:00
index = int(event.cursor_row or 0)
if index < 0:
index = 0
self._selected_row_index = index
2025-11-25 20:09:33 -08:00
# ------------------------------------------------------------------
# Pipeline execution helpers
# ------------------------------------------------------------------
@work(exclusive=True, thread=True)
def _run_pipeline_background(self, pipeline_text: str) -> None:
2025-12-24 02:13:21 -08:00
try:
run_result = self.executor.run_pipeline(
pipeline_text,
on_log=self._log_from_worker
)
2025-12-24 02:13:21 -08:00
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}",
)
2025-11-25 20:09:33 -08:00
self.call_from_thread(self._on_pipeline_finished, run_result)
def _on_pipeline_finished(self, run_result: PipelineRunResult) -> None:
self._pipeline_running = False
2025-12-24 02:13:21 -08:00
self._pipeline_worker = None
2025-11-25 20:09:33 -08:00
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
)
2025-11-25 20:09:33 -08:00
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 = []
2025-11-27 10:59:01 -08:00
self.current_result_table = run_result.result_table
2025-11-25 20:09:33 -08:00
self._populate_results_table()
self.refresh_workers()
2025-12-24 02:13:21 -08:00
if self.result_items:
self._selected_row_index = 0
2025-11-25 20:09:33 -08:00
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
2025-11-27 10:59:01 -08:00
self.results_table.clear(columns=True)
2025-11-25 20:09:33 -08:00
2025-11-27 10:59:01 -08:00
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)
)
2025-11-27 10:59:01 -08:00
2025-12-24 02:13:21 -08:00
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()}
)
2025-12-24 02:13:21 -08:00
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")
2025-11-25 20:09:33 -08:00
return
2025-12-24 02:13:21 -08:00
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")
2025-11-25 20:09:33 -08:00
2025-12-24 02:13:21 -08:00
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]
2025-12-24 02:13:21 -08:00
except Exception:
try:
self.suggestion_list.options = [
Option(m) for m in matches
] # type: ignore[attr-defined]
2025-12-24 02:13:21 -08:00
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]]:
2025-12-24 02:13:21 -08:00
"""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.
2025-12-29 17:05:03 -08:00
if self.current_result_table and 0 <= index < len(
getattr(self.current_result_table,
"rows",
[]) or []):
2025-11-27 10:59:01 -08:00
row = self.current_result_table.rows[index]
2025-12-24 02:13:21 -08:00
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):
2025-11-27 10:59:01 -08:00
item = self.result_items[index]
2025-12-24 02:13:21 -08:00
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)
)
2025-12-24 02:13:21 -08:00
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)
2025-12-29 17:05:03 -08:00
if self.current_result_table and 0 <= idx < len(
getattr(self.current_result_table,
"rows",
[]) or []):
2025-12-24 02:13:21 -08:00
row = self.current_result_table.rows[idx]
lines = [
f"{col.name}: {col.value}" for col in getattr(row, "columns", []) or []
]
2025-12-24 02:13:21 -08:00
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}")
2025-11-25 20:09:33 -08:00
else:
2025-12-24 02:13:21 -08:00
lines.append(str(relationships))
self.push_screen(TextPopup(title="Relationships", text="\n".join(lines)))
2025-11-25 20:09:33 -08:00
def _clear_log(self) -> None:
self.log_lines = []
if self.log_output:
2025-11-27 10:59:01 -08:00
self.log_output.text = ""
2025-11-25 20:09:33 -08:00
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:
2025-11-27 10:59:01 -08:00
self.log_output.text = "\n".join(self.log_lines)
2025-11-25 20:09:33 -08:00
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()
2025-12-24 02:13:21 -08:00
self._selected_row_index = 0
2025-11-25 20:09:33 -08:00
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")
2025-12-29 17:05:03 -08:00
details = (
worker.get("current_step") or worker.get("description")
or worker.get("pipe") or ""
2025-12-29 17:05:03 -08:00
)
2025-11-25 20:09:33 -08:00
self.worker_table.add_row(worker_id, worker_type, status, str(details)[:80])
if __name__ == "__main__":
PipelineHubApp().run()