mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(cli): restore stacked tool progress scrollback in TUI (#8201)
The TUI transition (4970705,f83e86d) replaced stacked per-tool history lines with a single live-updating spinner widget. While the spinner provides a nice live timer, it removed the scrollback history that users relied on to see what the agent did during a session. This restores stacked tool progress lines in 'all' and 'new' modes by printing persistent scrollback lines via _cprint() when tools complete, in addition to the existing live spinner display. Behavior per mode: - off: no scrollback lines, no spinner (unchanged) - new: scrollback line on completion, skipping consecutive same-tool repeats - all: scrollback line on every tool completion - verbose: no scrollback (run_agent.py handles verbose output directly) Implementation: - Store function_args from tool.started events in _pending_tool_info - On tool.completed, pop stored args and format via get_cute_tool_message() - FIFO queue per function_name handles concurrent tool execution - 'new' mode tracks _last_scrollback_tool for dedup - State cleared at end of agent run Reported by community user Mr.D — the stacked history provides transparency into what the agent is doing, which builds trust. Addresses user report from Discord about lost tool call visibility.
This commit is contained in:
parent
8e00b3a69e
commit
4cadfef8e3
2 changed files with 223 additions and 0 deletions
34
cli.py
34
cli.py
|
|
@ -1822,6 +1822,8 @@ class HermesCLI:
|
|||
self._secret_deadline = 0
|
||||
self._spinner_text: str = "" # thinking spinner text for TUI
|
||||
self._tool_start_time: float = 0.0 # monotonic timestamp when current tool started (for live elapsed)
|
||||
self._pending_tool_info: dict = {} # function_name -> list of (preview, args) for stacked scrollback
|
||||
self._last_scrollback_tool: str = "" # last tool name printed to scrollback (for "new" dedup)
|
||||
self._command_running = False
|
||||
self._command_status = ""
|
||||
self._attached_images: list[Path] = []
|
||||
|
|
@ -6560,10 +6562,36 @@ class HermesCLI:
|
|||
On tool.started, records a monotonic timestamp so get_spinner_text()
|
||||
can show a live elapsed timer (the TUI poll loop already invalidates
|
||||
every ~0.15s, so the counter updates automatically).
|
||||
|
||||
When tool_progress_mode is "all" or "new", also prints a persistent
|
||||
stacked line to scrollback on tool.completed so users can see the
|
||||
full history of tool calls (not just the current one in the spinner).
|
||||
"""
|
||||
if event_type == "tool.completed":
|
||||
import time as _time
|
||||
self._tool_start_time = 0.0
|
||||
# Print stacked scrollback line for "all" / "new" modes
|
||||
if function_name and self.tool_progress_mode in ("all", "new"):
|
||||
duration = kwargs.get("duration", 0.0)
|
||||
is_error = kwargs.get("is_error", False)
|
||||
# Pop stored args from tool.started for this function
|
||||
stored = self._pending_tool_info.get(function_name)
|
||||
stored_args = stored.pop(0) if stored else {}
|
||||
if stored is not None and not stored:
|
||||
del self._pending_tool_info[function_name]
|
||||
# "new" mode: skip consecutive repeats of the same tool
|
||||
if self.tool_progress_mode == "new" and function_name == self._last_scrollback_tool:
|
||||
self._invalidate()
|
||||
return
|
||||
self._last_scrollback_tool = function_name
|
||||
try:
|
||||
from agent.display import get_cute_tool_message
|
||||
line = get_cute_tool_message(function_name, stored_args, duration)
|
||||
if is_error:
|
||||
line = f"{line} [error]"
|
||||
_cprint(f" {line}")
|
||||
except Exception:
|
||||
pass
|
||||
self._invalidate()
|
||||
return
|
||||
if event_type != "tool.started":
|
||||
|
|
@ -6579,6 +6607,10 @@ class HermesCLI:
|
|||
label = label[:_pl - 3] + "..."
|
||||
self._spinner_text = f"{emoji} {label}"
|
||||
self._tool_start_time = _time.monotonic()
|
||||
# Store args for stacked scrollback line on completion
|
||||
self._pending_tool_info.setdefault(function_name, []).append(
|
||||
function_args if function_args is not None else {}
|
||||
)
|
||||
self._invalidate()
|
||||
|
||||
if not self._voice_mode:
|
||||
|
|
@ -9419,6 +9451,8 @@ class HermesCLI:
|
|||
self._agent_running = False
|
||||
self._spinner_text = ""
|
||||
self._tool_start_time = 0.0
|
||||
self._pending_tool_info.clear()
|
||||
self._last_scrollback_tool = ""
|
||||
|
||||
app.invalidate() # Refresh status line
|
||||
|
||||
|
|
|
|||
189
tests/cli/test_tool_progress_scrollback.py
Normal file
189
tests/cli/test_tool_progress_scrollback.py
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
"""Tests for stacked tool progress scrollback lines in the CLI TUI.
|
||||
|
||||
When tool_progress_mode is "all" or "new", _on_tool_progress should print
|
||||
persistent lines to scrollback on tool.completed, restoring the stacked
|
||||
tool history that was lost when the TUI switched to a single-line spinner.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import importlib
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
# Module-level reference to the cli module (set by _make_cli on first call)
|
||||
_cli_mod = None
|
||||
|
||||
|
||||
def _make_cli(tool_progress="all"):
|
||||
"""Create a HermesCLI instance with minimal mocking."""
|
||||
global _cli_mod
|
||||
_clean_config = {
|
||||
"model": {
|
||||
"default": "anthropic/claude-opus-4.6",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"provider": "auto",
|
||||
},
|
||||
"display": {"compact": False, "tool_progress": tool_progress},
|
||||
"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 mod
|
||||
mod = importlib.reload(mod)
|
||||
_cli_mod = mod
|
||||
with patch.object(mod, "get_tool_definitions", return_value=[]), \
|
||||
patch.dict(mod.__dict__, {"CLI_CONFIG": _clean_config}):
|
||||
return mod.HermesCLI()
|
||||
|
||||
|
||||
class TestToolProgressScrollback:
|
||||
"""Stacked scrollback lines for 'all' and 'new' modes."""
|
||||
|
||||
def test_all_mode_prints_scrollback_on_completed(self):
|
||||
"""In 'all' mode, tool.completed prints a stacked line."""
|
||||
cli = _make_cli(tool_progress="all")
|
||||
# Simulate tool.started
|
||||
cli._on_tool_progress("tool.started", "terminal", "git log", {"command": "git log"})
|
||||
# Simulate tool.completed
|
||||
with patch.object(_cli_mod, "_cprint") as mock_print:
|
||||
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=1.5, is_error=False)
|
||||
|
||||
mock_print.assert_called_once()
|
||||
line = mock_print.call_args[0][0]
|
||||
# Should contain tool info (the cute message format has "git log" for terminal)
|
||||
assert "git log" in line or "$" in line
|
||||
|
||||
def test_all_mode_prints_every_call(self):
|
||||
"""In 'all' mode, consecutive calls to the same tool each get a line."""
|
||||
cli = _make_cli(tool_progress="all")
|
||||
with patch.object(_cli_mod, "_cprint") as mock_print:
|
||||
# First call
|
||||
cli._on_tool_progress("tool.started", "read_file", "cli.py", {"path": "cli.py"})
|
||||
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.1, is_error=False)
|
||||
# Second call (same tool)
|
||||
cli._on_tool_progress("tool.started", "read_file", "run_agent.py", {"path": "run_agent.py"})
|
||||
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.2, is_error=False)
|
||||
|
||||
assert mock_print.call_count == 2
|
||||
|
||||
def test_new_mode_skips_consecutive_repeats(self):
|
||||
"""In 'new' mode, consecutive calls to the same tool only print once."""
|
||||
cli = _make_cli(tool_progress="new")
|
||||
with patch.object(_cli_mod, "_cprint") as mock_print:
|
||||
cli._on_tool_progress("tool.started", "read_file", "cli.py", {"path": "cli.py"})
|
||||
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.1, is_error=False)
|
||||
cli._on_tool_progress("tool.started", "read_file", "run_agent.py", {"path": "run_agent.py"})
|
||||
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.2, is_error=False)
|
||||
|
||||
assert mock_print.call_count == 1 # Only the first read_file
|
||||
|
||||
def test_new_mode_prints_when_tool_changes(self):
|
||||
"""In 'new' mode, a different tool name triggers a new line."""
|
||||
cli = _make_cli(tool_progress="new")
|
||||
with patch.object(_cli_mod, "_cprint") as mock_print:
|
||||
cli._on_tool_progress("tool.started", "read_file", "cli.py", {"path": "cli.py"})
|
||||
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.1, is_error=False)
|
||||
cli._on_tool_progress("tool.started", "search_files", "pattern", {"pattern": "test"})
|
||||
cli._on_tool_progress("tool.completed", "search_files", None, None, duration=0.3, is_error=False)
|
||||
cli._on_tool_progress("tool.started", "read_file", "run_agent.py", {"path": "run_agent.py"})
|
||||
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.2, is_error=False)
|
||||
|
||||
# read_file, search_files, read_file (3rd prints because search_files broke the streak)
|
||||
assert mock_print.call_count == 3
|
||||
|
||||
def test_off_mode_no_scrollback(self):
|
||||
"""In 'off' mode, no stacked lines are printed."""
|
||||
cli = _make_cli(tool_progress="off")
|
||||
with patch.object(_cli_mod, "_cprint") as mock_print:
|
||||
cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"})
|
||||
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=False)
|
||||
|
||||
mock_print.assert_not_called()
|
||||
|
||||
def test_error_suffix_on_failed_tool(self):
|
||||
"""When is_error=True, the stacked line includes [error]."""
|
||||
cli = _make_cli(tool_progress="all")
|
||||
cli._on_tool_progress("tool.started", "terminal", "bad cmd", {"command": "bad cmd"})
|
||||
with patch.object(_cli_mod, "_cprint") as mock_print:
|
||||
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=True)
|
||||
|
||||
line = mock_print.call_args[0][0]
|
||||
assert "[error]" in line
|
||||
|
||||
def test_spinner_still_updates_on_started(self):
|
||||
"""tool.started still updates the spinner text for live display."""
|
||||
cli = _make_cli(tool_progress="all")
|
||||
cli._on_tool_progress("tool.started", "terminal", "git status", {"command": "git status"})
|
||||
assert "git status" in cli._spinner_text
|
||||
|
||||
def test_spinner_timer_clears_on_completed(self):
|
||||
"""tool.completed still clears the tool timer."""
|
||||
cli = _make_cli(tool_progress="all")
|
||||
cli._on_tool_progress("tool.started", "terminal", "git status", {"command": "git status"})
|
||||
assert cli._tool_start_time > 0
|
||||
with patch.object(_cli_mod, "_cprint"):
|
||||
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=False)
|
||||
assert cli._tool_start_time == 0.0
|
||||
|
||||
def test_concurrent_tools_produce_stacked_lines(self):
|
||||
"""Multiple tool.started followed by multiple tool.completed all produce lines."""
|
||||
cli = _make_cli(tool_progress="all")
|
||||
with patch.object(_cli_mod, "_cprint") as mock_print:
|
||||
# All start first (concurrent pattern)
|
||||
cli._on_tool_progress("tool.started", "web_search", "query 1", {"query": "test 1"})
|
||||
cli._on_tool_progress("tool.started", "web_search", "query 2", {"query": "test 2"})
|
||||
# All complete
|
||||
cli._on_tool_progress("tool.completed", "web_search", None, None, duration=1.0, is_error=False)
|
||||
cli._on_tool_progress("tool.completed", "web_search", None, None, duration=1.5, is_error=False)
|
||||
|
||||
assert mock_print.call_count == 2
|
||||
|
||||
def test_verbose_mode_no_duplicate_scrollback(self):
|
||||
"""In 'verbose' mode, scrollback lines are NOT printed (run_agent handles verbose output)."""
|
||||
cli = _make_cli(tool_progress="verbose")
|
||||
with patch.object(_cli_mod, "_cprint") as mock_print:
|
||||
cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"})
|
||||
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=False)
|
||||
|
||||
mock_print.assert_not_called()
|
||||
|
||||
def test_pending_info_stores_on_started(self):
|
||||
"""tool.started stores args for later use by tool.completed."""
|
||||
cli = _make_cli(tool_progress="all")
|
||||
cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"})
|
||||
assert "terminal" in cli._pending_tool_info
|
||||
assert len(cli._pending_tool_info["terminal"]) == 1
|
||||
assert cli._pending_tool_info["terminal"][0] == {"command": "ls"}
|
||||
|
||||
def test_pending_info_consumed_on_completed(self):
|
||||
"""tool.completed consumes stored args (FIFO for concurrent)."""
|
||||
cli = _make_cli(tool_progress="all")
|
||||
cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"})
|
||||
cli._on_tool_progress("tool.started", "terminal", "pwd", {"command": "pwd"})
|
||||
assert len(cli._pending_tool_info["terminal"]) == 2
|
||||
with patch.object(_cli_mod, "_cprint"):
|
||||
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.1, is_error=False)
|
||||
# First entry consumed, second remains
|
||||
assert len(cli._pending_tool_info.get("terminal", [])) == 1
|
||||
assert cli._pending_tool_info["terminal"][0] == {"command": "pwd"}
|
||||
Loading…
Add table
Add a link
Reference in a new issue