Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,14 @@ display:
# false: Full ASCII banner with tool/skill summary (default)
compact: false

# Show the welcome banner at startup and on /new.
# Set to false for a minimal startup experience — the screen is still
# cleared, and diagnostic warnings (disabled tools, low context) still
# appear, but the ASCII-art / tool / skill summary panel is skipped.
# true: Render the welcome banner (default)
# false: Skip the banner for a clean startup
show_banner: true

# Tool progress display level (CLI and gateway)
# off: Silent — no tool activity shown, just the final response
# new: Show a tool indicator only when the tool changes (skip repeats)
Expand Down
101 changes: 55 additions & 46 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ def load_cli_config() -> Dict[str, Any]:

"display": {
"compact": False,
"show_banner": True,
"resume_display": "full",
# Recap tuning for /resume — see hermes_cli/config.py DEFAULT_CONFIG.
"resume_exchanges": 10,
Expand Down Expand Up @@ -3468,6 +3469,10 @@ def __init__(
self.console = Console()
self.config = CLI_CONFIG
self.compact = compact if compact is not None else CLI_CONFIG["display"].get("compact", False)
# show_banner: when False, suppress the ASCII-art / compact welcome
# banner at startup and on /new for a minimal "Ctrl+L" feel.
# Diagnostic warnings (disabled tools, low context) are still shown.
self.show_startup_banner = CLI_CONFIG["display"].get("show_banner", True)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please also add this default to both configuration schemas: cli.py's load_cli_config() defaults and hermes_cli/config.py's shared DEFAULT_CONFIG. The shared defaults are used by setup/reset and missing-option reporting, so the example file and this fallback alone leave the option outside those config surfaces.

# tool_progress: "off", "new", "all", "verbose" (from config.yaml display section)
# YAML 1.1 parses bare `off` as boolean False — normalise to string.
_raw_tp = CLI_CONFIG["display"].get("tool_progress", "all")
Expand Down Expand Up @@ -5855,33 +5860,36 @@ def show_banner(self):
ctx_len = None
if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'context_compressor'):
ctx_len = self.agent.context_compressor.context_length

# Auto-compact for narrow terminals — the full banner with caduceus
# + tool list needs ~80 columns minimum to render without wrapping.
term_width = shutil.get_terminal_size().columns
use_compact = self.compact or term_width < 80

if use_compact:
self._console_print(_build_compact_banner())
self._show_status()
else:
# Get tools for display
tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True)

# Get terminal working directory (where commands will execute)
cwd = os.getenv("TERMINAL_CWD", os.getcwd())

# Build and display the banner
build_welcome_banner(
console=self.console,
model=self.model,
cwd=cwd,
tools=tools,
enabled_toolsets=self.enabled_toolsets,
session_id=self.session_id,
context_length=ctx_len,
provider=self.provider,
)

# display.show_banner: false opts into a minimal startup — skip the
# visual banner entirely. Diagnostic warnings further below still run.
if self.show_startup_banner:
# Auto-compact for narrow terminals — the full banner with caduceus
# + tool list needs ~80 columns minimum to render without wrapping.
term_width = shutil.get_terminal_size().columns
use_compact = self.compact or term_width < 80

if use_compact:
self._console_print(_build_compact_banner())
self._show_status()
else:
# Get tools for display
tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True)

# Get terminal working directory (where commands will execute)
cwd = os.getenv("TERMINAL_CWD", os.getcwd())

# Build and display the banner
build_welcome_banner(
console=self.console,
model=self.model,
cwd=cwd,
tools=tools,
enabled_toolsets=self.enabled_toolsets,
session_id=self.session_id,
context_length=ctx_len,
provider=self.provider,
)

# Tool discovery is intentionally deferred on the Termux bare prompt
# path; availability warnings are shown once tools are initialized.
Expand Down Expand Up @@ -8132,25 +8140,26 @@ def process_command(self, command: str) -> bool:
# and gets mangled by patch_stdout).
if self._app:
cc = ChatConsole()
term_w = shutil.get_terminal_size().columns
if self.compact or term_w < 80:
cc.print(_build_compact_banner())
else:
tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True)
cwd = os.getenv("TERMINAL_CWD", os.getcwd())
ctx_len = None
if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'context_compressor'):
ctx_len = self.agent.context_compressor.context_length
build_welcome_banner(
console=cc,
model=self.model,
cwd=cwd,
tools=tools,
enabled_toolsets=self.enabled_toolsets,
session_id=self.session_id,
context_length=ctx_len,
provider=self.provider,
)
if self.show_startup_banner:
term_w = shutil.get_terminal_size().columns
if self.compact or term_w < 80:
cc.print(_build_compact_banner())
else:
tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True)
cwd = os.getenv("TERMINAL_CWD", os.getcwd())
ctx_len = None
if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'context_compressor'):
ctx_len = self.agent.context_compressor.context_length
build_welcome_banner(
console=cc,
model=self.model,
cwd=cwd,
tools=tools,
enabled_toolsets=self.enabled_toolsets,
session_id=self.session_id,
context_length=ctx_len,
provider=self.provider,
)
_cprint(" ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n")
# Show a random tip on new session
try:
Expand Down
1 change: 1 addition & 0 deletions tests/cli/test_cli_context_warning.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def cli_obj(_isolate):
obj.model = "test-model"
obj.enabled_toolsets = ["hermes-core"]
obj.compact = False
obj.show_startup_banner = True
obj.console = MagicMock()
obj.session_id = None
obj.api_key = "test"
Expand Down
202 changes: 202 additions & 0 deletions tests/cli/test_cli_show_banner_option.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
"""Tests for the display.show_banner config option.

The option (default true) lets users skip the welcome banner at startup
and on /new for a minimal startup experience — without disabling the
diagnostic warnings that follow.
"""

from __future__ import annotations

import importlib
import os
import sys
from contextlib import contextmanager
from unittest.mock import MagicMock, patch


def _make_real_cli(show_banner_value, **kwargs):
"""Build a HermesCLI instance and also return its bound cli module.

``_make_real_cli`` from sibling test files only returns the instance, but
after ``importlib.reload`` inside a ``patch.dict(sys.modules, ...)`` block
the reloaded module is dropped from ``sys.modules`` on exit. A later
``import cli`` then yields a *different* module than the one cli_obj's
class methods reference via __globals__. We need a handle on the right
module to patch attributes that show_banner() looks up.
"""
clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
"display": {
"compact": False,
"tool_progress": "all",
"show_banner": show_banner_value,
},
"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(),
}
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), cli_mod


@contextmanager
def _patch_banner_calls(cli_mod):
"""Patch the banner helpers + terminal size for the test's cli module.

Uses patch.object on the bound cli_mod (not "cli.foo") because the cli_obj
holds a reference to the reloaded module which gets dropped from
sys.modules — a top-level "import cli" patch would target a different
module than the one show_banner actually looks up.
"""
with (
patch.object(cli_mod, "build_welcome_banner") as mock_banner,
patch.object(cli_mod, "_build_compact_banner") as mock_compact,
patch.object(cli_mod, "get_tool_definitions", return_value=[]),
patch.object(
cli_mod.shutil,
"get_terminal_size",
return_value=os.terminal_size((120, 40)),
),
):
yield mock_banner, mock_compact


def test_show_banner_renders_when_show_banner_true():
cli_obj, cli_mod = _make_real_cli(show_banner_value=True, compact=False)
cli_obj.console = MagicMock()

with _patch_banner_calls(cli_mod) as (mock_banner, mock_compact):
cli_obj.show_banner()

assert mock_banner.call_count == 1
assert mock_compact.call_count == 0


def test_show_banner_skipped_when_show_banner_false():

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests exercise show_banner() directly, but the PR also changes the prompt-toolkit /new branch. Please add a show_banner: false /new regression test that verifies banner helpers are skipped while the fresh-start message and diagnostic warnings remain.

"""display.show_banner: false suppresses both full and compact banners."""
cli_obj, cli_mod = _make_real_cli(show_banner_value=False, compact=False)
cli_obj.console = MagicMock()

with _patch_banner_calls(cli_mod) as (mock_banner, mock_compact), patch.object(
cli_obj, "_show_tool_availability_warnings"
) as mock_warnings:
cli_obj.show_banner()

# Screen is still cleared, but no banner gets rendered.
cli_obj.console.clear.assert_called_once()
assert mock_banner.call_count == 0
assert mock_compact.call_count == 0
mock_warnings.assert_called_once()


def test_show_banner_skipped_in_compact_mode_when_disabled():
"""Even when compact=True, show_banner=False suppresses the compact banner."""
cli_obj, cli_mod = _make_real_cli(show_banner_value=False, compact=True)
cli_obj.console = MagicMock()

with _patch_banner_calls(cli_mod) as (mock_banner, mock_compact):
cli_obj.show_banner()

assert mock_banner.call_count == 0
assert mock_compact.call_count == 0


def test_show_banner_defaults_to_true_when_missing():
"""When display.show_banner is not configured, the banner is shown (back-compat)."""
clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
# No show_banner key — exercise the default.
"display": {"compact": False, "tool_progress": "all"},
"agent": {},
"terminal": {"env_type": "local"},
}
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(),
}
with (
patch.dict(sys.modules, prompt_toolkit_stubs),
patch.dict(
"os.environ", {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}, 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}),
):
cli_obj = cli_mod.HermesCLI(compact=False)

assert cli_obj.show_startup_banner is True


def test_clear_in_prompt_toolkit_skips_banner_when_disabled():
"""The prompt-toolkit /clear path honors display.show_banner."""
cli_obj, cli_mod = _make_real_cli(show_banner_value=False, compact=False)
cli_obj._pending_resume_sessions = None
cli_obj._app = MagicMock()
cli_obj._confirm_destructive_slash = MagicMock(return_value=True)
cli_obj.new_session = MagicMock()

with (
patch.object(cli_mod, "ChatConsole") as mock_chat_console,
patch.object(cli_mod, "build_welcome_banner") as mock_banner,
patch.object(cli_mod, "_build_compact_banner") as mock_compact,
):
assert cli_obj.process_command("/clear") is True

cli_obj.new_session.assert_called_once_with(silent=True)
cli_obj._app.output.erase_screen.assert_called_once()
cli_obj._app.output.cursor_goto.assert_called_once_with(0, 0)
cli_obj._app.output.flush.assert_called_once()
mock_banner.assert_not_called()
mock_compact.assert_not_called()
mock_chat_console.return_value.print.assert_called()
Loading