feat(config): add /config TUI with tab-based settings and plugin config framework

- textual-based ConfigApp with General, Plugins, and per-plugin tabs
- GeneralConfig (user_name, assistant_name) + plugin_settings dict added to PyraConfig
- ConfigField dataclass and config_fields() method added to plugin protocol
- /config slash command in chat REPL launches the TUI
- pyra auto-runs setup wizard on first invocation when no config.yaml exists
- CLAUDE.md updated with config_fields() plugin guide and Code Inventory entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-05-18 21:28:19 +02:00
parent 6bb7c77692
commit 1201606187
7 changed files with 291 additions and 4 deletions
+9
View File
@@ -1,3 +1,5 @@
from typing import Any
from pydantic import BaseModel, Field
@@ -7,6 +9,11 @@ class ProviderConfig(BaseModel):
base_url: str | None = None
class GeneralConfig(BaseModel):
user_name: str = "User"
assistant_name: str = "Pyra"
class MemoryConfig(BaseModel):
max_tokens_in_context: int = 4000
auto_load: bool = True
@@ -33,7 +40,9 @@ class DaemonConfig(BaseModel):
class PyraConfig(BaseModel):
version: int = 1
ai: ProviderConfig
general: GeneralConfig = Field(default_factory=GeneralConfig)
memory: MemoryConfig = Field(default_factory=MemoryConfig)
security: SecurityConfig = Field(default_factory=SecurityConfig)
plugins: PluginConfig = Field(default_factory=PluginConfig)
daemon: DaemonConfig = Field(default_factory=DaemonConfig)
plugin_settings: dict[str, Any] = Field(default_factory=dict)
+225
View File
@@ -0,0 +1,225 @@
from __future__ import annotations
from typing import Any, NamedTuple
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, VerticalScroll
from textual.coordinate import Coordinate
from textual.widget import Widget
from textual.widgets import Button, DataTable, Input, Label, Switch, TabbedContent, TabPane
from pyra.config.manager import load_config, save_config
from pyra.plugins.base import BasePlugin, ConfigField
from pyra.plugins.install import read_manifest
from pyra.plugins.loader import load_plugin_by_name
from pyra.utils.paths import pyra_home
class _CoreField(NamedTuple):
path: str # dotted path in PyraConfig, e.g. "general.user_name"
label: str
type: str # "text" | "bool"
default: Any
# ── Add new core settings here — one line each ────────────────────────────────
GENERAL_FIELDS: list[_CoreField] = [
_CoreField("general.user_name", "Your name", "text", "User"),
_CoreField("general.assistant_name", "Assistant name", "text", "Pyra"),
_CoreField("daemon.enabled", "Enable daemon", "bool", False),
]
# ─────────────────────────────────────────────────────────────────────────────
def _get_nested(obj: Any, path: str) -> Any:
for part in path.split("."):
obj = getattr(obj, part)
return obj
def _set_nested(obj: Any, path: str, value: Any) -> None:
parts = path.split(".")
for part in parts[:-1]:
obj = getattr(obj, part)
setattr(obj, parts[-1], value)
def _installed_plugins() -> list[tuple[str, dict, Any]]:
plugins_dir = pyra_home() / "plugins"
result = []
if plugins_dir.is_dir():
for entry in sorted(plugins_dir.iterdir()):
if entry.is_dir():
manifest = read_manifest(entry)
plugin = load_plugin_by_name(entry.name, plugins_dir)
result.append((entry.name, manifest, plugin))
return result
def _fid(path: str) -> str:
return "f-" + path.replace(".", "-")
def _pfid(plugin_name: str, key: str) -> str:
return f"pf-{plugin_name}-{key}"
# ── Tab widgets ───────────────────────────────────────────────────────────────
class _GeneralTab(Widget):
def compose(self) -> ComposeResult:
cfg = load_config()
with VerticalScroll():
for f in GENERAL_FIELDS:
current = _get_nested(cfg, f.path)
with Horizontal(classes="row"):
yield Label(f.label)
if f.type == "bool":
yield Switch(value=bool(current), id=_fid(f.path))
else:
yield Input(value=str(current), id=_fid(f.path))
with Horizontal(classes="actions"):
yield Button("Save", id="save-general", variant="primary")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id != "save-general":
return
cfg = load_config()
for f in GENERAL_FIELDS:
wid = _fid(f.path)
if f.type == "bool":
cfg_val: Any = self.query_one(f"#{wid}", Switch).value
else:
cfg_val = self.query_one(f"#{wid}", Input).value
_set_nested(cfg, f.path, cfg_val)
save_config(cfg)
self.app.notify("General settings saved.")
event.stop()
class _PluginsTab(Widget):
def compose(self) -> ComposeResult:
cfg = load_config()
enabled = set(cfg.plugins.enabled)
table = DataTable(id="plugins-table")
table.add_columns("Name", "Version", "Status", "Description")
for name, manifest, _ in _installed_plugins():
status = "enabled" if name in enabled else "disabled"
table.add_row(
name,
manifest.get("version", "?"),
status,
manifest.get("description", ""),
)
yield table
with Horizontal(classes="actions"):
yield Button("Enable", id="btn-enable", variant="success")
yield Button("Disable", id="btn-disable")
def on_button_pressed(self, event: Button.Pressed) -> None:
btn_id = event.button.id
if btn_id not in ("btn-enable", "btn-disable"):
return
table = self.query_one("#plugins-table", DataTable)
if table.row_count == 0:
event.stop()
return
plugin_name = str(table.get_cell_at(Coordinate(table.cursor_coordinate.row, 0)))
cfg = load_config()
if btn_id == "btn-enable" and plugin_name not in cfg.plugins.enabled:
cfg.plugins.enabled.append(plugin_name)
elif btn_id == "btn-disable" and plugin_name in cfg.plugins.enabled:
cfg.plugins.enabled.remove(plugin_name)
save_config(cfg)
self._refresh_table()
event.stop()
def _refresh_table(self) -> None:
cfg = load_config()
enabled = set(cfg.plugins.enabled)
table = self.query_one("#plugins-table", DataTable)
table.clear()
for name, manifest, _ in _installed_plugins():
status = "enabled" if name in enabled else "disabled"
table.add_row(
name,
manifest.get("version", "?"),
status,
manifest.get("description", ""),
)
class _PluginConfigTab(Widget):
def __init__(self, name: str, plugin: Any) -> None:
super().__init__()
self._name = name
self._plugin = plugin
def compose(self) -> ComposeResult:
cfg = load_config()
settings = cfg.plugin_settings.get(self._name, {})
with VerticalScroll():
for f in self._plugin.config_fields():
current = settings.get(f.key, f.default)
with Horizontal(classes="row"):
yield Label(f.label)
if f.type == "bool":
yield Switch(value=bool(current), id=_pfid(self._name, f.key))
else:
yield Input(value=str(current), id=_pfid(self._name, f.key))
if f.description:
yield Label(f.description, classes="hint")
with Horizontal(classes="actions"):
yield Button("Save", id=f"save-{self._name}", variant="primary")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id != f"save-{self._name}":
return
cfg = load_config()
settings: dict[str, Any] = dict(cfg.plugin_settings.get(self._name, {}))
for f in self._plugin.config_fields():
wid = _pfid(self._name, f.key)
if f.type == "bool":
settings[f.key] = self.query_one(f"#{wid}", Switch).value
else:
settings[f.key] = self.query_one(f"#{wid}", Input).value
cfg.plugin_settings[self._name] = settings
save_config(cfg)
self.app.notify(f"{self._name} settings saved.")
event.stop()
# ── App ───────────────────────────────────────────────────────────────────────
class ConfigApp(App):
TITLE = "Pyra Configuration"
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("escape", "quit", "Quit"),
]
CSS = """
Screen { background: $surface; }
.row { height: 3; margin: 0 2; }
.row Label { width: 26; content-align: left middle; }
.hint { color: $text-muted; margin: 0 2 1 28; }
.actions { height: 3; align: right middle; margin: 1 2; }
DataTable { height: 1fr; }
"""
def compose(self) -> ComposeResult:
plugins = _installed_plugins()
with TabbedContent():
with TabPane("General"):
yield _GeneralTab()
with TabPane("Plugins"):
yield _PluginsTab()
for name, _, plugin in plugins:
if plugin is not None and plugin.config_fields():
with TabPane(name):
yield _PluginConfigTab(name, plugin)
def launch_config_tui() -> None:
"""Open the configuration TUI. Blocks until the user quits (q / Escape)."""
ConfigApp().run()