Compare commits

2 Commits

Author SHA1 Message Date
curo1305 1412ced7a8 feat(tui): full keyboard support for config TUI and chat slash completion
Add Header/Footer with visible key hints, ctrl+right/ctrl+left tab navigation,
ctrl+s save bindings for General and plugin config tabs, e/d bindings for
plugin enable/disable in the Plugins tab. Extract shared _do_save() and
_toggle_plugin() helpers so button and key paths share one code path.

Add WordCompleter to the chat REPL so Tab completes slash commands.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:11:39 +02:00
curo1305 54241a9e4e fix(config): fix empty General tab — height collapse and invalid CSS variable
_GeneralTab and _PluginConfigTab inherited from Widget (height: auto), causing
the inner VerticalScroll to get height: 1fr of an auto-height parent, which
collapsed to 0. Fix: inherit from VerticalScroll directly and remove the inner
wrapper. _PluginsTab gets DEFAULT_CSS to fill its TabPane.

Also replace $text-muted (invalid in Textual 8.x) with $foreground 50%.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:15:45 +02:00
2 changed files with 100 additions and 45 deletions
+8 -4
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import litellm import litellm
from prompt_toolkit import PromptSession from prompt_toolkit import PromptSession
from prompt_toolkit.completion import WordCompleter
from prompt_toolkit.history import FileHistory from prompt_toolkit.history import FileHistory
from pyra.chat.history import ConversationHistory from pyra.chat.history import ConversationHistory
@@ -159,12 +160,15 @@ def start_chat() -> None:
)) ))
history = ConversationHistory(cfg, registry) history = ConversationHistory(cfg, registry)
session: PromptSession = PromptSession(
history=FileHistory(str(_HISTORY_FILE)),
multiline=False,
)
plugin_slash = registry.get_slash_commands() plugin_slash = registry.get_slash_commands()
all_commands = list(_STATIC_COMMANDS) + list(plugin_slash)
session: PromptSession = PromptSession(
history=FileHistory(str(_HISTORY_FILE)),
completer=WordCompleter(all_commands, sentence=True),
complete_while_typing=False,
multiline=False,
)
provider = get_provider(cfg.ai.provider_id) provider = get_provider(cfg.ai.provider_id)
render_system( render_system(
+69 -18
View File
@@ -7,7 +7,7 @@ from textual.binding import Binding
from textual.containers import Horizontal, VerticalScroll from textual.containers import Horizontal, VerticalScroll
from textual.coordinate import Coordinate from textual.coordinate import Coordinate
from textual.widget import Widget from textual.widget import Widget
from textual.widgets import Button, DataTable, Input, Label, Switch, TabbedContent, TabPane from textual.widgets import Button, DataTable, Footer, Header, Input, Label, Switch, TabbedContent, TabPane
from pyra.config.manager import load_config, save_config from pyra.config.manager import load_config, save_config
from pyra.plugins.base import BasePlugin, ConfigField from pyra.plugins.base import BasePlugin, ConfigField
@@ -67,10 +67,11 @@ def _pfid(plugin_name: str, key: str) -> str:
# ── Tab widgets ─────────────────────────────────────────────────────────────── # ── Tab widgets ───────────────────────────────────────────────────────────────
class _GeneralTab(Widget): class _GeneralTab(VerticalScroll):
BINDINGS = [Binding("ctrl+s", "save", "Save")]
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
cfg = load_config() cfg = load_config()
with VerticalScroll():
for f in GENERAL_FIELDS: for f in GENERAL_FIELDS:
current = _get_nested(cfg, f.path) current = _get_nested(cfg, f.path)
with Horizontal(classes="row"): with Horizontal(classes="row"):
@@ -82,9 +83,16 @@ class _GeneralTab(Widget):
with Horizontal(classes="actions"): with Horizontal(classes="actions"):
yield Button("Save", id="save-general", variant="primary") yield Button("Save", id="save-general", variant="primary")
def action_save(self) -> None:
self._do_save()
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id != "save-general": if event.button.id != "save-general":
return return
self._do_save()
event.stop()
def _do_save(self) -> None:
cfg = load_config() cfg = load_config()
for f in GENERAL_FIELDS: for f in GENERAL_FIELDS:
wid = _fid(f.path) wid = _fid(f.path)
@@ -95,10 +103,15 @@ class _GeneralTab(Widget):
_set_nested(cfg, f.path, cfg_val) _set_nested(cfg, f.path, cfg_val)
save_config(cfg) save_config(cfg)
self.app.notify("General settings saved.") self.app.notify("General settings saved.")
event.stop()
class _PluginsTab(Widget): class _PluginsTab(Widget):
DEFAULT_CSS = "_PluginsTab { height: 1fr; width: 1fr; }"
BINDINGS = [
Binding("e", "enable_plugin", "Enable"),
Binding("d", "disable_plugin", "Disable"),
]
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
cfg = load_config() cfg = load_config()
enabled = set(cfg.plugins.enabled) enabled = set(cfg.plugins.enabled)
@@ -114,26 +127,35 @@ class _PluginsTab(Widget):
) )
yield table yield table
with Horizontal(classes="actions"): with Horizontal(classes="actions"):
yield Button("Enable", id="btn-enable", variant="success") yield Button("Enable [e]", id="btn-enable", variant="success")
yield Button("Disable", id="btn-disable") yield Button("Disable [d]", id="btn-disable")
def action_enable_plugin(self) -> None:
self._toggle_plugin("enable")
def action_disable_plugin(self) -> None:
self._toggle_plugin("disable")
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
btn_id = event.button.id if event.button.id == "btn-enable":
if btn_id not in ("btn-enable", "btn-disable"): self._toggle_plugin("enable")
return event.stop()
elif event.button.id == "btn-disable":
self._toggle_plugin("disable")
event.stop()
def _toggle_plugin(self, action: str) -> None:
table = self.query_one("#plugins-table", DataTable) table = self.query_one("#plugins-table", DataTable)
if table.row_count == 0: if table.row_count == 0:
event.stop()
return return
plugin_name = str(table.get_cell_at(Coordinate(table.cursor_coordinate.row, 0))) plugin_name = str(table.get_cell_at(Coordinate(table.cursor_coordinate.row, 0)))
cfg = load_config() cfg = load_config()
if btn_id == "btn-enable" and plugin_name not in cfg.plugins.enabled: if action == "enable" and plugin_name not in cfg.plugins.enabled:
cfg.plugins.enabled.append(plugin_name) cfg.plugins.enabled.append(plugin_name)
elif btn_id == "btn-disable" and plugin_name in cfg.plugins.enabled: elif action == "disable" and plugin_name in cfg.plugins.enabled:
cfg.plugins.enabled.remove(plugin_name) cfg.plugins.enabled.remove(plugin_name)
save_config(cfg) save_config(cfg)
self._refresh_table() self._refresh_table()
event.stop()
def _refresh_table(self) -> None: def _refresh_table(self) -> None:
cfg = load_config() cfg = load_config()
@@ -150,7 +172,9 @@ class _PluginsTab(Widget):
) )
class _PluginConfigTab(Widget): class _PluginConfigTab(VerticalScroll):
BINDINGS = [Binding("ctrl+s", "save", "Save")]
def __init__(self, name: str, plugin: Any) -> None: def __init__(self, name: str, plugin: Any) -> None:
super().__init__() super().__init__()
self._name = name self._name = name
@@ -159,7 +183,6 @@ class _PluginConfigTab(Widget):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
cfg = load_config() cfg = load_config()
settings = cfg.plugin_settings.get(self._name, {}) settings = cfg.plugin_settings.get(self._name, {})
with VerticalScroll():
for f in self._plugin.config_fields(): for f in self._plugin.config_fields():
current = settings.get(f.key, f.default) current = settings.get(f.key, f.default)
with Horizontal(classes="row"): with Horizontal(classes="row"):
@@ -173,9 +196,16 @@ class _PluginConfigTab(Widget):
with Horizontal(classes="actions"): with Horizontal(classes="actions"):
yield Button("Save", id=f"save-{self._name}", variant="primary") yield Button("Save", id=f"save-{self._name}", variant="primary")
def action_save(self) -> None:
self._do_save()
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id != f"save-{self._name}": if event.button.id != f"save-{self._name}":
return return
self._do_save()
event.stop()
def _do_save(self) -> None:
cfg = load_config() cfg = load_config()
settings: dict[str, Any] = dict(cfg.plugin_settings.get(self._name, {})) settings: dict[str, Any] = dict(cfg.plugin_settings.get(self._name, {}))
for f in self._plugin.config_fields(): for f in self._plugin.config_fields():
@@ -187,7 +217,6 @@ class _PluginConfigTab(Widget):
cfg.plugin_settings[self._name] = settings cfg.plugin_settings[self._name] = settings
save_config(cfg) save_config(cfg)
self.app.notify(f"{self._name} settings saved.") self.app.notify(f"{self._name} settings saved.")
event.stop()
# ── App ─────────────────────────────────────────────────────────────────────── # ── App ───────────────────────────────────────────────────────────────────────
@@ -196,18 +225,21 @@ class ConfigApp(App):
TITLE = "Pyra Configuration" TITLE = "Pyra Configuration"
BINDINGS = [ BINDINGS = [
Binding("q", "quit", "Quit"), Binding("q", "quit", "Quit"),
Binding("escape", "quit", "Quit"), Binding("escape", "quit", "Quit", show=False),
Binding("ctrl+right", "next_tab", "Next tab"),
Binding("ctrl+left", "prev_tab", "Prev tab"),
] ]
CSS = """ CSS = """
Screen { background: $surface; } Screen { background: $surface; }
.row { height: 3; margin: 0 2; } .row { height: 3; margin: 0 2; }
.row Label { width: 26; content-align: left middle; } .row Label { width: 26; content-align: left middle; }
.hint { color: $text-muted; margin: 0 2 1 28; } .hint { color: $foreground 50%; margin: 0 2 1 28; }
.actions { height: 3; align: right middle; margin: 1 2; } .actions { height: 3; align: right middle; margin: 1 2; }
DataTable { height: 1fr; } DataTable { height: 1fr; }
""" """
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header()
plugins = _installed_plugins() plugins = _installed_plugins()
with TabbedContent(): with TabbedContent():
with TabPane("General"): with TabPane("General"):
@@ -218,6 +250,25 @@ class ConfigApp(App):
if plugin is not None and plugin.config_fields(): if plugin is not None and plugin.config_fields():
with TabPane(name): with TabPane(name):
yield _PluginConfigTab(name, plugin) yield _PluginConfigTab(name, plugin)
yield Footer()
def action_next_tab(self) -> None:
tc = self.query_one(TabbedContent)
panes = list(tc.query("TabPane"))
ids = [p.id for p in panes]
try:
tc.active = ids[(ids.index(tc.active) + 1) % len(ids)]
except (ValueError, IndexError):
pass
def action_prev_tab(self) -> None:
tc = self.query_one(TabbedContent)
panes = list(tc.query("TabPane"))
ids = [p.id for p in panes]
try:
tc.active = ids[(ids.index(tc.active) - 1) % len(ids)]
except (ValueError, IndexError):
pass
def launch_config_tui() -> None: def launch_config_tui() -> None: