| --- |
| 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 |
| |
| The default layout from top to bottom: |
| |
| 1. **Output area** β scrolling conversation history |
| 2. **Spacer** |
| 3. **Extra widgets** β from `_get_extra_tui_widgets()` |
| 4. **Status bar** β model, context %, elapsed time |
| 5. **Image bar** β attached image count |
| 6. **Input area** β user prompt |
| 7. **Voice status** β recording indicator |
| 8. **Completions menu** β autocomplete suggestions |
| |
| ## 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. |
|
|