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.
|