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