From 1201606187b6771c6b03357e55d12f843a13f235 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Mon, 18 May 2026 21:28:19 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 30 ++++- pyproject.toml | 1 + src/pyra/chat/session.py | 10 ++ src/pyra/cli.py | 4 + src/pyra/config/schema.py | 9 ++ src/pyra/config/tui.py | 225 ++++++++++++++++++++++++++++++++++++++ src/pyra/plugins/base.py | 16 ++- 7 files changed, 291 insertions(+), 4 deletions(-) create mode 100644 src/pyra/config/tui.py diff --git a/CLAUDE.md b/CLAUDE.md index db089a7..e027e4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,7 +94,8 @@ the vault under namespaced keys (`plugin:{name}:{key}`). | `cli.py` | Click entrypoint. Subcommands: `setup`, `chat`, `memory`, `plugin`, `daemon` | | `setup/providers.py` | Provider registry — pure data, no I/O | | `setup/wizard.py` | questionary-based interactive setup wizard | -| `config/schema.py` | Pydantic v2 models — `PyraConfig`, `PluginConfig`, `DaemonConfig` | +| `config/schema.py` | Pydantic v2 models — `PyraConfig`, `GeneralConfig`, `PluginConfig`, `DaemonConfig`; `plugin_settings` dict | +| `config/tui.py` | `textual`-based `/config` TUI — `ConfigApp`, `GENERAL_FIELDS`, `launch_config_tui()` | | `config/manager.py` | ruamel.yaml round-trip config read/write, chmod 600 enforced | | `config/dirs.py` | `bootstrap()` — creates `~/.pyra/` tree, checks vault sentinel every startup | | `chat/session.py` | prompt_toolkit REPL loop, AI tool-use loop, plugin slash commands | @@ -160,7 +161,7 @@ by convention in each plugin's `setup()` method. ``` 2. Create `~/.pyra/plugins//plugin.py` exporting `get_plugin() -> BasePlugin`: ```python - from pyra.plugins.base import BasePlugin, Tool + from pyra.plugins.base import BasePlugin, ConfigField, Tool class MyPlugin(BasePlugin): name = "" @@ -183,6 +184,15 @@ by convention in each plugin's `setup()` method. secret = console.input("Enter secret: ") vault_writer("plugin::secret", secret) + def config_fields(self): + # Declare user-adjustable settings. Values are saved to config.yaml + # under plugin_settings[""] and rendered in /config → plugin tab. + return [ + ConfigField("api_url", "API URL", "text", "https://example.com", + description="Base URL for the service"), + ConfigField("verify_ssl", "Verify SSL", "bool", True), + ] + def get_plugin(): return MyPlugin() ``` @@ -192,6 +202,10 @@ by convention in each plugin's `setup()` method. - Never import from `pyra.vault` directly — use the `vault_reader`/`vault_writer` callables - All write/destructive tools must set `requires_approval=True` - Return strings from tool handlers (truncated to 4000 chars by executor) +- Implement `config_fields()` for any user-adjustable settings beyond credentials. + Return a list of `ConfigField` objects — the `/config` TUI renders them automatically + and saves values to `config.yaml` under `plugin_settings[""]`. + Plugins that need no configuration can omit this method (base no-op is used). --- @@ -322,6 +336,7 @@ Before writing any new utility function, class, or import block, check the **Cod | `ruamel.yaml` | 0.18.0 | `config/manager.py` | Round-trip YAML read/write (preserves comments and formatting) | | `pydantic` | 2.0.0 | `config/schema.py` | Config validation via `BaseModel` | | `httpx` | 0.27.0 | `setup/wizard.py` | HTTP GET for local-server connectivity checks | +| `textual` | 1.0.0 | `config/tui.py` | Full-screen TUI framework — tabs, inputs, switches, data tables for `/config` | Optional plugin extras (declared in `pyproject.toml [project.optional-dependencies]`): @@ -388,6 +403,13 @@ Dataclass: `InjectionWarning(pattern_label: str, matched_text: str)` | `config_exists` | `() -> bool` | True if `config.yaml` exists | | `config_path` | `() -> Path` | Absolute path to `config.yaml` | +#### `config.tui` + +| Symbol | Purpose | +|--------|---------| +| `launch_config_tui` | `() -> None` — opens the full-screen configuration TUI; blocks until user presses `q`/Escape | +| `GENERAL_FIELDS` | List of `_CoreField` entries — the single place to add new core settings to the General tab | + #### `config.dirs` | Function | Signature | Purpose | @@ -480,7 +502,8 @@ Import `console` from here; do not create a second `rich.Console()` in new code. | Class | Module | Notes | |-------|--------|-------| -| `PyraConfig` | `config.schema` | Top-level config; fields: `ai`, `memory`, `security`, `plugins`, `daemon` | +| `PyraConfig` | `config.schema` | Top-level config; fields: `ai`, `general`, `memory`, `security`, `plugins`, `daemon`, `plugin_settings` | +| `GeneralConfig` | `config.schema` | `general:` block — `user_name`, `assistant_name` | | `ProviderConfig` | `config.schema` | `ai:` block — `provider_id`, `model`, `base_url` | | `PluginConfig` | `config.schema` | `plugins:` block — `enabled`, `require_approval`, `log_executions` | | `DaemonConfig` | `config.schema` | `daemon:` block | @@ -489,6 +512,7 @@ Import `console` from here; do not create a second `rich.Console()` in new code. | `ConversationHistory` | `chat.history` | Holds message list; builds API payload via `build_for_api()`; trims to token budget | | `PluginRegistry` | `plugins.registry` | Singleton (`instance()` / `reset()`); aggregates tools, slash commands, system prompt additions | | `ToolExecutor` | `plugins.executor` | Approval gate + injection scan + logging; call via `execute()` or `execute_tool_call_batch()` | +| `ConfigField` | `plugins.base` | Dataclass — declares one plugin config option (`key`, `label`, `type`, `default`, `options`, `description`); returned by `config_fields()` | | `Tool` | `plugins.base` | Dataclass — `name`, `description`, `parameters` (JSON Schema), `handler`, `requires_approval` | | `PyraPlugin` | `plugins.base` | `@runtime_checkable` Protocol — the plugin interface | | `BasePlugin` | `plugins.base` | Concrete base with no-op defaults; plugins should inherit this | diff --git a/pyproject.toml b/pyproject.toml index c2ae08d..955211f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "pydantic>=2.0.0", "httpx>=0.27.0", "python-dotenv>=1.0.0", + "textual>=1.0.0", ] [project.optional-dependencies] diff --git a/src/pyra/chat/session.py b/src/pyra/chat/session.py index c03e93b..a25c88c 100644 --- a/src/pyra/chat/session.py +++ b/src/pyra/chat/session.py @@ -33,6 +33,7 @@ _STATIC_COMMANDS = { "/exit": "Exit Pyra", "/clear": "Clear conversation history", "/memory list": "List memory files", + "/config": "Open configuration TUI", "/help": "Show available slash commands", } @@ -198,6 +199,15 @@ def start_chat() -> None: _show_memory_list() continue + if user_input == "/config": + from pyra.config.tui import launch_config_tui + launch_config_tui() + try: + cfg = load_config() + except FileNotFoundError: + pass + continue + if user_input in plugin_slash: try: plugin_slash[user_input]() diff --git a/src/pyra/cli.py b/src/pyra/cli.py index 87a989b..255f031 100644 --- a/src/pyra/cli.py +++ b/src/pyra/cli.py @@ -23,6 +23,10 @@ def main(ctx: click.Context) -> None: """Pyra — personal AI assistant.""" _bootstrap_or_exit() if ctx.invoked_subcommand is None: + from pyra.config.manager import config_exists + if not config_exists(): + from pyra.setup.wizard import run_setup + run_setup() from pyra.chat.session import start_chat start_chat() diff --git a/src/pyra/config/schema.py b/src/pyra/config/schema.py index d719a5b..216968e 100644 --- a/src/pyra/config/schema.py +++ b/src/pyra/config/schema.py @@ -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) diff --git a/src/pyra/config/tui.py b/src/pyra/config/tui.py new file mode 100644 index 0000000..20781f6 --- /dev/null +++ b/src/pyra/config/tui.py @@ -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() diff --git a/src/pyra/plugins/base.py b/src/pyra/plugins/base.py index a4fa3b6..c4ed93e 100644 --- a/src/pyra/plugins/base.py +++ b/src/pyra/plugins/base.py @@ -1,12 +1,22 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Callable, Coroutine, Protocol, runtime_checkable if TYPE_CHECKING: from rich.console import Console +@dataclass +class ConfigField: + key: str # key in plugin_settings[name] dict + label: str # display label in the config TUI + type: str # "text" | "bool" | "select" + default: Any = "" + options: list[str] = field(default_factory=list) # for "select" type + description: str = "" # optional hint shown below the field + + @dataclass class Tool: name: str @@ -35,6 +45,7 @@ class PyraPlugin(Protocol): def agent_spec(self) -> AgentSpec | None: ... def setup(self, console: Console, vault_writer: Callable[[str, str], None]) -> None: ... def daemon_tasks(self) -> list[Coroutine]: ... # type: ignore[type-arg] + def config_fields(self) -> list[ConfigField]: ... class BasePlugin: @@ -64,3 +75,6 @@ class BasePlugin: def daemon_tasks(self) -> list[Coroutine]: # type: ignore[type-arg] return [] + + def config_fields(self) -> list[ConfigField]: + return []