fix: CLI/UX batch — ChatConsole errors, curses scroll, skin-aware banner, git state banner (#5974)

* fix(cli): route error messages through ChatConsole inside patch_stdout

Cherry-pick of PR #5798 by @icn5381.

Replace self.console.print() with ChatConsole().print() for 11 error/status
messages reachable during the interactive session. Inside patch_stdout,
self.console (plain Rich Console) writes raw ANSI escapes that StdoutProxy
mangles into garbled text. ChatConsole uses prompt_toolkit's native
print_formatted_text which renders correctly.

Same class of bug as #2262 — that fix covered agent output but missed
these error paths in _ensure_runtime_credentials, _init_agent, quick
commands, skill loading, and plan mode.

* fix(model-picker): add scrolling viewport to curses provider menu

Cherry-pick of PR #5790 by @Lempkey. Fixes #5755.

_curses_prompt_choice rendered items starting unconditionally from index 0
with no scroll offset. The 'More providers' submenu has 13 entries. On
terminals shorter than ~16 rows, items past the fold were never drawn.
When UP-arrow wrapped cursor from 0 to the last item (Cancel, index 12),
the highlight rendered off-screen — appearing as if only Cancel existed.

Adds scroll_offset tracking that adjusts each frame to keep the cursor
inside the visible window.

* feat(cli): skin-aware compact banner + git state in startup banner

Combined salvage of PR #5922 by @ASRagab and PR #5877 by @xinbenlv.

Compact banner changes (from #5922):
- Read active skin colors and branding instead of hardcoding gold/NOUS HERMES
- Default skin preserves backward-compatible legacy branding
- Non-default skins use their own agent_name and colors

Git state in banner (from #5877):
- New format_banner_version_label() shows upstream/local git hashes
- Full banner title now includes git state (upstream hash, carried commits)
- Compact banner line2 shows the version label with git state
- Widen compact banner max width from 64 to 88 to fit version info

Both the full Rich banner and compact fallback are now skin-aware
and show git state.
This commit is contained in:
Teknium 2026-04-07 17:59:42 -07:00 committed by GitHub
parent f3c59321af
commit 9692b3c28a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 456 additions and 26 deletions

67
cli.py
View file

@ -63,7 +63,7 @@ from agent.usage_pricing import (
format_duration_compact,
format_token_count_compact,
)
from hermes_cli.banner import _format_context_length
from hermes_cli.banner import _format_context_length, format_banner_version_label
_COMMAND_SPINNER_FRAMES = ("", "", "", "", "", "", "", "", "", "")
@ -1036,21 +1036,44 @@ COMPACT_BANNER = """
def _build_compact_banner() -> str:
"""Build a compact banner that fits the current terminal width."""
w = min(shutil.get_terminal_size().columns - 2, 64)
try:
from hermes_cli.skin_engine import get_active_skin
_skin = get_active_skin()
except Exception:
_skin = None
skin_name = getattr(_skin, "name", "default") if _skin else "default"
border_color = _skin.get_color("banner_border", "#FFD700") if _skin else "#FFD700"
title_color = _skin.get_color("banner_title", "#FFBF00") if _skin else "#FFBF00"
dim_color = _skin.get_color("banner_dim", "#B8860B") if _skin else "#B8860B"
if skin_name == "default":
line1 = "⚕ NOUS HERMES - AI Agent Framework"
tiny_line = "⚕ NOUS HERMES"
else:
agent_name = _skin.get_branding("agent_name", "Hermes Agent") if _skin else "Hermes Agent"
line1 = f"{agent_name} - AI Agent Framework"
tiny_line = agent_name
version_line = format_banner_version_label()
w = min(shutil.get_terminal_size().columns - 2, 88)
if w < 30:
return "\n[#FFBF00]⚕ NOUS HERMES[/] [dim #B8860B]- Nous Research[/]\n"
return f"\n[{title_color}]{tiny_line}[/] [dim {dim_color}]- Nous Research[/]\n"
inner = w - 2 # inside the box border
bar = "" * w
line1 = "⚕ NOUS HERMES - AI Agent Framework"
line2 = "Messenger of the Digital Gods · Nous Research"
content_width = inner - 2
# Truncate and pad to fit
line1 = line1[:inner - 2].ljust(inner - 2)
line2 = line2[:inner - 2].ljust(inner - 2)
line1 = line1[:content_width].ljust(content_width)
line2 = version_line[:content_width].ljust(content_width)
return (
f"\n[bold #FFD700]╔{bar}╗[/]\n"
f"[bold #FFD700]║[/] [#FFBF00]{line1}[/] [bold #FFD700]║[/]\n"
f"[bold #FFD700]║[/] [dim #B8860B]{line2}[/] [bold #FFD700]║[/]\n"
f"[bold #FFD700]╚{bar}╝[/]\n"
f"\n[bold {border_color}]╔{bar}╗[/]\n"
f"[bold {border_color}]║[/] [{title_color}]{line1}[/] [bold {border_color}]║[/]\n"
f"[bold {border_color}]║[/] [dim {dim_color}]{line2}[/] [bold {border_color}]║[/]\n"
f"[bold {border_color}]╚{bar}╝[/]\n"
)
@ -2163,7 +2186,7 @@ class HermesCLI:
)
except Exception as exc:
message = format_runtime_provider_error(exc)
self.console.print(f"[bold red]{message}[/]")
ChatConsole().print(f"[bold red]{message}[/]")
return False
api_key = runtime.get("api_key")
@ -2378,7 +2401,7 @@ class HermesCLI:
self._pending_title = None
return True
except Exception as e:
self.console.print(f"[bold red]Failed to initialize agent: {e}[/]")
ChatConsole().print(f"[bold red]Failed to initialize agent: {e}[/]")
return False
def show_banner(self):
@ -4530,13 +4553,13 @@ class HermesCLI:
if output:
self.console.print(_rich_text_from_ansi(output))
else:
self.console.print("[dim]Command returned no output[/]")
ChatConsole().print("[dim]Command returned no output[/]")
except subprocess.TimeoutExpired:
self.console.print("[bold red]Quick command timed out (30s)[/]")
ChatConsole().print("[bold red]Quick command timed out (30s)[/]")
except Exception as e:
self.console.print(f"[bold red]Quick command error: {e}[/]")
ChatConsole().print(f"[bold red]Quick command error: {e}[/]")
else:
self.console.print(f"[bold red]Quick command '{base_cmd}' has no command defined[/]")
ChatConsole().print(f"[bold red]Quick command '{base_cmd}' has no command defined[/]")
elif qcmd.get("type") == "alias":
target = qcmd.get("target", "").strip()
if target:
@ -4545,9 +4568,9 @@ class HermesCLI:
aliased_command = f"{target} {user_args}".strip()
return self.process_command(aliased_command)
else:
self.console.print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]")
ChatConsole().print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]")
else:
self.console.print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]")
ChatConsole().print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]")
# Check for plugin-registered slash commands
elif base_cmd.lstrip("/") in _get_plugin_cmd_handler_names():
from hermes_cli.plugins import get_plugin_command_handler
@ -4572,7 +4595,7 @@ class HermesCLI:
if hasattr(self, '_pending_input'):
self._pending_input.put(msg)
else:
self.console.print(f"[bold red]Failed to load skill for {base_cmd}[/]")
ChatConsole().print(f"[bold red]Failed to load skill for {base_cmd}[/]")
else:
# Prefix matching: if input uniquely identifies one command, execute it.
# Matches against both built-in COMMANDS and installed skill commands so
@ -4633,14 +4656,14 @@ class HermesCLI:
)
if not msg:
self.console.print("[bold red]Failed to load the bundled /plan skill[/]")
ChatConsole().print("[bold red]Failed to load the bundled /plan skill[/]")
return
_cprint(f" 📝 Plan mode queued via skill. Markdown plan target: {plan_path}")
if hasattr(self, '_pending_input'):
self._pending_input.put(msg)
else:
self.console.print("[bold red]Plan mode unavailable: input queue not initialized[/]")
ChatConsole().print("[bold red]Plan mode unavailable: input queue not initialized[/]")
def _handle_background_command(self, cmd: str):
"""Handle /background <prompt> — run a prompt in a separate background session.