2026-01-11 00:39:17 -08:00
|
|
|
from textual.app import ComposeResult
|
|
|
|
|
from textual.screen import ModalScreen
|
|
|
|
|
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
|
|
|
|
|
from textual.widgets import Static, Button, Input, Label, ListView, ListItem, Rule, OptionList, Footer
|
|
|
|
|
from textual import on
|
|
|
|
|
from textual.message import Message
|
|
|
|
|
from typing import Dict, Any, List, Optional
|
|
|
|
|
import os
|
|
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from SYS.config import load_config, save_config
|
|
|
|
|
from Store.registry import _discover_store_classes, _required_keys_for
|
2026-01-11 02:42:08 -08:00
|
|
|
from ProviderCore.registry import list_providers
|
|
|
|
|
from TUI.modalscreen.selection_modal import SelectionModal
|
2026-01-11 00:39:17 -08:00
|
|
|
|
|
|
|
|
class ConfigModal(ModalScreen):
|
|
|
|
|
"""A modal for editing the configuration."""
|
|
|
|
|
|
|
|
|
|
CSS = """
|
|
|
|
|
ConfigModal {
|
|
|
|
|
align: center middle;
|
|
|
|
|
background: $boost;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#config-container {
|
|
|
|
|
width: 90%;
|
|
|
|
|
height: 90%;
|
|
|
|
|
background: $panel;
|
|
|
|
|
border: thick $primary;
|
|
|
|
|
padding: 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.section-title {
|
|
|
|
|
background: $accent;
|
|
|
|
|
color: $text;
|
|
|
|
|
padding: 0 1;
|
|
|
|
|
margin-bottom: 1;
|
|
|
|
|
text-align: center;
|
|
|
|
|
text-style: bold;
|
|
|
|
|
height: 3;
|
|
|
|
|
content-align: center middle;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#config-sidebar {
|
|
|
|
|
width: 25%;
|
|
|
|
|
border-right: solid $surface;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#config-content {
|
|
|
|
|
width: 75%;
|
|
|
|
|
padding: 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.config-field {
|
|
|
|
|
margin-bottom: 1;
|
|
|
|
|
height: auto;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.config-label {
|
|
|
|
|
width: 100%;
|
|
|
|
|
text-style: bold;
|
|
|
|
|
color: $accent;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.config-input {
|
|
|
|
|
width: 100%;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#config-actions {
|
|
|
|
|
height: 3;
|
|
|
|
|
align: right middle;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.item-row {
|
|
|
|
|
height: 3;
|
|
|
|
|
margin-bottom: 1;
|
|
|
|
|
padding: 0 1;
|
|
|
|
|
border: solid $surface;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.item-label {
|
|
|
|
|
width: 1fr;
|
|
|
|
|
content-align: left middle;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Button {
|
|
|
|
|
margin: 0 1;
|
|
|
|
|
}
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
super().__init__()
|
|
|
|
|
# Load config from the workspace root (parent of SYS)
|
|
|
|
|
workspace_root = Path(__file__).resolve().parent.parent.parent
|
|
|
|
|
self.config_data = load_config(config_dir=workspace_root)
|
|
|
|
|
self.current_category = "globals"
|
|
|
|
|
self.editing_item_type = None # 'store' or 'provider'
|
|
|
|
|
self.editing_item_name = None
|
|
|
|
|
self.workspace_root = workspace_root
|
|
|
|
|
|
|
|
|
|
def compose(self) -> ComposeResult:
|
|
|
|
|
with Container(id="config-container"):
|
|
|
|
|
yield Static("CONFIGURATION EDITOR", classes="section-title")
|
|
|
|
|
with Horizontal():
|
|
|
|
|
with Vertical(id="config-sidebar"):
|
|
|
|
|
yield Label("Categories", classes="config-label")
|
|
|
|
|
with ListView(id="category-list"):
|
|
|
|
|
yield ListItem(Label("Global Settings"), id="cat-globals")
|
|
|
|
|
yield ListItem(Label("Stores"), id="cat-stores")
|
|
|
|
|
yield ListItem(Label("Providers"), id="cat-providers")
|
|
|
|
|
|
|
|
|
|
with Vertical(id="config-content"):
|
|
|
|
|
yield ScrollableContainer(id="fields-container")
|
|
|
|
|
with Horizontal(id="config-actions"):
|
|
|
|
|
yield Button("Save", variant="success", id="save-btn")
|
|
|
|
|
yield Button("Add Store", variant="primary", id="add-store-btn")
|
2026-01-11 02:42:08 -08:00
|
|
|
yield Button("Add Provider", variant="primary", id="add-provider-btn")
|
2026-01-11 00:39:17 -08:00
|
|
|
yield Button("Back", id="back-btn")
|
|
|
|
|
yield Button("Close", variant="error", id="cancel-btn")
|
|
|
|
|
|
|
|
|
|
def on_mount(self) -> None:
|
|
|
|
|
self.query_one("#add-store-btn", Button).display = False
|
2026-01-11 02:42:08 -08:00
|
|
|
self.query_one("#add-provider-btn", Button).display = False
|
2026-01-11 00:39:17 -08:00
|
|
|
self.query_one("#back-btn", Button).display = False
|
|
|
|
|
self.refresh_view()
|
|
|
|
|
|
|
|
|
|
def refresh_view(self) -> None:
|
|
|
|
|
container = self.query_one("#fields-container", ScrollableContainer)
|
|
|
|
|
|
|
|
|
|
# Clear existing synchronously
|
|
|
|
|
for child in list(container.children):
|
|
|
|
|
child.remove()
|
|
|
|
|
|
|
|
|
|
# Update visibility of buttons
|
|
|
|
|
try:
|
|
|
|
|
self.query_one("#add-store-btn", Button).display = (self.current_category == "stores" and self.editing_item_name is None)
|
2026-01-11 02:42:08 -08:00
|
|
|
self.query_one("#add-provider-btn", Button).display = (self.current_category == "providers" and self.editing_item_name is None)
|
2026-01-11 00:39:17 -08:00
|
|
|
self.query_one("#back-btn", Button).display = (self.editing_item_name is not None)
|
2026-01-11 02:42:08 -08:00
|
|
|
self.query_one("#save-btn", Button).display = (self.editing_item_name is not None or self.current_category == "globals")
|
2026-01-11 00:39:17 -08:00
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# We mount using call_after_refresh to ensure the removals are processed by Textual
|
|
|
|
|
# before we try to mount new widgets with potentially duplicate IDs.
|
|
|
|
|
def do_mount():
|
|
|
|
|
if self.editing_item_name:
|
|
|
|
|
self.render_item_editor(container)
|
|
|
|
|
elif self.current_category == "globals":
|
|
|
|
|
self.render_globals(container)
|
|
|
|
|
elif self.current_category == "stores":
|
|
|
|
|
self.render_stores(container)
|
|
|
|
|
elif self.current_category == "providers":
|
|
|
|
|
self.render_providers(container)
|
|
|
|
|
|
|
|
|
|
self.call_after_refresh(do_mount)
|
|
|
|
|
|
|
|
|
|
def render_globals(self, container: ScrollableContainer) -> None:
|
|
|
|
|
container.mount(Label("General Configuration", classes="config-label"))
|
|
|
|
|
for k, v in self.config_data.items():
|
|
|
|
|
if not isinstance(v, dict) and not k.startswith("_"):
|
|
|
|
|
container.mount(Label(k))
|
|
|
|
|
container.mount(Input(value=str(v), id=f"global-{k}", classes="config-input"))
|
|
|
|
|
|
|
|
|
|
def render_stores(self, container: ScrollableContainer) -> None:
|
|
|
|
|
container.mount(Label("Configured Stores", classes="config-label"))
|
|
|
|
|
stores = self.config_data.get("store", {})
|
|
|
|
|
if not stores:
|
|
|
|
|
container.mount(Static("No stores configured."))
|
|
|
|
|
else:
|
|
|
|
|
# stores is structured as: {type: {name: config}}
|
|
|
|
|
for stype, instances in stores.items():
|
|
|
|
|
if isinstance(instances, dict):
|
|
|
|
|
for name, conf in instances.items():
|
|
|
|
|
row = Horizontal(
|
|
|
|
|
Static(f"{name} ({stype})", classes="item-label"),
|
|
|
|
|
Button("Edit", id=f"edit-store-{stype}-{name}"),
|
|
|
|
|
Button("Delete", variant="error", id=f"del-store-{stype}-{name}"),
|
|
|
|
|
classes="item-row"
|
|
|
|
|
)
|
|
|
|
|
container.mount(row)
|
|
|
|
|
|
|
|
|
|
def render_providers(self, container: ScrollableContainer) -> None:
|
|
|
|
|
container.mount(Label("Configured Providers", classes="config-label"))
|
|
|
|
|
providers = self.config_data.get("provider", {})
|
|
|
|
|
if not providers:
|
|
|
|
|
container.mount(Static("No providers configured."))
|
|
|
|
|
else:
|
|
|
|
|
for name, _ in providers.items():
|
|
|
|
|
row = Horizontal(
|
|
|
|
|
Static(name, classes="item-label"),
|
|
|
|
|
Button("Edit", id=f"edit-provider-{name}"),
|
2026-01-11 02:42:08 -08:00
|
|
|
Button("Delete", variant="error", id=f"del-provider-{name}"),
|
2026-01-11 00:39:17 -08:00
|
|
|
classes="item-row"
|
|
|
|
|
)
|
|
|
|
|
container.mount(row)
|
|
|
|
|
|
|
|
|
|
def render_item_editor(self, container: ScrollableContainer) -> None:
|
|
|
|
|
item_type = str(self.editing_item_type or "")
|
|
|
|
|
item_name = str(self.editing_item_name or "")
|
|
|
|
|
|
|
|
|
|
# Parse item_type for store-{stype} or just provider
|
|
|
|
|
if item_type.startswith("store-"):
|
|
|
|
|
stype = item_type.replace("store-", "")
|
|
|
|
|
container.mount(Label(f"Editing Store: {item_name} ({stype})", classes="config-label"))
|
|
|
|
|
section = self.config_data.get("store", {}).get(stype, {}).get(item_name, {})
|
|
|
|
|
else:
|
|
|
|
|
container.mount(Label(f"Editing {item_type.capitalize()}: {item_name}", classes="config-label"))
|
|
|
|
|
section = self.config_data.get(item_type, {}).get(item_name, {})
|
|
|
|
|
|
|
|
|
|
# Show all existing keys
|
|
|
|
|
existing_keys_upper = set()
|
|
|
|
|
for k, v in section.items():
|
|
|
|
|
if k.startswith("_"): continue
|
|
|
|
|
|
|
|
|
|
# Deduplicate keys case-insensitively (e.g. name vs NAME vs Name)
|
|
|
|
|
k_upper = k.upper()
|
|
|
|
|
if k_upper in existing_keys_upper:
|
|
|
|
|
continue
|
|
|
|
|
existing_keys_upper.add(k_upper)
|
|
|
|
|
|
|
|
|
|
container.mount(Label(k))
|
|
|
|
|
container.mount(Input(value=str(v), id=f"item-{k}", classes="config-input"))
|
|
|
|
|
|
|
|
|
|
# If it's a store, we might have required keys
|
|
|
|
|
if item_type.startswith("store-"):
|
|
|
|
|
stype = item_type.replace("store-", "")
|
|
|
|
|
classes = _discover_store_classes()
|
|
|
|
|
if stype in classes:
|
|
|
|
|
required_keys = _required_keys_for(classes[stype])
|
|
|
|
|
for rk in required_keys:
|
|
|
|
|
# Case-insensitive deduplication (fix path vs PATH)
|
|
|
|
|
if rk.upper() not in existing_keys_upper:
|
|
|
|
|
container.mount(Label(rk))
|
|
|
|
|
container.mount(Input(value="", id=f"item-{rk}", classes="config-input"))
|
2026-01-11 02:42:08 -08:00
|
|
|
|
|
|
|
|
# If it's a provider, we might have required keys
|
|
|
|
|
if item_type == "provider":
|
|
|
|
|
from ProviderCore.registry import get_provider_class
|
|
|
|
|
try:
|
|
|
|
|
pcls = get_provider_class(item_name)
|
|
|
|
|
if pcls:
|
|
|
|
|
required_keys = pcls.required_config_keys()
|
|
|
|
|
for rk in required_keys:
|
|
|
|
|
if rk.upper() not in existing_keys_upper:
|
|
|
|
|
container.mount(Label(rk))
|
|
|
|
|
container.mount(Input(value="", id=f"item-{rk}", classes="config-input"))
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2026-01-11 00:39:17 -08:00
|
|
|
|
|
|
|
|
def create_field(self, name: str, value: Any, id: str) -> Vertical:
|
|
|
|
|
# This method is now unused - we mount labels and inputs directly
|
|
|
|
|
v = Vertical(classes="config-field")
|
|
|
|
|
return v
|
|
|
|
|
|
|
|
|
|
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
|
|
|
if not event.item: return
|
|
|
|
|
if event.item.id == "cat-globals":
|
|
|
|
|
self.current_category = "globals"
|
|
|
|
|
elif event.item.id == "cat-stores":
|
|
|
|
|
self.current_category = "stores"
|
|
|
|
|
elif event.item.id == "cat-providers":
|
|
|
|
|
self.current_category = "providers"
|
|
|
|
|
|
|
|
|
|
self.editing_item_name = None
|
|
|
|
|
self.editing_item_type = None
|
|
|
|
|
self.refresh_view()
|
|
|
|
|
|
|
|
|
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
|
|
|
bid = event.button.id
|
|
|
|
|
if not bid: return
|
|
|
|
|
|
|
|
|
|
if bid == "cancel-btn":
|
|
|
|
|
self.dismiss()
|
|
|
|
|
elif bid == "back-btn":
|
|
|
|
|
self.editing_item_name = None
|
|
|
|
|
self.editing_item_type = None
|
|
|
|
|
self.refresh_view()
|
|
|
|
|
elif bid == "save-btn":
|
2026-01-11 02:42:08 -08:00
|
|
|
if not self.validate_current_editor():
|
|
|
|
|
return
|
2026-01-11 00:39:17 -08:00
|
|
|
self.save_all()
|
|
|
|
|
self.notify("Configuration saved!")
|
|
|
|
|
# Return to the main list view within the current category
|
|
|
|
|
self.editing_item_name = None
|
|
|
|
|
self.editing_item_type = None
|
|
|
|
|
self.refresh_view()
|
|
|
|
|
elif bid.startswith("edit-store-"):
|
|
|
|
|
parts = bid.replace("edit-store-", "").split("-", 1)
|
|
|
|
|
if len(parts) == 2:
|
|
|
|
|
stype, name = parts
|
|
|
|
|
self.editing_item_type = f"store-{stype}"
|
|
|
|
|
self.editing_item_name = name
|
|
|
|
|
self.refresh_view()
|
|
|
|
|
elif bid.startswith("edit-provider-"):
|
|
|
|
|
self.editing_item_name = bid.replace("edit-provider-", "")
|
|
|
|
|
self.editing_item_type = "provider"
|
|
|
|
|
self.refresh_view()
|
|
|
|
|
elif bid == "add-store-btn":
|
2026-01-11 02:42:08 -08:00
|
|
|
options = list(_discover_store_classes().keys())
|
|
|
|
|
self.app.push_screen(SelectionModal("Select Store Type", options), callback=self.on_store_type_selected)
|
|
|
|
|
elif bid == "add-provider-btn":
|
|
|
|
|
options = list(list_providers().keys())
|
|
|
|
|
self.app.push_screen(SelectionModal("Select Provider Type", options), callback=self.on_provider_type_selected)
|
2026-01-11 00:39:17 -08:00
|
|
|
elif bid.startswith("del-store-"):
|
|
|
|
|
parts = bid.replace("del-store-", "").split("-", 1)
|
|
|
|
|
if len(parts) == 2:
|
|
|
|
|
stype, name = parts
|
|
|
|
|
if "store" in self.config_data and stype in self.config_data["store"]:
|
|
|
|
|
if name in self.config_data["store"][stype]:
|
|
|
|
|
del self.config_data["store"][stype][name]
|
|
|
|
|
self.refresh_view()
|
2026-01-11 02:42:08 -08:00
|
|
|
elif bid.startswith("del-provider-"):
|
|
|
|
|
name = bid.replace("del-provider-", "")
|
|
|
|
|
if "provider" in self.config_data and name in self.config_data["provider"]:
|
|
|
|
|
del self.config_data["provider"][name]
|
|
|
|
|
self.refresh_view()
|
|
|
|
|
|
|
|
|
|
def on_store_type_selected(self, stype: str) -> None:
|
|
|
|
|
if not stype: return
|
|
|
|
|
new_name = f"new_{stype}"
|
|
|
|
|
if "store" not in self.config_data:
|
|
|
|
|
self.config_data["store"] = {}
|
|
|
|
|
if stype not in self.config_data["store"]:
|
|
|
|
|
self.config_data["store"][stype] = {}
|
|
|
|
|
|
|
|
|
|
# Default config for the new store
|
|
|
|
|
new_config = {"NAME": new_name}
|
|
|
|
|
classes = _discover_store_classes()
|
|
|
|
|
if stype in classes:
|
|
|
|
|
required = _required_keys_for(classes[stype])
|
|
|
|
|
for rk in required:
|
|
|
|
|
if rk.upper() != "NAME":
|
|
|
|
|
new_config[rk] = ""
|
|
|
|
|
|
|
|
|
|
self.config_data["store"][stype][new_name] = new_config
|
|
|
|
|
self.editing_item_type = f"store-{stype}"
|
|
|
|
|
self.editing_item_name = new_name
|
|
|
|
|
self.refresh_view()
|
|
|
|
|
|
|
|
|
|
def on_provider_type_selected(self, ptype: str) -> None:
|
|
|
|
|
if not ptype: return
|
|
|
|
|
if "provider" not in self.config_data:
|
|
|
|
|
self.config_data["provider"] = {}
|
|
|
|
|
|
|
|
|
|
# For providers, they are usually top-level entries in 'provider' dict
|
|
|
|
|
if ptype not in self.config_data["provider"]:
|
|
|
|
|
# Get required keys if possible
|
|
|
|
|
from ProviderCore.registry import get_provider_class
|
|
|
|
|
try:
|
|
|
|
|
pcls = get_provider_class(ptype)
|
|
|
|
|
new_config = {}
|
|
|
|
|
if pcls:
|
|
|
|
|
required = pcls.required_config_keys()
|
|
|
|
|
for rk in required:
|
|
|
|
|
new_config[rk] = ""
|
|
|
|
|
self.config_data["provider"][ptype] = new_config
|
|
|
|
|
except Exception:
|
|
|
|
|
self.config_data["provider"][ptype] = {}
|
|
|
|
|
|
|
|
|
|
self.editing_item_type = "provider"
|
|
|
|
|
self.editing_item_name = ptype
|
|
|
|
|
self.refresh_view()
|
2026-01-11 00:39:17 -08:00
|
|
|
|
|
|
|
|
@on(Input.Changed)
|
|
|
|
|
def on_input_changed(self, event: Input.Changed) -> None:
|
|
|
|
|
if not event.input.id:
|
|
|
|
|
return
|
|
|
|
|
if event.input.id.startswith("global-"):
|
|
|
|
|
key = event.input.id.replace("global-", "")
|
|
|
|
|
self.config_data[key] = event.value
|
|
|
|
|
elif event.input.id.startswith("item-") and self.editing_item_name:
|
|
|
|
|
key = event.input.id.replace("item-", "")
|
|
|
|
|
it = str(self.editing_item_type or "")
|
|
|
|
|
inm = str(self.editing_item_name or "")
|
|
|
|
|
|
|
|
|
|
# Handle nested store structure
|
|
|
|
|
if it.startswith("store-"):
|
|
|
|
|
stype = it.replace("store-", "")
|
|
|
|
|
if "store" not in self.config_data:
|
|
|
|
|
self.config_data["store"] = {}
|
|
|
|
|
if stype not in self.config_data["store"]:
|
|
|
|
|
self.config_data["store"][stype] = {}
|
|
|
|
|
if inm not in self.config_data["store"][stype]:
|
|
|
|
|
self.config_data["store"][stype][inm] = {}
|
|
|
|
|
self.config_data["store"][stype][inm][key] = event.value
|
|
|
|
|
else:
|
|
|
|
|
# Provider or other top-level sections
|
|
|
|
|
if it not in self.config_data:
|
|
|
|
|
self.config_data[it] = {}
|
|
|
|
|
if inm not in self.config_data[it]:
|
|
|
|
|
self.config_data[it][inm] = {}
|
|
|
|
|
self.config_data[it][inm][key] = event.value
|
|
|
|
|
|
|
|
|
|
def save_all(self) -> None:
|
|
|
|
|
save_config(self.config_data, config_dir=self.workspace_root)
|
2026-01-11 02:42:08 -08:00
|
|
|
|
|
|
|
|
def validate_current_editor(self) -> bool:
|
|
|
|
|
"""Ensure all required fields for the current item are filled."""
|
|
|
|
|
if not self.editing_item_name:
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
item_type = str(self.editing_item_type or "")
|
|
|
|
|
item_name = str(self.editing_item_name or "")
|
|
|
|
|
|
|
|
|
|
required_keys = []
|
|
|
|
|
section = {}
|
|
|
|
|
|
|
|
|
|
if item_type.startswith("store-"):
|
|
|
|
|
stype = item_type.replace("store-", "")
|
|
|
|
|
classes = _discover_store_classes()
|
|
|
|
|
if stype in classes:
|
|
|
|
|
required_keys = _required_keys_for(classes[stype])
|
|
|
|
|
section = self.config_data.get("store", {}).get(stype, {}).get(item_name, {})
|
|
|
|
|
elif item_type == "provider":
|
|
|
|
|
from ProviderCore.registry import get_provider_class
|
|
|
|
|
try:
|
|
|
|
|
pcls = get_provider_class(item_name)
|
|
|
|
|
if pcls:
|
|
|
|
|
required_keys = pcls.required_config_keys()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
section = self.config_data.get("provider", {}).get(item_name, {})
|
|
|
|
|
|
|
|
|
|
# Check required keys
|
|
|
|
|
for rk in required_keys:
|
|
|
|
|
# Case-insensitive lookup for the required key in the current section
|
|
|
|
|
val = None
|
|
|
|
|
rk_upper = rk.upper()
|
|
|
|
|
for k, v in section.items():
|
|
|
|
|
if k.upper() == rk_upper:
|
|
|
|
|
val = v
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
if not val or not str(val).strip():
|
|
|
|
|
self.notify(f"Required field '{rk}' cannot be blank", severity="error")
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
return True
|