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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user