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>
This commit is contained in:
curo1305
2026-05-18 23:11:39 +02:00
parent 54241a9e4e
commit 1412ced7a8
2 changed files with 73 additions and 18 deletions
+8 -4
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import litellm
from prompt_toolkit import PromptSession
from prompt_toolkit.completion import WordCompleter
from prompt_toolkit.history import FileHistory
from pyra.chat.history import ConversationHistory
@@ -159,12 +160,15 @@ def start_chat() -> None:
))
history = ConversationHistory(cfg, registry)
session: PromptSession = PromptSession(
history=FileHistory(str(_HISTORY_FILE)),
multiline=False,
)
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)
render_system(
+65 -14
View File
@@ -7,7 +7,7 @@ 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 textual.widgets import Button, DataTable, Footer, Header, Input, Label, Switch, TabbedContent, TabPane
from pyra.config.manager import load_config, save_config
from pyra.plugins.base import BasePlugin, ConfigField
@@ -68,6 +68,8 @@ def _pfid(plugin_name: str, key: str) -> str:
# ── Tab widgets ───────────────────────────────────────────────────────────────
class _GeneralTab(VerticalScroll):
BINDINGS = [Binding("ctrl+s", "save", "Save")]
def compose(self) -> ComposeResult:
cfg = load_config()
for f in GENERAL_FIELDS:
@@ -81,9 +83,16 @@ class _GeneralTab(VerticalScroll):
with Horizontal(classes="actions"):
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:
if event.button.id != "save-general":
return
self._do_save()
event.stop()
def _do_save(self) -> None:
cfg = load_config()
for f in GENERAL_FIELDS:
wid = _fid(f.path)
@@ -94,11 +103,14 @@ class _GeneralTab(VerticalScroll):
_set_nested(cfg, f.path, cfg_val)
save_config(cfg)
self.app.notify("General settings saved.")
event.stop()
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:
cfg = load_config()
@@ -115,26 +127,35 @@ class _PluginsTab(Widget):
)
yield table
with Horizontal(classes="actions"):
yield Button("Enable", id="btn-enable", variant="success")
yield Button("Disable", id="btn-disable")
yield Button("Enable [e]", id="btn-enable", variant="success")
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:
btn_id = event.button.id
if btn_id not in ("btn-enable", "btn-disable"):
return
if event.button.id == "btn-enable":
self._toggle_plugin("enable")
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)
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:
if action == "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:
elif action == "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()
@@ -152,6 +173,8 @@ class _PluginsTab(Widget):
class _PluginConfigTab(VerticalScroll):
BINDINGS = [Binding("ctrl+s", "save", "Save")]
def __init__(self, name: str, plugin: Any) -> None:
super().__init__()
self._name = name
@@ -173,9 +196,16 @@ class _PluginConfigTab(VerticalScroll):
with Horizontal(classes="actions"):
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:
if event.button.id != f"save-{self._name}":
return
self._do_save()
event.stop()
def _do_save(self) -> None:
cfg = load_config()
settings: dict[str, Any] = dict(cfg.plugin_settings.get(self._name, {}))
for f in self._plugin.config_fields():
@@ -187,7 +217,6 @@ class _PluginConfigTab(VerticalScroll):
cfg.plugin_settings[self._name] = settings
save_config(cfg)
self.app.notify(f"{self._name} settings saved.")
event.stop()
# ── App ───────────────────────────────────────────────────────────────────────
@@ -195,8 +224,10 @@ class _PluginConfigTab(VerticalScroll):
class ConfigApp(App):
TITLE = "Pyra Configuration"
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("escape", "quit", "Quit"),
Binding("q", "quit", "Quit"),
Binding("escape", "quit", "Quit", show=False),
Binding("ctrl+right", "next_tab", "Next tab"),
Binding("ctrl+left", "prev_tab", "Prev tab"),
]
CSS = """
Screen { background: $surface; }
@@ -208,6 +239,7 @@ class ConfigApp(App):
"""
def compose(self) -> ComposeResult:
yield Header()
plugins = _installed_plugins()
with TabbedContent():
with TabPane("General"):
@@ -218,6 +250,25 @@ class ConfigApp(App):
if plugin is not None and plugin.config_fields():
with TabPane(name):
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: