diff --git a/pyproject.toml b/pyproject.toml index 2236c0e..4970a81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "pip>=26.0.1", "rich>=13.9.4", "sentence-transformers>=5.4.0", + "textual>=0.63.0", ] [dependency-groups] @@ -28,4 +29,4 @@ omnia = { workspace = true } [project.scripts] play = "omnia.main:main" build = "omnia.tools.scenario_builder:main" - +build-tui = "omnia.tools.scenario_builder_tui:main" diff --git a/src/omnia/tools/scenario_builder_tui.py b/src/omnia/tools/scenario_builder_tui.py new file mode 100644 index 0000000..22828c0 --- /dev/null +++ b/src/omnia/tools/scenario_builder_tui.py @@ -0,0 +1,1442 @@ +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional + +from langchain_core.messages import HumanMessage, SystemMessage +from textual.app import App, ComposeResult +from textual.containers import Grid, Horizontal, Vertical, VerticalScroll +from textual.reactive import reactive +from textual.screen import ModalScreen, Screen +from textual.widgets import ( + Button, + Footer, + Header, + Input, + Label, + ListItem, + ListView, + Select, + TabbedContent, + TabPane, + TextArea, + Tree, + Rule, +) + +from omnia.tools.scenario_builder import DEFAULT_ENTITY, DEFAULT_SCENARIO + + +class PathPrompt(ModalScreen[Optional[str]]): + def __init__(self, title: str, default: str = "") -> None: + super().__init__() + self.title = title + self.default = default + + def compose(self) -> ComposeResult: + yield Label(self.title, id="prompt-title") + yield Input(value=self.default, id="prompt-input") + with Horizontal(id="prompt-buttons"): + yield Button("OK", id="ok", variant="primary") + yield Button("Cancel", id="cancel") + + def on_mount(self) -> None: + self.query_one(Input).focus() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "ok": + value = self.query_one(Input).value.strip() + self.dismiss(value or None) + else: + self.dismiss(None) + + +class NodeTypePrompt(ModalScreen[Optional[str]]): + CSS = """ + NodeTypePrompt { + align: center middle; + } + #dialog { + layout: grid; + grid-size: 2; + grid-gutter: 1 2; + grid-rows: 1fr 3; + padding: 0 1; + width: 60; + height: 11; + border: thick $background 80%; + background: $surface; + } + #node-type-title { + column-span: 2; + content-align: center middle; + } + """ + + def compose(self) -> ComposeResult: + with Grid(id="dialog"): + yield Label("Select node type", id="node-type-title") + yield Button("πŸ—ΊοΈ Region", id="node_region", variant="primary") + yield Button("πŸ“ Location", id="node_location") + yield Button("⭐ POI", id="node_poi") + yield Button("Cancel", id="node_cancel") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "node_region": + self.dismiss("region") + elif event.button.id == "node_location": + self.dismiss("location") + elif event.button.id == "node_poi": + self.dismiss("poi") + else: + self.dismiss(None) + + +class ScenarioItem(ListItem): + def __init__(self, path: Path) -> None: + super().__init__(Label(path.name)) + self.path = path + + +class EntityItem(ListItem): + def __init__(self, index: int, entity: dict[str, Any]) -> None: + super().__init__(Label(self._label(entity))) + self.index = index + + @staticmethod + def _label(entity: dict[str, Any]) -> str: + name = str(entity.get("name") or "Unnamed").strip() + entity_id = str(entity.get("id") or "no-id").strip() + return f"{name} ({entity_id})" + + +@dataclass +class SpatialNodeRef: + node_type: str + obj: dict[str, Any] + + +class EntitiesScreen(Screen[Optional[dict[str, Any]]]): + CSS = """ + Screen { + layout: vertical; + } + #entities-main { + height: 1fr; + } + #entity-left { + width: 35%; + min-width: 24; + border: round $primary; + padding: 1; + } + #entity-right { + width: 1fr; + border: round $primary; + padding: 1; + } + #entity_list { + height: 1fr; + } + #entity-form { + height: 1fr; + } + #entity_stats, + #entity_voice, + #entity_memories { + height: 4; + } + #entity-traits-label, + #entity-stats-label, + #entity-voice-label, + #entity-memories-label { + margin-top: 1; + } + #entity_status { + height: 1; + padding: 0 1; + } + """ + + def __init__(self, scenario: dict[str, Any]) -> None: + super().__init__() + self.scenario = scenario + self.entities = self.scenario.setdefault("entities", []) + if not isinstance(self.entities, list): + self.entities = [] + self.scenario["entities"] = self.entities + self.selected_index: Optional[int] = None + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + with Horizontal(id="entities-main"): + with Vertical(id="entity-left"): + yield Label("Entities", id="entity-title") + yield ListView(id="entity_list") + with Horizontal(id="entity-actions"): + yield Button("New", id="entity_new") + yield Button("Delete", id="entity_delete") + with Vertical(id="entity-right"): + yield Label("Entity Details", id="entity-current") + with VerticalScroll(id="entity-form"): + yield Label("Entity ID", id="entity-id-label") + yield Input(id="entity_id") + yield Label("Name", id="entity-name-label") + yield Input(id="entity_name") + yield Label("Traits (comma-separated)", id="entity-traits-label") + yield Input(id="entity_traits") + yield Label("Stats (key=value per line)", id="entity-stats-label") + yield TextArea(id="entity_stats") + yield Label("Voice sample", id="entity-voice-label") + yield TextArea(id="entity_voice") + yield Label("Current mood", id="entity-mood-label") + yield Input(id="entity_mood") + yield Label("Location ID", id="entity-location-label") + yield Input(id="entity_location") + yield Label("Spatial descriptor", id="entity-spatial-label") + yield Input(id="entity_spatial") + yield Label( + "Memories (one per line; JSON allowed)", + id="entity-memories-label", + ) + yield TextArea(id="entity_memories") + with Horizontal(id="entity-actions-right"): + yield Button("Save", id="entity_save", variant="primary") + yield Button("Apply", id="entity_apply") + yield Button("Cancel", id="entity_cancel") + yield Label("", id="entity_status") + yield Footer() + + def on_mount(self) -> None: + self._reload_entity_list(select_index=0 if self.entities else None) + if self.entities: + self._load_entity_into_form(self.entities[0]) + self.selected_index = 0 + else: + self._load_entity_into_form(self._default_entity()) + self.selected_index = None + self._set_status("Editing entities") + + def _set_status(self, message: str) -> None: + self.query_one("#entity_status", Label).update(message) + + def _default_entity(self) -> dict[str, Any]: + return json.loads(json.dumps(DEFAULT_ENTITY)) + + def _load_entity_into_form(self, entity: dict[str, Any]) -> None: + self.query_one("#entity_id", Input).value = str(entity.get("id", "")) + self.query_one("#entity_name", Input).value = str(entity.get("name", "")) + traits = entity.get("traits", []) + if isinstance(traits, list): + traits_text = ", ".join(str(t).strip() for t in traits if str(t).strip()) + else: + traits_text = str(traits) + self.query_one("#entity_traits", Input).value = traits_text + + stats = entity.get("stats", {}) + stats_lines = [] + if isinstance(stats, dict): + for key, value in stats.items(): + stats_lines.append(f"{key}={value}") + self.query_one("#entity_stats", TextArea).text = "\n".join(stats_lines) + + self.query_one("#entity_voice", TextArea).text = str( + entity.get("voice_sample", "") + ) + self.query_one("#entity_mood", Input).value = str( + entity.get("current_mood", "") + ) + + metadata = ( + entity.get("metadata", {}) + if isinstance(entity.get("metadata"), dict) + else {} + ) + self.query_one("#entity_location", Input).value = str( + metadata.get("location", "") + ) + self.query_one("#entity_spatial", Input).value = str( + metadata.get("spatial_descriptor", "") + ) + + memories = entity.get("memories", []) + memory_lines = [] + if isinstance(memories, list): + for memory in memories: + if isinstance(memory, (dict, list)): + memory_lines.append(json.dumps(memory)) + else: + memory_lines.append(str(memory)) + self.query_one("#entity_memories", TextArea).text = "\n".join(memory_lines) + + def _build_entity_from_form(self) -> Optional[dict[str, Any]]: + entity_id = self.query_one("#entity_id", Input).value.strip() + name = self.query_one("#entity_name", Input).value.strip() + if not entity_id or not name: + self._set_status("Entity requires non-empty ID and name") + return None + + entity = self._default_entity() + entity["id"] = entity_id + entity["name"] = name + + traits_value = self.query_one("#entity_traits", Input).value + traits = [t.strip() for t in traits_value.split(",") if t.strip()] + entity["traits"] = traits + + stats_text = self.query_one("#entity_stats", TextArea).text + stats: dict[str, int] = {} + for line in stats_text.splitlines(): + raw = line.strip() + if not raw: + continue + if "=" not in raw: + self._set_status(f"Invalid stats line (missing '='): {raw}") + return None + key, value = raw.split("=", 1) + key = key.strip() + value = value.strip() + if not key or not value: + self._set_status(f"Invalid stats line: {raw}") + return None + try: + stats[key] = int(value) + except ValueError: + self._set_status(f"Stat value must be an integer: {raw}") + return None + entity["stats"] = stats + + entity["voice_sample"] = self.query_one("#entity_voice", TextArea).text.strip() + mood = self.query_one("#entity_mood", Input).value.strip() + entity["current_mood"] = mood if mood else "Neutral" + + entity["metadata"]["location"] = self.query_one( + "#entity_location", Input + ).value.strip() + entity["metadata"]["spatial_descriptor"] = self.query_one( + "#entity_spatial", Input + ).value.strip() + + memories_text = self.query_one("#entity_memories", TextArea).text + memories: list[Any] = [] + for line in memories_text.splitlines(): + raw = line.strip() + if not raw: + continue + if raw.startswith(("{", "[")): + try: + memories.append(json.loads(raw)) + except json.JSONDecodeError: + self._set_status(f"Invalid memory JSON: {raw}") + return None + else: + memories.append(raw) + entity["memories"] = memories + + return entity + + def _reload_entity_list(self, select_index: Optional[int] = None) -> None: + list_view = self.query_one("#entity_list", ListView) + list_view.clear() + for index, entity in enumerate(self.entities): + list_view.append(EntityItem(index, entity)) + if select_index is not None and self.entities: + clamped = max(0, min(select_index, len(self.entities) - 1)) + list_view.index = clamped + self.selected_index = clamped + + def on_list_view_selected(self, event: ListView.Selected) -> None: + if isinstance(event.item, EntityItem): + self.selected_index = event.item.index + self._load_entity_into_form(self.entities[event.item.index]) + + async def on_button_pressed(self, event: Button.Pressed) -> None: + button_id = event.button.id + if button_id == "entity_new": + self.selected_index = None + self._load_entity_into_form(self._default_entity()) + self._set_status("Creating new entity") + return + + if button_id == "entity_save": + entity = self._build_entity_from_form() + if entity is None: + return + + existing_ids = { + e.get("id") + for i, e in enumerate(self.entities) + if i != self.selected_index + } + if entity["id"] in existing_ids: + self._set_status(f"Entity ID '{entity['id']}' already exists") + return + + if self.selected_index is None: + self.entities.append(entity) + self.selected_index = len(self.entities) - 1 + else: + self.entities[self.selected_index] = entity + + self._reload_entity_list(select_index=self.selected_index) + self._set_status("Entity saved") + return + + if button_id == "entity_delete": + if self.selected_index is None: + self._set_status("Select an entity to delete") + return + removed = self.entities.pop(self.selected_index) + self._reload_entity_list(select_index=self.selected_index) + if self.entities: + self._load_entity_into_form( + self.entities[min(self.selected_index, len(self.entities) - 1)] + ) + else: + self._load_entity_into_form(self._default_entity()) + self.selected_index = None + self._set_status(f"Deleted {removed.get('name')}") + return + + if button_id == "entity_apply": + self.scenario["entities"] = self.entities + self.dismiss(self.scenario) + return + + if button_id == "entity_cancel": + self.dismiss(None) + + +class SpatialGraphScreen(Screen[Optional[dict[str, Any]]]): + CSS = """ + Screen { + layout: vertical; + } + #spatial-main { + height: 1fr; + } + #spatial-left { + width: 35%; + min-width: 24; + border: round $primary; + padding: 1; + } + #spatial-right { + width: 1fr; + border: round $primary; + padding: 1; + } + .hidden { + display: none; + } + #spatial-tree { + height: 1fr; + } + #node-description { + height: 5; + } + #spatial-status { + height: 1; + padding: 0 1; + } + #spatial-actions { + height: auto; + width: auto; + align-horizontal: left; + } + """ + + def __init__(self, scenario: dict[str, Any]) -> None: + super().__init__() + self.scenario = scenario + self.selected_node = None + self.node_map: dict[int, Any] = {} + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + with Horizontal(id="spatial-main"): + with Vertical(id="spatial-left"): + yield Label("Spatial Graph", id="spatial-title") + yield Tree("🌍 World", id="spatial-tree") + with Horizontal(id="spatial-tree-actions"): + yield Button("Add", id="spatial_add", variant="primary") + yield Button("Delete", id="spatial_delete") + with Horizontal(id="spatial-nesting-actions"): + yield Button("Promote", id="spatial_promote") + yield Button("Demote", id="spatial_demote") + with Vertical(id="spatial-right"): + yield Label("Node Details", id="node-title") + with Vertical(id="node-details"): + yield Label("Type", id="node-type-label") + yield Label("", id="node-type-value") + yield Label("ID", id="node-id-label") + yield Input(id="node_id") + yield Label("Name", id="node-name-label") + yield Input(id="node_name") + yield Label("Description", id="node-desc-label") + yield TextArea(id="node-description") + with Horizontal(id="node-actions"): + yield Button("Generate", id="node_generate") + yield Button("Update", id="node_update", variant="primary") + yield Button("Reset", id="node_reset") + with Horizontal(id="spatial-actions"): + yield Button("Save", id="spatial_save", variant="primary") + yield Button("Cancel", id="spatial_cancel") + yield Label("", id="spatial-status") + yield Footer() + + def on_mount(self) -> None: + self._ensure_spatial_graph() + self._build_tree() + self._clear_form() + self._set_status("Select a node to edit") + + def _set_status(self, message: str) -> None: + self.query_one("#spatial-status", Label).update(message) + + def _toast(self, message: str, severity: str = "warning") -> None: + try: + self.app.notify(message, severity=severity) + except Exception: + self._set_status(message) + + def _ensure_spatial_graph(self) -> None: + spatial_graph = self.scenario.get("spatial_graph") + if spatial_graph is None: + spatial_graph = json.loads(json.dumps(DEFAULT_SCENARIO["spatial_graph"])) + self.scenario["spatial_graph"] = spatial_graph + world = spatial_graph.setdefault("world", {}) + world.setdefault("regions", []) + world.setdefault("name", "World") + world.setdefault("id", "world") + + def _world(self) -> dict[str, Any]: + return self.scenario["spatial_graph"]["world"] + + def _build_tree(self, select_obj: Optional[dict[str, Any]] = None) -> None: + tree = self.query_one("#spatial-tree", Tree) + world = self._world() + tree.reset(f"🌍 {world.get('name', 'World')}") + tree.root.data = SpatialNodeRef("world", world) + tree.root.expand() + self.selected_node = None + self.node_map = {} + + def add_region(region: dict[str, Any]) -> None: + node = tree.root.add( + self._label_for("region", region), data=SpatialNodeRef("region", region) + ) + self.node_map[id(region)] = node + for location in region.get("locations", []): + add_location(node, location) + + def add_location(parent_node: Any, location: dict[str, Any]) -> None: + loc_node = parent_node.add( + self._label_for("location", location), + data=SpatialNodeRef("location", location), + ) + self.node_map[id(location)] = loc_node + for nested in location.get("locations", []): + add_location(loc_node, nested) + for poi in location.get("pois", []): + poi_node = loc_node.add( + self._label_for("poi", poi), data=SpatialNodeRef("poi", poi) + ) + self.node_map[id(poi)] = poi_node + + for region in world.get("regions", []): + add_region(region) + + if select_obj and id(select_obj) in self.node_map: + self.selected_node = self.node_map[id(select_obj)] + self._load_form_from_node(self.selected_node) + else: + self._clear_form() + + def _label_for(self, node_type: str, obj: dict[str, Any]) -> str: + name = str(obj.get("name") or "Unnamed").strip() + node_id = str(obj.get("id") or "no-id").strip() + icon = {"region": "πŸ—ΊοΈ", "location": "πŸ“", "poi": "⭐"}.get(node_type, "β€’") + return f"{icon} {name} ({node_id})" + + def _clear_form(self) -> None: + self.query_one("#node-type-value", Label).update("") + self.query_one("#node_id", Input).value = "" + self.query_one("#node_name", Input).value = "" + self.query_one("#node-description", TextArea).text = "" + self.query_one("#node-details", Vertical).add_class("hidden") + self.query_one("#node_generate", Button).disabled = True + self.query_one("#node_update", Button).disabled = True + self.query_one("#node_reset", Button).disabled = True + self.query_one("#spatial_delete", Button).disabled = True + self.query_one("#spatial_promote", Button).disabled = True + self.query_one("#spatial_demote", Button).disabled = True + + def _load_form_from_node(self, node: Any) -> None: + ref = node.data + if not isinstance(ref, SpatialNodeRef) or ref.node_type == "world": + self._clear_form() + return + self.query_one("#node-details", Vertical).remove_class("hidden") + self.query_one("#node-type-value", Label).update(ref.node_type.title()) + self.query_one("#node_id", Input).value = str(ref.obj.get("id", "")) + self.query_one("#node_name", Input).value = str(ref.obj.get("name", "")) + self.query_one("#node-description", TextArea).text = str( + ref.obj.get("description", "") + ) + self.query_one("#node_generate", Button).disabled = False + self.query_one("#node_update", Button).disabled = False + self.query_one("#node_reset", Button).disabled = False + self.query_one("#spatial_delete", Button).disabled = False + is_location = ref.node_type == "location" + can_promote, can_demote = ( + self._location_move_state(node) if is_location else (False, False) + ) + self.query_one("#spatial_promote", Button).disabled = not can_promote + self.query_one("#spatial_demote", Button).disabled = not can_demote + + def on_tree_node_selected(self, event: Tree.NodeSelected) -> None: + self.selected_node = event.node + self._load_form_from_node(event.node) + + def _open_node_type_prompt(self) -> None: + self.app.push_screen(NodeTypePrompt(), callback=self._handle_node_type) + + def _handle_node_type(self, result: Optional[str]) -> None: + if result: + self._add_node(result) + + def _selected_ref(self) -> Optional[SpatialNodeRef]: + if self.selected_node is None: + return None + ref = self.selected_node.data + if isinstance(ref, SpatialNodeRef): + return ref + return None + + def _selected_parent_node(self) -> Optional[Any]: + if self.selected_node is None: + return None + return self.selected_node.parent + + def _parent_locations_list( + self, parent_node: Any + ) -> Optional[list[dict[str, Any]]]: + if parent_node is None or not isinstance(parent_node.data, SpatialNodeRef): + return None + parent_ref = parent_node.data + if parent_ref.node_type not in {"region", "location"}: + return None + return parent_ref.obj.setdefault("locations", []) + + def _location_move_state(self, node: Any) -> tuple[bool, bool]: + if node is None or not isinstance(node.data, SpatialNodeRef): + return (False, False) + ref = node.data + if ref.node_type != "location": + return (False, False) + parent_node = node.parent + parent_locations = self._parent_locations_list(parent_node) + if parent_locations is None: + return (False, False) + can_promote = False + if parent_node is not None and isinstance(parent_node.data, SpatialNodeRef): + parent_ref = parent_node.data + can_promote = parent_ref.node_type == "location" + can_demote = False + if ref.obj in parent_locations: + index = parent_locations.index(ref.obj) + can_demote = index < len(parent_locations) - 1 + return (can_promote, can_demote) + + def _parent_pois_list(self, parent_node: Any) -> Optional[list[dict[str, Any]]]: + if parent_node is None or not isinstance(parent_node.data, SpatialNodeRef): + return None + parent_ref = parent_node.data + if parent_ref.node_type != "location": + return None + return parent_ref.obj.setdefault("pois", []) + + def _update_selected_node(self) -> None: + ref = self._selected_ref() + if ref is None or ref.node_type == "world": + self._set_status("Select a node to update") + return + node_id = self.query_one("#node_id", Input).value.strip() + name = self.query_one("#node_name", Input).value.strip() + description = self.query_one("#node-description", TextArea).text.strip() + if not node_id or not name: + self._set_status("ID and name are required") + return + ref.obj["id"] = node_id + ref.obj["name"] = name + ref.obj["description"] = description + if ref.node_type == "region": + ref.obj.setdefault("locations", []) + elif ref.node_type == "location": + ref.obj.setdefault("locations", []) + ref.obj.setdefault("pois", []) + elif ref.node_type == "poi": + ref.obj.setdefault("connections", []) + if self.selected_node: + self.selected_node.label = self._label_for(ref.node_type, ref.obj) + self._set_status("Node updated") + + def _collect_ids(self, node_type: str) -> set[str]: + world = self._world() + ids: set[str] = set() + + if node_type == "region": + for region in world.get("regions", []): + if region_id := str(region.get("id", "")).strip(): + ids.add(region_id) + return ids + + def walk_locations(locations: list[dict[str, Any]]) -> None: + for location in locations: + if node_type == "location": + if loc_id := str(location.get("id", "")).strip(): + ids.add(loc_id) + if node_type == "poi": + for poi in location.get("pois", []): + if poi_id := str(poi.get("id", "")).strip(): + ids.add(poi_id) + walk_locations(location.get("locations", [])) + + for region in world.get("regions", []): + walk_locations(region.get("locations", [])) + + return ids + + def _node_path(self, node: Any) -> str: + parts = [] + current = node + while current is not None and isinstance(current.data, SpatialNodeRef): + ref = current.data + if ref.node_type == "world": + break + label = str(ref.obj.get("name") or ref.obj.get("id") or ref.node_type) + parts.append(f"{ref.node_type}: {label}") + current = current.parent + return " > ".join(reversed(parts)) if parts else "World" + + def _generate_node_fields(self) -> None: + ref = self._selected_ref() + if ref is None or ref.node_type == "world": + self._toast("Select a node to generate") + return + + scenario_meta = self.scenario.get("scenario", {}) + node_type = ref.node_type + system_prompt = """ +You are helping build a game scenario. +Return ONLY a single-line JSON object with keys: +id, name, description. +- id: short snake_case identifier +- description: 1-3 sentences +No commentary. ASCII only. +""".strip() + + user_prompt = f""" +Scenario title: {scenario_meta.get("title", "")} +Scenario description: {scenario_meta.get("description", "")} +Node type: {node_type} +Node path: {self._node_path(self.selected_node)} +Current id: {ref.obj.get("id", "")} +Current name: {ref.obj.get("name", "")} +""".strip() + + from omnia.llm_runtime import invoke_llm + + response_text = invoke_llm( + [SystemMessage(content=system_prompt), HumanMessage(content=user_prompt)] + ) + + try: + payload = json.loads(response_text) + except json.JSONDecodeError: + self._toast("LLM response was not valid JSON", severity="error") + return + + if not isinstance(payload, dict): + self._toast("LLM response must be a JSON object", severity="error") + return + + generated_id = str(payload.get("id", "")).strip() + generated_name = str(payload.get("name", "")).strip() + generated_description = str(payload.get("description", "")).strip() + + if not generated_id or not generated_name: + self._toast("Generated data missing id or name", severity="error") + return + + existing_ids = self._collect_ids(node_type) + if generated_id in existing_ids and generated_id != str(ref.obj.get("id", "")).strip(): + self._toast(f"Generated id '{generated_id}' already exists", severity="warning") + return + + ref.obj["id"] = generated_id + ref.obj["name"] = generated_name + ref.obj["description"] = generated_description + if self.selected_node: + self.selected_node.label = self._label_for(node_type, ref.obj) + self._load_form_from_node(self.selected_node) + self._set_status("Generated fields applied") + + def _reset_selected_node(self) -> None: + if self.selected_node is None: + return + self._load_form_from_node(self.selected_node) + self._set_status("Reverted changes") + + def _add_node(self, node_type: str) -> None: + world = self._world() + if node_type == "region": + region = {"id": "", "name": "", "description": "", "locations": []} + world.setdefault("regions", []).append(region) + self._build_tree(select_obj=region) + self._set_status("Region added") + return + + parent_node = self.selected_node + if node_type == "location": + if parent_node is None or not isinstance(parent_node.data, SpatialNodeRef): + self._toast("Select a region or location for the new location") + return + parent_ref = parent_node.data + if parent_ref.node_type not in {"region", "location"}: + self._toast("Select a region or location for the new location") + return + location = { + "id": "", + "name": "", + "description": "", + "locations": [], + "pois": [], + } + parent_ref.obj.setdefault("locations", []).append(location) + self._build_tree(select_obj=location) + self._set_status("Location added") + return + + if node_type == "poi": + if parent_node is None or not isinstance(parent_node.data, SpatialNodeRef): + self._toast("Select a location for the new POI") + return + parent_ref = parent_node.data + if parent_ref.node_type == "poi": + parent_node = parent_node.parent + if parent_node is None or not isinstance( + parent_node.data, SpatialNodeRef + ): + self._toast("Select a location for the new POI") + return + parent_ref = parent_node.data + if parent_ref.node_type != "location": + self._toast("Select a location for the new POI") + return + poi = {"id": "", "name": "", "description": "", "connections": []} + parent_ref.obj.setdefault("pois", []).append(poi) + self._build_tree(select_obj=poi) + self._set_status("POI added") + + def _delete_node(self) -> None: + ref = self._selected_ref() + if ref is None or ref.node_type == "world": + self._set_status("Select a node to delete") + return + parent_node = self._selected_parent_node() + if ref.node_type == "region": + world = self._world() + world["regions"] = [r for r in world.get("regions", []) if r is not ref.obj] + self._build_tree() + self._set_status("Region deleted") + return + if ref.node_type == "location": + parent_locations = self._parent_locations_list(parent_node) + if parent_locations is None: + self._set_status("Unable to delete location") + return + parent_locations[:] = [ + loc for loc in parent_locations if loc is not ref.obj + ] + self._build_tree() + self._set_status("Location deleted") + return + if ref.node_type == "poi": + parent_pois = self._parent_pois_list(parent_node) + if parent_pois is None: + self._set_status("Unable to delete POI") + return + parent_pois[:] = [poi for poi in parent_pois if poi is not ref.obj] + self._build_tree() + self._set_status("POI deleted") + + def _promote_location(self) -> None: + ref = self._selected_ref() + if ref is None or ref.node_type != "location": + self._set_status("Select a location to promote") + return + parent_node = self._selected_parent_node() + if parent_node is None or not isinstance(parent_node.data, SpatialNodeRef): + self._set_status("Unable to promote location") + return + parent_ref = parent_node.data + if parent_ref.node_type != "location": + self._set_status("Location is already top-level") + return + grandparent_node = parent_node.parent + parent_locations = parent_ref.obj.setdefault("locations", []) + if ref.obj in parent_locations: + parent_locations.remove(ref.obj) + target_list = self._parent_locations_list(grandparent_node) + if target_list is None: + self._set_status("Unable to promote location") + return + target_list.append(ref.obj) + self._build_tree(select_obj=ref.obj) + self._set_status("Location promoted") + + def _demote_location(self) -> None: + ref = self._selected_ref() + if ref is None or ref.node_type != "location": + self._set_status("Select a location to demote") + return + parent_node = self._selected_parent_node() + parent_locations = self._parent_locations_list(parent_node) + if parent_locations is None: + self._set_status("Unable to demote location") + return + try: + index = parent_locations.index(ref.obj) + except ValueError: + self._set_status("Unable to demote location") + return + if index >= len(parent_locations) - 1: + self._set_status("No next sibling to demote under") + return + new_parent = parent_locations[index + 1] + new_parent.setdefault("locations", []).append(ref.obj) + parent_locations.remove(ref.obj) + self._build_tree(select_obj=ref.obj) + self._set_status("Location demoted") + + def on_button_pressed(self, event: Button.Pressed) -> None: + button_id = event.button.id + if button_id == "spatial_add": + self._open_node_type_prompt() + return + if button_id == "spatial_delete": + self._delete_node() + return + if button_id == "spatial_promote": + self._promote_location() + return + if button_id == "spatial_demote": + self._demote_location() + return + if button_id == "node_generate": + self._generate_node_fields() + return + if button_id == "node_update": + self._update_selected_node() + return + if button_id == "node_reset": + self._reset_selected_node() + return + if button_id == "spatial_save": + self.dismiss(self.scenario) + return + if button_id == "spatial_cancel": + self.dismiss(None) + + +class ScenarioBuilderTUI(App): + CSS = """ + Screen { + layout: vertical; + } + #main { + height: 1fr; + } + #left { + width: 30%; + min-width: 24; + border: round $primary; + padding: 1; + } + #right { + width: 1fr; + border: round $primary; + padding: 1; + } + #scenario_list { + height: 1fr; + } + #scenario-options { + margin-bottom: 1; + height: auto; + } + #scenario-tabs { + height: 1fr; + min-height: 1fr; + } + #scenario-metadata-tab, + #scenario-json-tab { + height: 1fr; + } + #metadata-form { + height: 1fr; + min-height: 1fr; + } + #metadata-actions { + height: auto; + } + #meta-description, + #world-description { + height: 4; + } + #editor { + height: 1fr; + } + #scenario-placeholder { + height: 1fr; + content-align: center middle; + color: $text-muted; + } + #status { + height: 1; + padding: 0 1; + } + #prompt-title { + padding: 1 1 0 1; + } + #prompt-input { + margin: 0 1 1 1; + } + #prompt-buttons { + padding: 0 1 1 1; + } + .hidden { + display: none; + } + """ + + current_path: reactive[Optional[Path]] = reactive(None) + scenario_active: reactive[bool] = reactive(False) + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + with Horizontal(id="main"): + with Vertical(id="left"): + yield Label("Scenarios", id="left-title") + yield ListView(id="scenario_list") + with Horizontal(id="left-actions"): + yield Button("New", id="new") + yield Button("Import", id="import") + yield Button("Refresh", id="refresh") + with Horizontal(id="left-save-actions"): + yield Button("Save", id="save", variant="primary") + yield Button("Export", id="export") + with Vertical(id="right"): + yield Label("No scenario loaded", id="current-path") + yield Label( + "Create a new scenario or select one from the list to begin.", + id="scenario-placeholder", + ) + with Horizontal(id="scenario-options"): + yield Button("Entities", id="entities") + yield Button("Spatial Graph", id="spatial_graph") + with TabbedContent(id="scenario-tabs"): + with TabPane("Metadata", id="scenario-metadata-tab"): + with VerticalScroll(id="metadata-form"): + yield Label("Scenario Metadata", id="meta-title") + yield Label("Scenario ID", id="meta-id-label") + yield Input(id="meta_id") + yield Label("Title", id="meta-title-label") + yield Input(id="meta_title") + yield Label("Description", id="meta-desc-label") + yield TextArea(id="meta-description") + yield Label("World time", id="meta-worldtime-label") + yield Input(id="meta_world_time") + yield Label("Player ID", id="meta-player-label") + yield Select([], prompt="Select player", id="meta_player") + yield Label("Location ID", id="meta-location-label") + yield Select([], prompt="Select location", id="meta_location") + yield Rule() + yield Label("World Metadata", id="world-title") + yield Label("World ID", id="world-id-label") + yield Input(id="world_id") + yield Label("World name", id="world-name-label") + yield Input(id="world_name") + yield Label("World description", id="world-desc-label") + yield TextArea(id="world-description") + with Horizontal(id="metadata-actions"): + yield Button("Apply", id="metadata_apply", variant="primary") + yield Button("Reload", id="metadata_reload") + with TabPane("JSON", id="scenario-json-tab"): + yield TextArea(id="editor") + yield Label("", id="status") + yield Footer() + + def on_mount(self) -> None: + self._ensure_scenarios_dir() + self._load_scenario_list() + self._set_editor_text("") + self._set_scenario_active(False) + self._set_status("Select or create a scenario to begin") + + def _scenarios_dir(self) -> Path: + return Path.cwd() + + def _ensure_scenarios_dir(self) -> None: + self._scenarios_dir().mkdir(exist_ok=True) + + def _load_scenario_list(self) -> None: + list_view = self.query_one("#scenario_list", ListView) + list_view.clear() + for path in sorted(self._scenarios_dir().glob("*.json")): + list_view.append(ScenarioItem(path)) + + def _set_status(self, message: str) -> None: + self.query_one("#status", Label).update(message) + + def _set_current_path(self, path: Optional[Path]) -> None: + self.current_path = path + self._update_current_path_label() + + def _update_current_path_label(self) -> None: + label = self.query_one("#current-path", Label) + if not self.scenario_active: + label.update("No scenario loaded") + elif self.current_path: + label.update(f"Editing: {self.current_path}") + else: + label.update("Editing: [new scenario]") + + def _set_editor_text(self, text: str) -> None: + editor = self.query_one("#editor", TextArea) + editor.text = text + + def _set_scenario_active(self, active: bool) -> None: + self.scenario_active = active + editor = self.query_one("#editor", TextArea) + placeholder = self.query_one("#scenario-placeholder", Label) + options = self.query_one("#scenario-options", Horizontal) + tabs = self.query_one("#scenario-tabs", TabbedContent) + for button_id in ( + "entities", + "spatial_graph", + "save", + "export", + "metadata_apply", + "metadata_reload", + ): + self.query_one(f"#{button_id}", Button).disabled = not active + if active: + editor.remove_class("hidden") + placeholder.add_class("hidden") + options.remove_class("hidden") + tabs.remove_class("hidden") + else: + editor.add_class("hidden") + placeholder.remove_class("hidden") + options.add_class("hidden") + tabs.add_class("hidden") + self._update_current_path_label() + + def _get_editor_text(self) -> str: + return self.query_one("#editor", TextArea).text + + def _parse_editor_json(self) -> Optional[dict[str, Any]]: + try: + return json.loads(self._get_editor_text()) + except json.JSONDecodeError as exc: + self._set_status(f"Invalid JSON: {exc}") + return None + + def _collect_location_options(self, payload: dict[str, Any]) -> list[tuple[str, str]]: + spatial_graph = payload.get("spatial_graph", {}) + world = spatial_graph.get("world", {}) + options: list[tuple[str, str]] = [] + seen: set[str] = set() + + def walk_locations(locations: list[dict[str, Any]]) -> None: + for location in locations: + location_id = str(location.get("id", "")).strip() + location_name = str(location.get("name", "")).strip() + if location_id and location_id not in seen: + seen.add(location_id) + label = ( + f"{location_name} ({location_id})" if location_name else location_id + ) + options.append((label, location_id)) + walk_locations(location.get("locations", [])) + + for region in world.get("regions", []): + walk_locations(region.get("locations", [])) + + return options + + def _collect_entity_options(self, payload: dict[str, Any]) -> list[tuple[str, str]]: + options: list[tuple[str, str]] = [] + for entity in payload.get("entities", []): + entity_id = str(entity.get("id", "")).strip() + name = str(entity.get("name", "")).strip() + if not entity_id: + continue + label = f"{name} ({entity_id})" if name else entity_id + options.append((label, entity_id)) + return options + + def _load_metadata_from_payload(self, payload: dict[str, Any]) -> None: + meta = payload.get("scenario", {}) + self.query_one("#meta_id", Input).value = str(meta.get("id", "")) + self.query_one("#meta_title", Input).value = str(meta.get("title", "")) + self.query_one("#meta-description", TextArea).text = str( + meta.get("description", "") + ) + self.query_one("#meta_world_time", Input).value = str( + meta.get("world_time", "") + ) + + entity_options = self._collect_entity_options(payload) + player_select = self.query_one("#meta_player", Select) + player_select.set_options(entity_options) + player_value = str(meta.get("player_id", "")).strip() + valid_player_ids = {value for _, value in entity_options} + player_select.value = ( + player_value if player_value in valid_player_ids else Select.NULL + ) + + location_options = self._collect_location_options(payload) + location_select = self.query_one("#meta_location", Select) + location_select.set_options(location_options) + location_value = str(meta.get("location", "")).strip() + valid_location_ids = {value for _, value in location_options} + location_select.value = ( + location_value if location_value in valid_location_ids else Select.NULL + ) + + spatial_graph = payload.get("spatial_graph", {}) + world = spatial_graph.get("world", {}) + self.query_one("#world_id", Input).value = str(world.get("id", "")) + self.query_one("#world_name", Input).value = str(world.get("name", "")) + self.query_one("#world-description", TextArea).text = str( + world.get("description", "") + ) + + def _apply_metadata_changes(self) -> None: + payload = self._parse_editor_json() + if payload is None: + return + + meta = payload.setdefault("scenario", {}) + meta["id"] = self.query_one("#meta_id", Input).value.strip() + meta["title"] = self.query_one("#meta_title", Input).value.strip() + meta["description"] = self.query_one("#meta-description", TextArea).text.strip() + meta["world_time"] = self.query_one("#meta_world_time", Input).value.strip() + + player_select = self.query_one("#meta_player", Select) + player_value = ( + "" if player_select.value is Select.NULL else str(player_select.value) + ) + meta["player_id"] = player_value + + location_select = self.query_one("#meta_location", Select) + location_value = ( + "" if location_select.value is Select.NULL else str(location_select.value) + ) + meta["location"] = location_value + + spatial_graph = payload.setdefault("spatial_graph", {}) + world = spatial_graph.setdefault("world", {}) + world["id"] = self.query_one("#world_id", Input).value.strip() + world["name"] = self.query_one("#world_name", Input).value.strip() + world["description"] = self.query_one("#world-description", TextArea).text.strip() + + self._set_editor_text(json.dumps(payload, indent=2)) + self._set_status("Metadata updated") + + def _default_filename(self, payload: Optional[dict[str, Any]]) -> str: + scenario_id = "" + if payload: + scenario_id = str(payload.get("scenario", {}).get("id", "")).strip() + return f"{scenario_id}.json" if scenario_id else "scenario.json" + + def _resolve_save_path(self, path_str: str) -> Path: + path = Path(path_str).expanduser() + if not path.is_absolute(): + path = Path.cwd() / path + if path.suffix != ".json": + path = path.with_suffix(".json") + return path + + def _load_from_path(self, path: Path) -> None: + try: + content = path.read_text() + except FileNotFoundError: + self._set_status(f"File not found: {path}") + return + except OSError as exc: + self._set_status(f"Error reading {path}: {exc}") + return + + try: + parsed = json.loads(content) + except json.JSONDecodeError as exc: + self._set_status(f"Invalid JSON in {path}: {exc}") + return + + self._set_editor_text(json.dumps(parsed, indent=2)) + self._set_current_path(path) + self._set_scenario_active(True) + self._load_metadata_from_payload(parsed) + self._set_status(f"Loaded {path}") + + def _write_to_path(self, path: Path) -> None: + payload = self._parse_editor_json() + if payload is None: + return + + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n") + except OSError as exc: + self._set_status(f"Error saving {path}: {exc}") + return + if not path.exists(): + self._set_status(f"Save failed: {path}") + return + + self._set_current_path(path) + self._set_status(f"Saved {path}") + self._load_scenario_list() + + def _open_import_prompt(self) -> None: + self.push_screen(PathPrompt("Import from path"), callback=self._handle_import_path) + + def _handle_import_path(self, result: Optional[str]) -> None: + if result: + self._load_from_path(Path(result).expanduser()) + + def _open_save_prompt(self, default_name: str) -> None: + self.push_screen( + PathPrompt("Save as (current directory)", default_name), + callback=self._handle_save_path, + ) + + def _handle_save_path(self, result: Optional[str]) -> None: + if result: + self._write_to_path(self._resolve_save_path(result)) + + def _open_export_prompt(self, default_name: str) -> None: + self.push_screen( + PathPrompt("Export to path", default_name), + callback=self._handle_export_path, + ) + + def _handle_export_path(self, result: Optional[str]) -> None: + if result: + self._write_to_path(self._resolve_save_path(result)) + + def _open_entities_screen(self) -> None: + payload = self._parse_editor_json() + if payload is None: + return + scenario_copy = json.loads(json.dumps(payload)) + self.push_screen(EntitiesScreen(scenario_copy), callback=self._handle_entities_result) + + def _handle_entities_result(self, result: Optional[dict[str, Any]]) -> None: + if result is None: + self._set_status("Entity changes discarded") + return + self._set_editor_text(json.dumps(result, indent=2)) + self._load_metadata_from_payload(result) + self._set_status("Entities updated") + + def _open_spatial_screen(self) -> None: + payload = self._parse_editor_json() + if payload is None: + return + scenario_copy = json.loads(json.dumps(payload)) + self.push_screen(SpatialGraphScreen(scenario_copy), callback=self._handle_spatial_result) + + def _handle_spatial_result(self, result: Optional[dict[str, Any]]) -> None: + if result is None: + self._set_status("Spatial graph changes discarded") + return + self._set_editor_text(json.dumps(result, indent=2)) + self._load_metadata_from_payload(result) + self._set_status("Spatial graph updated") + + def on_list_view_selected(self, event: ListView.Selected) -> None: + if isinstance(event.item, ScenarioItem): + self._load_from_path(event.item.path) + + async def on_button_pressed(self, event: Button.Pressed) -> None: + button_id = event.button.id + if button_id == "new": + payload = json.loads(json.dumps(DEFAULT_SCENARIO)) + payload["scenario"]["location"] = "" + payload["scenario"]["player_id"] = "" + self._set_editor_text(json.dumps(payload, indent=2)) + self._set_current_path(None) + self._set_scenario_active(True) + self._load_metadata_from_payload(payload) + self._set_status("New scenario created") + elif button_id == "refresh": + self._load_scenario_list() + self._set_status("Scenario list refreshed") + elif button_id == "import": + self._open_import_prompt() + elif ( + button_id + in { + "save", + "export", + "entities", + "spatial_graph", + "metadata_apply", + "metadata_reload", + } + and not self.scenario_active + ): + self._set_status("Load or create a scenario first") + return + elif button_id == "save": + payload = self._parse_editor_json() + if payload is None: + return + if self.current_path: + self._write_to_path(self.current_path) + return + + default_name = self._default_filename(payload) + self._open_save_prompt(default_name) + elif button_id == "export": + payload = self._parse_editor_json() + if payload is None: + return + default_name = self._default_filename(payload) + self._open_export_prompt(default_name) + elif button_id == "entities": + self._open_entities_screen() + elif button_id == "spatial_graph": + self._open_spatial_screen() + elif button_id == "metadata_apply": + self._apply_metadata_changes() + elif button_id == "metadata_reload": + payload = self._parse_editor_json() + if payload is None: + return + self._load_metadata_from_payload(payload) + self._set_status("Metadata reloaded") + + +def main() -> None: + ScenarioBuilderTUI().run() + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index 7dc29b5..dd0d936 100644 --- a/uv.lock +++ b/uv.lock @@ -827,6 +827,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/19/96250cf58070c5563446651b03bb76c2eb5afbf08e754840ab639532d8c6/langsmith-0.7.30-py3-none-any.whl", hash = "sha256:43dd9f8d290e4d406606d6cc0bd62f5d1050963f05fe0ab6ffe50acf41f2f55a", size = 372682, upload-time = "2026-04-09T21:12:00.481Z" }, ] +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + [[package]] name = "llama-cpp-python" version = "0.3.20" @@ -851,6 +863,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -926,6 +943,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" }, ] +[[package]] +name = "mdit-py-plugins" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1283,6 +1312,7 @@ dependencies = [ { name = "pip" }, { name = "rich" }, { name = "sentence-transformers" }, + { name = "textual" }, ] [package.dev-dependencies] @@ -1299,6 +1329,7 @@ requires-dist = [ { name = "pip", specifier = ">=26.0.1" }, { name = "rich", specifier = ">=13.9.4" }, { name = "sentence-transformers", specifier = ">=5.4.0" }, + { name = "textual", specifier = ">=0.63.0" }, ] [package.metadata.requires-dev] @@ -1414,6 +1445,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/f0/c81e05b613866b76d2d1066490adf1a3dbc4ee9d9c839961c3fc8a6997af/pip-26.0.1-py3-none-any.whl", hash = "sha256:bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b", size = 1787723, upload-time = "2026-02-05T02:20:16.416Z" }, ] +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + [[package]] name = "propcache" version = "0.4.1" @@ -2021,6 +2061,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] +[[package]] +name = "textual" +version = "8.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/2f/d44f0f12b3ddb1f0b88f7775652e99c6b5a43fd733badf4ce064bdbfef4a/textual-8.2.3.tar.gz", hash = "sha256:beea7b86b03b03558a2224f0cc35252e60ef8b0c4353b117b2f40972902d976a", size = 1848738, upload-time = "2026-04-05T09:12:45.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/28/a81d6ce9f4804818bd1231a9a6e4d56ea84ebbe8385c49591444f0234fa2/textual-8.2.3-py3-none-any.whl", hash = "sha256:5008ac581bebf1f6fa0520404261844a231e5715fdbddd10ca73916a3af48ca2", size = 724231, upload-time = "2026-04-05T09:12:48.747Z" }, +] + [[package]] name = "threadpoolctl" version = "3.6.0" @@ -2197,6 +2254,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + [[package]] name = "urllib3" version = "2.6.3"