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.
7.6 KiB
sidebar_position, title, description
| sidebar_position | title | description |
|---|---|---|
| 8 | Extending the CLI | 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
#!/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:
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.
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.
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.
def _register_extra_tui_keybindings(self, kb, *, input_area):
pass # default: no extra keybindings
Parameters:
kb— TheKeyBindingsinstance for the prompt_toolkit applicationinput_area— The mainTextAreawidget, if you need to read or manipulate user input
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.
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:
[
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_historyare 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 callsuper().process_command(cmd)for everything else. - Don't override
run()unless absolutely necessary — the extension hooks exist specifically to avoid that coupling.