diff --git a/cli.py b/cli.py index 50634f34..98a18489 100755 --- a/cli.py +++ b/cli.py @@ -5779,6 +5779,73 @@ class HermesCLI: self._invalidate(min_interval=0.0) return True + # --- Protected TUI extension hooks for wrapper CLIs --- + + def _get_extra_tui_widgets(self) -> list: + """Return extra prompt_toolkit widgets to insert into the TUI layout. + + Wrapper CLIs can override this to inject widgets (e.g. a mini-player, + overlay menu) into the layout without overriding ``run()``. Widgets + are inserted between the spacer and the status bar. + """ + return [] + + def _register_extra_tui_keybindings(self, kb, *, input_area) -> None: + """Register extra keybindings on the TUI ``KeyBindings`` object. + + Wrapper CLIs can override this to add keybindings (e.g. transport + controls, modal shortcuts) without overriding ``run()``. + + Parameters + ---------- + kb : KeyBindings + The active keybinding registry for the prompt_toolkit application. + input_area : TextArea + The main input widget, for wrappers that need to inspect or + manipulate user input from a keybinding handler. + """ + + def _build_tui_layout_children( + self, + *, + sudo_widget, + secret_widget, + approval_widget, + clarify_widget, + spinner_widget, + spacer, + status_bar, + input_rule_top, + image_bar, + input_area, + input_rule_bot, + voice_status_bar, + completions_menu, + ) -> list: + """Assemble the ordered list of children for the root ``HSplit``. + + Wrapper CLIs typically override ``_get_extra_tui_widgets`` instead of + this method. Override this only when you need full control over widget + ordering. + """ + return [ + Window(height=0), + sudo_widget, + secret_widget, + approval_widget, + clarify_widget, + spinner_widget, + spacer, + *self._get_extra_tui_widgets(), + status_bar, + input_rule_top, + image_bar, + input_area, + input_rule_bot, + voice_status_bar, + completions_menu, + ] + def run(self): """Run the interactive CLI loop with persistent input at bottom.""" self.show_banner() @@ -6741,26 +6808,32 @@ class HermesCLI: filter=Condition(lambda: cli_ref._status_bar_visible), ) + # Allow wrapper CLIs to register extra keybindings. + self._register_extra_tui_keybindings(kb, input_area=input_area) + # Layout: interactive prompt widgets + ruled input at bottom. # The sudo, approval, and clarify widgets appear above the input when # the corresponding interactive prompt is active. + completions_menu = CompletionsMenu(max_height=12, scroll_offset=1) + layout = Layout( - HSplit([ - Window(height=0), - sudo_widget, - secret_widget, - approval_widget, - clarify_widget, - spinner_widget, - spacer, - status_bar, - input_rule_top, - image_bar, - input_area, - input_rule_bot, - voice_status_bar, - CompletionsMenu(max_height=12, scroll_offset=1), - ]) + HSplit( + self._build_tui_layout_children( + sudo_widget=sudo_widget, + secret_widget=secret_widget, + approval_widget=approval_widget, + clarify_widget=clarify_widget, + spinner_widget=spinner_widget, + spacer=spacer, + status_bar=status_bar, + input_rule_top=input_rule_top, + image_bar=image_bar, + input_area=input_area, + input_rule_bot=input_rule_bot, + voice_status_bar=voice_status_bar, + completions_menu=completions_menu, + ) + ) ) # Style for the application diff --git a/tests/test_cli_extension_hooks.py b/tests/test_cli_extension_hooks.py new file mode 100644 index 00000000..7599f244 --- /dev/null +++ b/tests/test_cli_extension_hooks.py @@ -0,0 +1,138 @@ +"""Tests for protected HermesCLI TUI extension hooks. + +Verifies that wrapper CLIs can extend the TUI via: + - _get_extra_tui_widgets() + - _register_extra_tui_keybindings() + - _build_tui_layout_children() +without overriding run(). +""" + +from __future__ import annotations + +import importlib +import sys +from unittest.mock import MagicMock, patch + +from prompt_toolkit.key_binding import KeyBindings + + +def _make_cli(**kwargs): + """Create a HermesCLI with prompt_toolkit stubs (same pattern as test_cli_init).""" + _clean_config = { + "model": { + "default": "anthropic/claude-opus-4.6", + "base_url": "https://openrouter.ai/api/v1", + "provider": "auto", + }, + "display": {"compact": False, "tool_progress": "all"}, + "agent": {}, + "terminal": {"env_type": "local"}, + } + clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""} + prompt_toolkit_stubs = { + "prompt_toolkit": MagicMock(), + "prompt_toolkit.history": MagicMock(), + "prompt_toolkit.styles": MagicMock(), + "prompt_toolkit.patch_stdout": MagicMock(), + "prompt_toolkit.application": MagicMock(), + "prompt_toolkit.layout": MagicMock(), + "prompt_toolkit.layout.processors": MagicMock(), + "prompt_toolkit.filters": MagicMock(), + "prompt_toolkit.layout.dimension": MagicMock(), + "prompt_toolkit.layout.menus": MagicMock(), + "prompt_toolkit.widgets": MagicMock(), + "prompt_toolkit.key_binding": MagicMock(), + "prompt_toolkit.completion": MagicMock(), + "prompt_toolkit.formatted_text": MagicMock(), + "prompt_toolkit.auto_suggest": MagicMock(), + } + with patch.dict(sys.modules, prompt_toolkit_stubs), patch.dict( + "os.environ", clean_env, clear=False + ): + import cli as _cli_mod + + _cli_mod = importlib.reload(_cli_mod) + with patch.object(_cli_mod, "get_tool_definitions", return_value=[]), patch.dict( + _cli_mod.__dict__, {"CLI_CONFIG": _clean_config} + ): + return _cli_mod.HermesCLI(**kwargs) + + +class TestExtensionHookDefaults: + def test_extra_tui_widgets_default_empty(self): + cli = _make_cli() + assert cli._get_extra_tui_widgets() == [] + + def test_register_extra_tui_keybindings_default_noop(self): + cli = _make_cli() + kb = KeyBindings() + result = cli._register_extra_tui_keybindings(kb, input_area=None) + assert result is None + assert kb.bindings == [] + + def test_build_tui_layout_children_returns_all_widgets_in_order(self): + cli = _make_cli() + children = cli._build_tui_layout_children( + sudo_widget="sudo", + secret_widget="secret", + approval_widget="approval", + clarify_widget="clarify", + spinner_widget="spinner", + spacer="spacer", + status_bar="status", + input_rule_top="top-rule", + image_bar="image-bar", + input_area="input-area", + input_rule_bot="bottom-rule", + voice_status_bar="voice-status", + completions_menu="completions-menu", + ) + # First element is Window(height=0), rest are the named widgets + assert children[1:] == [ + "sudo", "secret", "approval", "clarify", "spinner", + "spacer", "status", "top-rule", "image-bar", "input-area", + "bottom-rule", "voice-status", "completions-menu", + ] + + +class TestExtensionHookSubclass: + def test_extra_widgets_inserted_before_status_bar(self): + cli = _make_cli() + # Monkey-patch to simulate subclass override + cli._get_extra_tui_widgets = lambda: ["radio-menu", "mini-player"] + + children = cli._build_tui_layout_children( + sudo_widget="sudo", + secret_widget="secret", + approval_widget="approval", + clarify_widget="clarify", + spinner_widget="spinner", + spacer="spacer", + status_bar="status", + input_rule_top="top-rule", + image_bar="image-bar", + input_area="input-area", + input_rule_bot="bottom-rule", + voice_status_bar="voice-status", + completions_menu="completions-menu", + ) + # Extra widgets should appear between spacer and status bar + spacer_idx = children.index("spacer") + status_idx = children.index("status") + assert children[spacer_idx + 1] == "radio-menu" + assert children[spacer_idx + 2] == "mini-player" + assert children[spacer_idx + 3] == "status" + assert status_idx == spacer_idx + 3 + + def test_extra_keybindings_can_add_bindings(self): + cli = _make_cli() + kb = KeyBindings() + + def _custom_hook(kb, *, input_area): + @kb.add("f2") + def _toggle(event): + return None + + cli._register_extra_tui_keybindings = _custom_hook + cli._register_extra_tui_keybindings(kb, input_area=None) + assert len(kb.bindings) == 1 diff --git a/website/docs/developer-guide/extending-the-cli.md b/website/docs/developer-guide/extending-the-cli.md new file mode 100644 index 00000000..2625a21d --- /dev/null +++ b/website/docs/developer-guide/extending-the-cli.md @@ -0,0 +1,196 @@ +--- +sidebar_position: 8 +title: "Extending the CLI" +description: "Build wrapper CLIs that extend the Hermes TUI with custom widgets, keybindings, and layout changes" +--- + +# Extending the CLI + +Hermes exposes protected extension hooks on `HermesCLI` so wrapper CLIs can add widgets, keybindings, and layout customizations without overriding the 1000+ line `run()` method. This keeps your extension decoupled from internal changes. + +## Extension points + +There are five extension seams available: + +| Hook | Purpose | Override when... | +|------|---------|------------------| +| `_get_extra_tui_widgets()` | Inject widgets into the layout | You need a persistent UI element (panel, status line, mini-player) | +| `_register_extra_tui_keybindings(kb, *, input_area)` | Add keyboard shortcuts | You need hotkeys (toggle panels, transport controls, modal shortcuts) | +| `_build_tui_layout_children(**widgets)` | Full control over widget ordering | You need to reorder or wrap existing widgets (rare) | +| `process_command()` | Add custom slash commands | You need `/mycommand` handling (pre-existing hook) | +| `_build_tui_style_dict()` | Custom prompt_toolkit styles | You need custom colors or styling (pre-existing hook) | + +The first three are new protected hooks. The last two already existed. + +## Quick start: a wrapper CLI + +```python +#!/usr/bin/env python3 +"""my_cli.py β€” Example wrapper CLI that extends Hermes.""" + +from cli import HermesCLI +from prompt_toolkit.layout import FormattedTextControl, Window +from prompt_toolkit.filters import Condition + + +class MyCLI(HermesCLI): + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._panel_visible = False + + def _get_extra_tui_widgets(self): + """Add a toggleable info panel above the status bar.""" + cli_ref = self + return [ + Window( + FormattedTextControl(lambda: "πŸ“Š My custom panel content"), + height=1, + filter=Condition(lambda: cli_ref._panel_visible), + ), + ] + + def _register_extra_tui_keybindings(self, kb, *, input_area): + """F2 toggles the custom panel.""" + cli_ref = self + + @kb.add("f2") + def _toggle_panel(event): + cli_ref._panel_visible = not cli_ref._panel_visible + + def process_command(self, cmd: str) -> bool: + """Add a /panel slash command.""" + if cmd.strip().lower() == "/panel": + self._panel_visible = not self._panel_visible + state = "visible" if self._panel_visible else "hidden" + print(f"Panel is now {state}") + return True + return super().process_command(cmd) + + +if __name__ == "__main__": + cli = MyCLI() + cli.run() +``` + +Run it: + +```bash +cd ~/.hermes/hermes-agent +source .venv/bin/activate +python my_cli.py +``` + +## Hook reference + +### `_get_extra_tui_widgets()` + +Returns a list of prompt_toolkit widgets to insert into the TUI layout. Widgets appear **between the spacer and the status bar** β€” above the input area but below the main output. + +```python +def _get_extra_tui_widgets(self) -> list: + return [] # default: no extra widgets +``` + +Each widget should be a prompt_toolkit container (e.g., `Window`, `ConditionalContainer`, `HSplit`). Use `ConditionalContainer` or `filter=Condition(...)` to make widgets toggleable. + +```python +from prompt_toolkit.layout import ConditionalContainer, Window, FormattedTextControl +from prompt_toolkit.filters import Condition + +def _get_extra_tui_widgets(self): + return [ + ConditionalContainer( + Window(FormattedTextControl("Status: connected"), height=1), + filter=Condition(lambda: self._show_status), + ), + ] +``` + +### `_register_extra_tui_keybindings(kb, *, input_area)` + +Called after Hermes registers its own keybindings and before the layout is built. Add your keybindings to `kb`. + +```python +def _register_extra_tui_keybindings(self, kb, *, input_area): + pass # default: no extra keybindings +``` + +Parameters: +- **`kb`** β€” The `KeyBindings` instance for the prompt_toolkit application +- **`input_area`** β€” The main `TextArea` widget, if you need to read or manipulate user input + +```python +def _register_extra_tui_keybindings(self, kb, *, input_area): + cli_ref = self + + @kb.add("f3") + def _clear_input(event): + input_area.text = "" + + @kb.add("f4") + def _insert_template(event): + input_area.text = "/search " +``` + +**Avoid conflicts** with built-in keybindings: `Enter` (submit), `Escape Enter` (newline), `Ctrl-C` (interrupt), `Ctrl-D` (exit), `Tab` (auto-suggest accept). Function keys F2+ and Ctrl-combinations are generally safe. + +### `_build_tui_layout_children(**widgets)` + +Override this only when you need full control over widget ordering. Most extensions should use `_get_extra_tui_widgets()` instead. + +```python +def _build_tui_layout_children(self, *, sudo_widget, secret_widget, + approval_widget, clarify_widget, spinner_widget, spacer, + status_bar, input_rule_top, image_bar, input_area, + input_rule_bot, voice_status_bar, completions_menu) -> list: +``` + +The default implementation returns: + +```python +[ + Window(height=0), # anchor + sudo_widget, # sudo password prompt (conditional) + secret_widget, # secret input prompt (conditional) + approval_widget, # dangerous command approval (conditional) + clarify_widget, # clarify question UI (conditional) + spinner_widget, # thinking spinner (conditional) + spacer, # fills remaining vertical space + *self._get_extra_tui_widgets(), # YOUR WIDGETS GO HERE + status_bar, # model/token/context status line + input_rule_top, # ─── border above input + image_bar, # attached images indicator + input_area, # user text input + input_rule_bot, # ─── border below input + voice_status_bar, # voice mode status (conditional) + completions_menu, # autocomplete dropdown +] +``` + +## Layout diagram + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ (output scrolls here) β”‚ +β”‚ β”‚ +β”‚ spacer ────────│ +β”‚ β˜… Your extra widgets appear here β˜… β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ βš• claude-sonnet-4 Β· 42% Β· 2m status β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ πŸ“Ž 2 images image barβ”‚ +β”‚ ❯ your input here input area β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ 🎀 Voice mode: listening voice status β”‚ +β”‚ β–Έ completions... autocomplete β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Tips + +- **Invalidate the display** after state changes: call `self._invalidate()` to trigger a prompt_toolkit redraw. +- **Access agent state**: `self.agent`, `self.model`, `self.conversation_history` are all available. +- **Custom styles**: Override `_build_tui_style_dict()` and add entries for your custom style classes. +- **Slash commands**: Override `process_command()`, handle your commands, and call `super().process_command(cmd)` for everything else. +- **Don't override `run()`** unless absolutely necessary β€” the extension hooks exist specifically to avoid that coupling. diff --git a/website/sidebars.ts b/website/sidebars.ts index 2d515e8a..92a56bcc 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -129,6 +129,7 @@ const sidebars: SidebarsConfig = { 'developer-guide/environments', 'developer-guide/adding-tools', 'developer-guide/creating-skills', + 'developer-guide/extending-the-cli', 'developer-guide/contributing', ], },