refactored config plugin defintions

This commit is contained in:
2026-04-21 14:18:52 -07:00
parent bc95a5c45d
commit 90787bd0a2
7 changed files with 439 additions and 321 deletions
+156 -315
View File
@@ -7,7 +7,7 @@ from textual import on, work
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
from textual.screen import ModalScreen
from textual.widgets import Static, Button, Input, Label, ListView, ListItem, Rule, Select, Checkbox
from textual.widgets import Static, Button, Input, Label, ListView, ListItem, Rule, Select, Checkbox, TextArea
from pathlib import Path
from SYS.config import (
@@ -15,15 +15,24 @@ from SYS.config import (
save_config,
save_config_and_verify,
reload_config,
global_config,
count_changed_entries,
ConfigSaveConflict,
coerce_config_value,
)
from SYS.database import db
from SYS.logger import log, debug
from Store.registry import _discover_store_classes, _required_keys_for
from ProviderCore.registry import get_plugin, list_plugins
from SYS.plugin_config import (
build_default_provider_config,
build_default_store_config,
build_default_tool_config,
get_configurable_provider_types,
get_configurable_store_types,
get_configurable_tool_types,
get_global_schema,
get_item_schema_map,
get_required_config_keys,
)
from ProviderCore.registry import get_plugin
from TUI.modalscreen.matrix_room_picker import MatrixRoomPicker
from TUI.modalscreen.selection_modal import SelectionModal
import logging
@@ -93,6 +102,18 @@ class ConfigModal(ModalScreen):
width: 1fr;
}
.config-textarea {
height: 8;
margin-bottom: 1;
}
.config-group {
color: $accent;
text-style: bold;
margin-top: 1;
margin-bottom: 1;
}
#config-actions {
height: 3;
align: right middle;
@@ -277,18 +298,19 @@ class ConfigModal(ModalScreen):
def render_globals(self, container: ScrollableContainer) -> None:
container.mount(Label("General Configuration", classes="config-label"))
# Get global schema
schema_map = {f["key"].lower(): f for f in global_config()}
schema = get_global_schema()
schema_map = {f["key"].lower(): f for f in schema}
existing_keys_lower = set()
render_state = {"group": None, "mounted_any": False}
idx = 0
# Show fields defined in schema first
for key_lower, field_def in schema_map.items():
for field_def in schema:
key_lower = field_def["key"].lower()
existing_keys_lower.add(key_lower)
label_text = field_def.get("label") or field_def["key"]
choices = field_def.get("choices")
self._mount_schema_group(container, field_def, render_state)
# Find current value (case-insensitive)
current_val = None
found_key = field_def["key"]
@@ -301,33 +323,10 @@ class ConfigModal(ModalScreen):
if current_val is None:
current_val = str(field_def.get("default") or "")
container.mount(Label(label_text))
inp_id = f"global-{idx}"
self._input_id_map[inp_id] = found_key
if choices:
# Normalize boolean-like choices to lowercase ('true'/'false') to avoid duplicate choices
normalized_choices = []
for c in choices:
s = str(c)
if s.lower() in ("true", "false"):
normalized_choices.append(s.lower())
else:
normalized_choices.append(s)
select_options = [(str(c), str(c)) for c in normalized_choices]
# Normalize current value as well
cur_val = str(current_val) if current_val is not None else ""
if cur_val.lower() in ("true", "false"):
cur_val = cur_val.lower()
if cur_val not in normalized_choices:
select_options.insert(0, (cur_val, cur_val))
sel = Select(select_options, value=cur_val, id=inp_id)
container.mount(sel)
else:
row = Horizontal(classes="field-row")
container.mount(row)
row.mount(Input(value=current_val, id=inp_id, classes="config-input"))
self._mount_schema_field(container, field_def, inp_id, current_val, allow_paste=True)
render_state["mounted_any"] = True
idx += 1
# Show any other top-level keys not in schema
@@ -345,6 +344,85 @@ class ConfigModal(ModalScreen):
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
idx += 1
def _mount_schema_group(self, container: ScrollableContainer, field_def: Dict[str, Any], state: Dict[str, Any]) -> None:
group_name = str(field_def.get("group") or "").strip()
if not group_name:
return
if state.get("group") == group_name:
return
if state.get("mounted_any"):
container.mount(Rule())
container.mount(Label(group_name, classes="config-group"))
state["group"] = group_name
def _normalized_select_options(self, choices: Any, current_val: Any) -> tuple[list[tuple[str, str]], str]:
select_options: list[tuple[str, str]] = []
choice_values: list[str] = []
for choice in list(choices or []):
if isinstance(choice, tuple) and len(choice) == 2:
label = str(choice[0])
value = str(choice[1])
else:
label = str(choice)
value = str(choice)
if value.lower() in ("true", "false"):
value = value.lower()
label = value
select_options.append((label, value))
choice_values.append(value)
normalized_current = str(current_val) if current_val is not None else ""
if normalized_current.lower() in ("true", "false"):
normalized_current = normalized_current.lower()
if normalized_current not in choice_values:
select_options.insert(0, (normalized_current, normalized_current))
return select_options, normalized_current
def _mount_schema_field(
self,
container: ScrollableContainer,
field_def: Dict[str, Any],
widget_id: str,
current_value: Any,
*,
allow_paste: bool,
) -> None:
label_text = str(field_def.get("label") or field_def.get("key") or widget_id)
if field_def.get("required"):
label_text += " *"
container.mount(Label(label_text))
choices = field_def.get("choices")
field_type = str(field_def.get("type") or "").strip().lower()
if choices:
select_options, normalized_current = self._normalized_select_options(choices, current_value)
container.mount(Select(select_options, value=normalized_current, id=widget_id))
return
if field_type in {"multiline", "textarea"}:
text_area = TextArea(str(current_value or ""), id=widget_id, classes="config-textarea")
placeholder = field_def.get("placeholder")
if placeholder:
try:
text_area.tooltip = str(placeholder)
except Exception:
pass
container.mount(text_area)
return
row = Horizontal(classes="field-row")
container.mount(row)
input_widget = Input(value=str(current_value or ""), id=widget_id, classes="config-input")
if field_def.get("secret") or field_type == "secret":
input_widget.password = True
if field_def.get("placeholder"):
input_widget.placeholder = str(field_def.get("placeholder") or "")
elif field_type == "path":
input_widget.placeholder = "Path"
row.mount(input_widget)
if allow_paste:
row.mount(Button("Paste", id=f"paste-{widget_id}", classes="paste-btn"))
def render_stores(self, container: ScrollableContainer) -> None:
container.mount(Label("Configured Stores", classes="config-label"))
stores = self.config_data.get("store", {})
@@ -423,54 +501,18 @@ class ConfigModal(ModalScreen):
def render_item_editor(self, container: ScrollableContainer) -> None:
item_type = str(self.editing_item_type or "")
item_name = str(self.editing_item_name or "")
provider_schema_map = {}
item_schema_map = get_item_schema_map(item_type, item_name)
render_state = {"group": None, "mounted_any": False}
# 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, {})
# Fetch Store schema
classes = _discover_store_classes()
if stype in classes:
cls = classes[stype]
if hasattr(cls, "config_schema") and callable(cls.config_schema):
for field_def in cls.config_schema():
k = field_def.get("key")
if k:
provider_schema_map[k.upper()] = field_def
else:
container.mount(Label(f"Editing {item_type.capitalize()}: {item_name}", classes="config-label"))
section = self.config_data.get(item_type, {}).get(item_name, {})
# Fetch Provider schema
if item_type == "provider":
from ProviderCore.registry import get_plugin_class
try:
pcls = get_plugin_class(item_name)
if pcls and hasattr(pcls, "config_schema") and callable(pcls.config_schema):
for field_def in pcls.config_schema():
k = field_def.get("key")
if k:
provider_schema_map[k.upper()] = field_def
except Exception:
logger.exception("Failed to retrieve provider config_schema")
# Fetch Tool schema
if item_type == "tool":
try:
import importlib
mod = importlib.import_module(f"tool.{item_name}")
if hasattr(mod, "config_schema") and callable(mod.config_schema):
for field_def in mod.config_schema():
k = field_def.get("key")
if k:
provider_schema_map[k.upper()] = field_def
except Exception:
logger.exception("Failed to retrieve tool config_schema")
# Use columns for better layout of inputs with paste buttons
container.mount(Label("Edit Settings"))
# render_item_editor will handle the inputs for us if we set these
@@ -504,144 +546,53 @@ class ConfigModal(ModalScreen):
label_text = k
is_secret = False
choices = None
schema = provider_schema_map.get(k_upper)
schema = item_schema_map.get(k_upper)
if schema:
label_text = schema.get("label") or k
if schema.get("required"):
label_text += " *"
self._mount_schema_group(container, schema, render_state)
if schema.get("secret"):
is_secret = True
choices = schema.get("choices")
container.mount(Label(label_text))
inp_id = f"item-{idx}"
self._input_id_map[inp_id] = k
if choices:
# Select takes a list of (label, value) tuples; normalize boolean-like values
select_options = []
choice_values = []
for c in choices:
if isinstance(c, tuple) and len(c) == 2:
label = str(c[0])
val = str(c[1])
else:
label = str(c)
val = str(c)
if val.lower() in ("true", "false"):
val = val.lower()
label = val
select_options.append((label, val))
choice_values.append(val)
# If current value not in choices, add it or stay blank
current_val = str(v)
if current_val.lower() in ("true", "false"):
current_val = current_val.lower()
if current_val not in choice_values:
select_options.insert(0, (current_val, current_val))
sel = Select(select_options, value=current_val, id=inp_id)
container.mount(sel)
if schema:
self._mount_schema_field(container, schema, inp_id, v, allow_paste=True)
else:
container.mount(Label(label_text))
row = Horizontal(classes="field-row")
container.mount(row)
inp = Input(value=str(v), id=inp_id, classes="config-input")
if is_secret:
inp.password = True
row.mount(inp)
render_state["mounted_any"] = True
idx += 1
# Add required/optional fields from schema that are missing
for k_upper, field_def in provider_schema_map.items():
for k_upper, field_def in item_schema_map.items():
if k_upper not in existing_keys_upper:
existing_keys_upper.add(k_upper)
key = field_def["key"]
label_text = field_def.get("label") or key
if field_def.get("required"):
label_text += " *"
self._mount_schema_group(container, field_def, render_state)
default_val = str(field_def.get("default") or "")
choices = field_def.get("choices")
container.mount(Label(label_text))
inp_id = f"item-{idx}"
self._input_id_map[inp_id] = key
if choices:
select_options = []
choice_values = []
for c in choices:
if isinstance(c, tuple) and len(c) == 2:
label = str(c[0])
val = str(c[1])
else:
label = str(c)
val = str(c)
if val.lower() in ("true", "false"):
val = val.lower()
label = val
select_options.append((label, val))
choice_values.append(val)
# Normalize default/current value
current_val = str(default_val) if default_val is not None else ""
if current_val.lower() in ("true", "false"):
current_val = current_val.lower()
if current_val not in choice_values:
select_options.insert(0, (current_val, current_val))
sel = Select(select_options, value=current_val, id=inp_id)
container.mount(sel)
else:
row = Horizontal(classes="field-row")
container.mount(row)
inp = Input(value=default_val, id=inp_id, classes="config-input")
if field_def.get("secret"):
inp.password = True
if field_def.get("placeholder"):
inp.placeholder = field_def.get("placeholder")
row.mount(inp)
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
self._mount_schema_field(container, field_def, inp_id, default_val, allow_paste=True)
render_state["mounted_any"] = True
idx += 1
# If it's a store, we might have required keys (legacy check fallback)
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))
inp_id = f"item-{idx}"
self._input_id_map[inp_id] = rk
row = Horizontal(classes="field-row")
container.mount(row)
row.mount(Input(value="", id=inp_id, classes="config-input"))
idx += 1
# If it's a provider, we might have required keys (legacy check fallback)
if item_type == "provider":
# 2. Legacy required_config_keys
from ProviderCore.registry import get_plugin_class
try:
pcls = get_plugin_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))
inp_id = f"item-{idx}"
self._input_id_map[inp_id] = rk
row = Horizontal(classes="field-row")
container.mount(row)
row.mount(Input(value="", id=inp_id, classes="config-input"))
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
idx += 1
except Exception:
logger.exception("Failed to build required config inputs for provider/tool")
for required_key in get_required_config_keys(item_type, item_name):
if required_key.upper() in existing_keys_upper:
continue
existing_keys_upper.add(required_key.upper())
container.mount(Label(required_key))
inp_id = f"item-{idx}"
self._input_id_map[inp_id] = required_key
row = Horizontal(classes="field-row")
container.mount(row)
row.mount(Input(value="", id=inp_id, classes="config-input"))
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
idx += 1
if (
item_type == "provider"
@@ -875,48 +826,13 @@ class ConfigModal(ModalScreen):
self.notify(f"Save failed: {exc}", severity="error", timeout=10)
self.refresh_view()
elif bid == "add-store-btn":
all_classes = _discover_store_classes()
options = []
for stype, cls in all_classes.items():
if hasattr(cls, "config_schema") and callable(cls.config_schema):
try:
if cls.config_schema():
options.append(stype)
except Exception:
logger.exception("Failed to inspect store class config_schema for '%s'", stype)
options = get_configurable_store_types()
self.app.push_screen(SelectionModal("Select Store Type", options), callback=self.on_store_type_selected)
elif bid == "add-provider-btn":
provider_names = list(list_plugins().keys())
options = []
from ProviderCore.registry import get_plugin_class
for ptype in provider_names:
pcls = get_plugin_class(ptype)
if pcls and hasattr(pcls, "config_schema") and callable(pcls.config_schema):
try:
if pcls.config_schema():
options.append(ptype)
except Exception:
logger.exception("Failed to inspect provider class config_schema for '%s'", ptype)
options = get_configurable_provider_types()
self.app.push_screen(SelectionModal("Select Plugin Type", options), callback=self.on_provider_type_selected)
elif bid == "add-tool-btn":
# Discover tool modules that advertise a config_schema()
options = []
try:
import pkgutil
import importlib
import tool as _tool_pkg
for _mod in pkgutil.iter_modules(_tool_pkg.__path__):
try:
mod = importlib.import_module(f"tool.{_mod.name}")
if hasattr(mod, "config_schema") and callable(mod.config_schema):
options.append(_mod.name)
except Exception:
continue
except Exception:
# Fallback to known entry
options = ["ytdlp"]
options = get_configurable_tool_types() or ["ytdlp"]
if options:
options.sort()
self.app.push_screen(SelectionModal("Select Tool", options), callback=self.on_tool_type_selected)
@@ -1033,26 +949,7 @@ class ConfigModal(ModalScreen):
self.config_data["store"][stype] = {}
# Default config for the new store
new_config = {"NAME": new_name}
classes = _discover_store_classes()
if stype in classes:
cls = classes[stype]
# Use schema for defaults if present
if hasattr(cls, "config_schema") and callable(cls.config_schema):
for field_def in cls.config_schema():
key = field_def.get("key")
if key:
val = field_def.get("default", "")
# Don't override NAME if we already set it to new_stype
if key.upper() == "NAME":
continue
new_config[key] = val
else:
# Fallback to required keys list
required = _required_keys_for(cls)
for rk in required:
if rk.upper() != "NAME":
new_config[rk] = ""
new_config = build_default_store_config(stype, new_name)
self.config_data["store"][stype][new_name] = new_config
self.editing_item_type = f"store-{stype}"
@@ -1067,25 +964,7 @@ class ConfigModal(ModalScreen):
# For providers, they are usually top-level entries in 'provider' dict
if ptype not in self.config_data["provider"]:
from ProviderCore.registry import get_plugin_class
try:
pcls = get_plugin_class(ptype)
new_config = {}
if pcls:
# Use schema for defaults
if hasattr(pcls, "config_schema") and callable(pcls.config_schema):
for field_def in pcls.config_schema():
key = field_def.get("key")
if key:
new_config[key] = field_def.get("default", "")
else:
# Fallback to legacy required keys
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.config_data["provider"][ptype] = build_default_provider_config(ptype)
self.editing_item_type = "provider"
self.editing_item_name = ptype
@@ -1099,18 +978,7 @@ class ConfigModal(ModalScreen):
self.config_data["tool"] = {}
if tname not in self.config_data["tool"]:
new_config = {}
try:
import importlib
mod = importlib.import_module(f"tool.{tname}")
if hasattr(mod, "config_schema") and callable(mod.config_schema):
for field_def in mod.config_schema():
key = field_def.get("key")
if key:
new_config[key] = field_def.get("default", "")
except Exception:
logger.exception("Failed to load config_schema for tool '%s'", tname)
self.config_data["tool"][tname] = new_config
self.config_data["tool"][tname] = build_default_tool_config(tname)
self.editing_item_type = "tool"
self.editing_item_name = tname
@@ -1182,7 +1050,7 @@ class ConfigModal(ModalScreen):
def _synchronize_inputs_to_config(self) -> None:
"""Capture current input/select values before saving."""
widgets = list(self.query(Input)) + list(self.query(Select))
widgets = list(self.query(Input)) + list(self.query(Select)) + list(self.query(TextArea))
for widget in widgets:
widget_id = widget.id
if not widget_id or widget_id not in self._input_id_map:
@@ -1192,6 +1060,8 @@ class ConfigModal(ModalScreen):
if widget.value == Select.BLANK:
continue
value = widget.value
elif isinstance(widget, TextArea):
value = widget.text
else:
value = widget.value
@@ -1696,6 +1566,11 @@ class ConfigModal(ModalScreen):
if event.input.id:
self._update_config_value(event.input.id, event.value)
@on(TextArea.Changed)
def on_text_area_changed(self, event: TextArea.Changed) -> None:
if event.text_area.id:
self._update_config_value(event.text_area.id, event.text_area.text)
@on(Select.Changed)
def on_select_changed(self, event: Select.Changed) -> None:
if event.select.id:
@@ -1860,52 +1735,18 @@ class ConfigModal(ModalScreen):
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 = list(_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_plugin_class
try:
pcls = get_plugin_class(item_name)
if pcls:
# Collect required keys from schema
if hasattr(pcls, "config_schema") and callable(pcls.config_schema):
for field_def in pcls.config_schema():
if field_def.get("required"):
k = field_def.get("key")
if k and k not in required_keys:
required_keys.append(k)
# Merge with legacy required keys
for rk in pcls.required_config_keys():
if rk not in required_keys:
required_keys.append(rk)
except Exception:
logger.exception("Failed to inspect provider class '%s' for required keys", item_name)
section = self.config_data.get("provider", {}).get(item_name, {})
elif item_type == "tool":
try:
import importlib
mod = importlib.import_module(f"tool.{item_name}")
if hasattr(mod, "config_schema") and callable(mod.config_schema):
for field_def in mod.config_schema():
if field_def.get("required"):
k = field_def.get("key")
if k and k not in required_keys:
required_keys.append(k)
except Exception:
logger.exception("Failed to inspect tool module 'tool.%s' for required keys", item_name)
section = self.config_data.get("tool", {}).get(item_name, {})
# Check required keys
for rk in required_keys:
for rk in get_required_config_keys(item_type, item_name):
# Case-insensitive lookup for the required key in the current section
val = None
rk_upper = rk.upper()