refactor(cli): add protected TUI extension hooks for wrapper CLIs
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.
This commit is contained in:
105
cli.py
105
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
|
||||
|
||||
138
tests/test_cli_extension_hooks.py
Normal file
138
tests/test_cli_extension_hooks.py
Normal file
@@ -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
|
||||
196
website/docs/developer-guide/extending-the-cli.md
Normal file
196
website/docs/developer-guide/extending-the-cli.md
Normal file
@@ -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.
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user