Based on PR #1749 by @erosika (reimplemented on current main). Extracts three protected methods from run() so wrapper CLIs can extend the TUI without overriding the entire method: - _get_extra_tui_widgets(): inject widgets between spacer and status bar - _register_extra_tui_keybindings(kb, input_area): add keybindings - _build_tui_layout_children(**widgets): full control over ordering Default implementations reproduce existing layout exactly. The inline HSplit in run() now delegates to _build_tui_layout_children(). 5 tests covering defaults, widget insertion position, and keybinding registration.
197 lines
7.6 KiB
Markdown
197 lines
7.6 KiB
Markdown
---
|
||
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.
|