From 1412ced7a80024a8f188b8692a71c5bb0b9a4ba1 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Mon, 18 May 2026 23:11:39 +0200 Subject: [PATCH] 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 --- src/pyra/chat/session.py | 12 ++++-- src/pyra/config/tui.py | 79 +++++++++++++++++++++++++++++++++------- 2 files changed, 73 insertions(+), 18 deletions(-) diff --git a/src/pyra/chat/session.py b/src/pyra/chat/session.py index a25c88c..aa24776 100644 --- a/src/pyra/chat/session.py +++ b/src/pyra/chat/session.py @@ -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( diff --git a/src/pyra/config/tui.py b/src/pyra/config/tui.py index e0bc411..94113f9 100644 --- a/src/pyra/config/tui.py +++ b/src/pyra/config/tui.py @@ -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: