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
|
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(
|
||||||
|
|||||||
+65
-14
@@ -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
|
||||||
@@ -68,6 +68,8 @@ def _pfid(plugin_name: str, key: str) -> str:
|
|||||||
# ── Tab widgets ───────────────────────────────────────────────────────────────
|
# ── Tab widgets ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class _GeneralTab(VerticalScroll):
|
class _GeneralTab(VerticalScroll):
|
||||||
|
BINDINGS = [Binding("ctrl+s", "save", "Save")]
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
for f in GENERAL_FIELDS:
|
for f in GENERAL_FIELDS:
|
||||||
@@ -81,9 +83,16 @@ class _GeneralTab(VerticalScroll):
|
|||||||
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)
|
||||||
@@ -94,11 +103,14 @@ class _GeneralTab(VerticalScroll):
|
|||||||
_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; }"
|
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()
|
||||||
@@ -115,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()
|
||||||
@@ -152,6 +173,8 @@ class _PluginsTab(Widget):
|
|||||||
|
|
||||||
|
|
||||||
class _PluginConfigTab(VerticalScroll):
|
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
|
||||||
@@ -173,9 +196,16 @@ class _PluginConfigTab(VerticalScroll):
|
|||||||
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(VerticalScroll):
|
|||||||
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 ───────────────────────────────────────────────────────────────────────
|
||||||
@@ -195,8 +224,10 @@ class _PluginConfigTab(VerticalScroll):
|
|||||||
class ConfigApp(App):
|
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; }
|
||||||
@@ -208,6 +239,7 @@ class ConfigApp(App):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
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:
|
||||||
|
|||||||
Reference in New Issue
Block a user