feat(tui): AI provider tab + expanded General settings

Add a dedicated AI tab with provider Select, model Input, base URL Input,
and masked API key Input (write-only, stored in vault). Switching providers
reactively updates the model placeholder, base URL default, and shows/hides
the API key row for cloud vs. local providers. ctrl+s saves config and vault.

Extend GENERAL_FIELDS with Memory, Security, Plugin, and Daemon sections
using a new "section" header type and optional int cast for numeric fields.
_CoreField gains cast: type | None for automatic value coercion on save.

Add 5 new tests covering AI tab rendering, config save, vault key write,
vault key skip-on-empty, and section header rendering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-05-18 23:43:36 +02:00
parent 3b89d940de
commit f1213e28c8
2 changed files with 224 additions and 11 deletions
+104 -4
View File
@@ -1,7 +1,7 @@
import json
from unittest.mock import patch
from textual.widgets import DataTable, Input, Switch, TabbedContent
from textual.widgets import DataTable, Input, Label, Select, Switch, TabbedContent
from pyra.config.schema import ProviderConfig, PyraConfig
@@ -68,7 +68,7 @@ def test_general_fields_non_empty():
def test_general_fields_all_valid_types():
from pyra.config.tui import GENERAL_FIELDS
valid_types = {"text", "bool", "select"}
valid_types = {"text", "bool", "select", "section"}
for f in GENERAL_FIELDS:
assert f.type in valid_types, f"Field '{f.path}' has unexpected type '{f.type}'"
@@ -82,6 +82,8 @@ async def test_config_app_renders_all_general_fields(tmp_pyra_home):
save_config(_make_cfg())
async with ConfigApp().run_test() as pilot:
for f in GENERAL_FIELDS:
if f.type == "section":
continue
wid = _fid(f.path)
if f.type == "bool":
assert pilot.app.query_one(f"#{wid}", Switch)
@@ -150,8 +152,8 @@ async def test_plugin_config_tab_appears_for_plugin_with_config_fields(tmp_pyra_
async with ConfigApp().run_test() as pilot:
content = pilot.app.query_one(TabbedContent)
tab_count = len(list(content.query("TabPane")))
# General + Plugins + fake plugin tab
assert tab_count == 3
# AI + General + Plugins + fake plugin tab
assert tab_count == 4
async def test_q_key_exits_app(tmp_pyra_home):
@@ -162,3 +164,101 @@ async def test_q_key_exits_app(tmp_pyra_home):
async with ConfigApp().run_test() as pilot:
await pilot.press("q")
# Reaching here means the app exited cleanly
async def test_ai_tab_renders_provider_fields(tmp_pyra_home):
from pyra.config.manager import save_config
from pyra.config.tui import ConfigApp
save_config(_make_cfg())
async with ConfigApp().run_test() as pilot:
assert pilot.app.query_one("#ai-provider", Select)
assert pilot.app.query_one("#ai-model", Input)
assert pilot.app.query_one("#ai-base-url", Input)
async def test_ai_tab_save_updates_config(tmp_pyra_home):
from textual.app import App as TextualApp, ComposeResult as CR
from pyra.config.manager import load_config, save_config as initial_save
from pyra.config.tui import _AITab
initial_save(_make_cfg())
class _TestApp(TextualApp):
def compose(self) -> CR:
yield _AITab()
saved = []
with patch("pyra.config.tui.save_config", side_effect=lambda c: saved.append(c) or None):
async with _TestApp().run_test() as pilot:
pilot.app.query_one("#ai-model", Input).value = "llama3:70b"
await pilot.pause()
await pilot.press("ctrl+s")
assert saved, "save_config was not called"
assert saved[-1].ai.model == "llama3:70b"
async def test_ai_tab_save_calls_set_key_when_provided(tmp_pyra_home):
from textual.app import App as TextualApp, ComposeResult as CR
from pyra.config.manager import save_config as initial_save
from pyra.config.tui import _AITab
initial_save(_make_cfg())
class _TestApp(TextualApp):
def compose(self) -> CR:
yield _AITab()
calls = []
with patch("pyra.config.tui.save_config"):
with patch("pyra.config.tui.set_key", side_effect=lambda p, k: calls.append((p, k))):
async with _TestApp().run_test() as pilot:
pilot.app.query_one("#ai-key", Input).value = "sk-test"
await pilot.pause()
await pilot.press("ctrl+s")
assert calls, "set_key was not called"
assert calls[-1][1] == "sk-test"
async def test_ai_tab_save_skips_set_key_when_empty(tmp_pyra_home):
from textual.app import App as TextualApp, ComposeResult as CR
from pyra.config.manager import save_config as initial_save
from pyra.config.tui import _AITab
initial_save(_make_cfg())
class _TestApp(TextualApp):
def compose(self) -> CR:
yield _AITab()
calls = []
with patch("pyra.config.tui.save_config"):
with patch("pyra.config.tui.set_key", side_effect=lambda p, k: calls.append((p, k))):
async with _TestApp().run_test() as pilot:
# Leave api-key empty (default)
await pilot.pause()
await pilot.press("ctrl+s")
assert not calls, "set_key should not be called when key input is empty"
async def test_general_tab_renders_section_headers(tmp_pyra_home):
from textual.app import App as TextualApp, ComposeResult as CR
from pyra.config.manager import save_config as initial_save
from pyra.config.tui import _GeneralTab
initial_save(_make_cfg())
class _TestApp(TextualApp):
def compose(self) -> CR:
yield _GeneralTab()
async with _TestApp().run_test() as pilot:
headers = list(pilot.app.query(".section-header"))
assert len(headers) >= 5, "Expected at least 5 section headers"